From 551b5f3611306de31d4d0776ecb76820d1ce5fbf Mon Sep 17 00:00:00 2001 From: enne2 Date: Thu, 24 Sep 2026 12:15:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20skill=20hub=20=E2=80=94=20distribuzione?= =?UTF-8?q?=20skill=20via=20Gitea,=20indicizzazione=20e=20ricerca?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extensions/skill-hub.ts: skill_search (FTS su frontmatter+body+references), skill_info, skill_sync/commando /skill-sync, iniezione istruzioni via before_agent_start (append al systemPrompt, mai replace) - skills/sap-timesheet: prima skill migrata (SAP CATS timesheet) - distribuzione: pi package (keyword pi-package), install git:git.enne2.net/enne2/pi-skill-hub --- extensions/skill-hub.ts | 321 ++++++++++++++++++ package.json | 15 + skills/sap-timesheet/SKILL.md | 122 +++++++ .../references/best-practice-controlli.md | 32 ++ .../sap-timesheet/references/errori-noti.md | 57 ++++ .../references/procedura-tecnica.md | 69 ++++ skills/sap-timesheet/scripts/bridge_helper.py | 118 +++++++ 7 files changed, 734 insertions(+) create mode 100644 extensions/skill-hub.ts create mode 100644 package.json create mode 100644 skills/sap-timesheet/SKILL.md create mode 100644 skills/sap-timesheet/references/best-practice-controlli.md create mode 100644 skills/sap-timesheet/references/errori-noti.md create mode 100644 skills/sap-timesheet/references/procedura-tecnica.md create mode 100644 skills/sap-timesheet/scripts/bridge_helper.py diff --git a/extensions/skill-hub.ts b/extensions/skill-hub.ts new file mode 100644 index 0000000..38ae30f --- /dev/null +++ b/extensions/skill-hub.ts @@ -0,0 +1,321 @@ +/** + * skill-hub.ts — Indicizzazione, ricerca e caricamento dinamico delle skill + * condivise del package pi-skill-hub. + * + * - `skill_search` : ricerca testuale su frontmatter, corpo e references + * - `skill_info` : dettaglio di una skill (frontmatter + body + references) + * - `skill_sync` : ricostruisce l'indice (scan a mtime) + * - `before_agent_start`: iniezione di istruzioni brevi nel system prompt + * + * Il routing primario resta quello nativo di pi (description nel frontmatter); + * skill_search è il fallback per ricerca profonda o task non riconosciuti. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills"); +const MAX_CHARS_PER_SKILL = 200_000; +const MAX_BODY_IN_RESULT = 12_000; + +type SkillEntry = { + name: string; + dir: string; + skillMdPath: string; + description: string; + tags: string[]; + body: string; + references: { path: string; name: string; text: string }[]; + scripts: string[]; + mtime: number; +}; + +type IndexState = { + entries: SkillEntry[]; + fingerprints: Map; + builtAt: number; +}; + +const state: { index?: IndexState } = {}; + +// ---------------------------------------------------------------- utilities + +function tokenize(text: string): string[] { + return text + .toLowerCase() + .split(/[^a-z0-9àèéìòù#._-]+/i) + .filter((t) => t.length > 1); +} + +/** Parser frontmatter tollerante: name, description (inline o folded '>'), metadata (tags). */ +function parseFrontmatter(raw: string): { + name: string; + description: string; + tags: string[]; + body: string; +} { + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + const fm = m ? m[1] : ""; + const body = m ? raw.slice(m[0].length) : raw; + + let name = ""; + let description = ""; + let descMode = false; + let inMetadata = false; + const tags: string[] = []; + + for (const line of fm.split(/\r?\n/)) { + const top = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (top) { + inMetadata = top[1] === "metadata"; + if (top[1] === "name") { + name = top[2].trim(); + descMode = false; + continue; + } + if (top[1] === "description") { + const rest = top[2].trim(); + descMode = rest === ">" || rest === ">-" || rest === "|"; + description = descMode ? "" : rest.replace(/^["']|["']$/g, ""); + continue; + } + descMode = false; + continue; + } + if (descMode) { + description += (description ? " " : "") + line.trim(); + continue; + } + if (inMetadata) { + const t = line.trim(); + if (/^(tags|keywords):\s*/.test(t)) { + const rest = t.replace(/^(tags|keywords):\s*/, ""); + if (rest) tags.push(...rest.split(/[,\s]+/).filter(Boolean)); + else tags.push(...[]); // valori su righe successive + continue; + } + if (/^-\s*/.test(t)) tags.push(t.replace(/^-\s*/, "")); + } + } + return { name, description: description.trim(), tags, body }; +} + +function listDir(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + } catch { + return []; + } +} + +function readClamped(path: string): string { + try { + const buf = readFileSync(path); + return buf.length > MAX_CHARS_PER_SKILL + ? buf.subarray(0, MAX_CHARS_PER_SKILL).toString("utf8") + : buf.toString("utf8"); + } catch { + return ""; + } +} + +function fingerprint(dir: string): number { + let acc = 0; + try { + for (const name of listDir(dir)) { + const sdir = join(dir, name); + acc += statSync(join(sdir, "SKILL.md")).mtimeMs; + } + } catch { + /* ignore */ + } + return acc; +} + +// ---------------------------------------------------------------- index + +let index: IndexState | undefined; + +function buildIndex(force = false): IndexState { + if (index && !force) { + const fp = fingerprint(SKILLS_DIR); + if (fp === index.fingerprints.get(SKILLS_DIR)) return index; + } + const entries: SkillEntry[] = []; + const fingerprints = new Map(); + for (const name of listDir(SKILLS_DIR)) { + const dir = join(SKILLS_DIR, name); + const skillMdPath = join(dir, "SKILL.md"); + let raw = ""; + try { + raw = readClamped(skillMdPath); + } catch { + continue; // no SKILL.md → non è una skill + } + const fm = parseFrontmatter(raw); + const references: SkillEntry["references"] = []; + try { + for (const r of readdirSync(join(dir, "references"))) { + if (!r.endsWith(".md")) continue; + const rpath = join(dir, "references", r); + references.push({ path: rpath, name: r, text: readClamped(rpath) }); + } + } catch { + /* nessuna references/ */ + } + entries.push({ + name: fm.name || name, + dir, + skillMdPath, + description: fm.description, + tags: fm.tags, + body: raw, + references, + mtime: 0, + }); + } + fingerprints.set(SKILLS_DIR, fingerprint(SKILLS_DIR)); + index = { entries, fingerprints, builtAt: Date.now() }; + return index; +} + +// ---------------------------------------------------------------- search + +function searchSkills(query: string, limit: number): { entry: SkillEntry; score: number; excerpt: string }[] { + const idx = buildIndex(); + const tokens = tokenize(query); + const out: { entry: SkillEntry; score: number; excerpt: string }[] = []; + for (const entry of idx.entries) { + const haystacks: [string, number][] = [ + [entry.name, 5], + [entry.tags.join(" "), 4], + [entry.description, 3], + [(entry.body.match(/^#{1,3} .*$/gm) || []).join("\n"), 2], + [entry.body, 1], + [entry.references.map((r) => `${r.name}\n${r.text}`).join("\n"), 1], + ]; + let score = 0; + let bestExcerpt = ""; + for (const tok of tokens) { + for (const [text, weight] of haystacks) { + const lower = text.toLowerCase(); + let pos = 0; + let count = 0; + while ((pos = lower.indexOf(tok, pos)) !== -1 && count < 5) { + if (!bestExcerpt && weight >= 3) { + bestExcerpt = text.slice(Math.max(0, pos - 120), pos + 180).replace(/\s+/g, " ").trim(); + } + score += weight; + count++; + pos += tok.length; + } + } + } + if (score > 0) out.push({ entry, score, excerpt: bestExcerpt }); + } + out.sort((a, b) => b.score - a.score); + return out.slice(0, limit); +} + +// ---------------------------------------------------------------- extension + +export default function (pi: ExtensionAPI) { + // indicizza all'avvio della sessione ("ad ogni avvio l'indice si aggiorna") + pi.on("session_start", async () => { + try { + buildIndex(true); + } catch { + /* la scan avviene comunque lazy alla prima ricerca */ + } + }); + + // istruzioni brevi nel system prompt (append, mai replace) + pi.on("before_agent_start", async (event) => { + const idx = buildIndex(); + const names = idx.entries.map((e) => e.name); + if (names.length === 0) return; + const block = [ + "## Skill Hub (pi-skill-hub)", + `Skill condivise installate (${names.length}): ${names.join(", ")}.`, + "Se il task richiede una competenza operativa specializzata e nessuna skill nota corrisponde, esegui skill_search prima di improvvisare; carica la skill scelta con /skill: (o leggi il suo SKILL.md per il corpo completo). Le evidenze di esecuzione vanno registrate in qmem.", + ].join("\n"); + return { systemPrompt: `${event.systemPrompt}\n\n${block}` }; + }); + + pi.registerTool({ + name: "skill_search", + label: "Skill search", + description: + "Cerca nelle skill condivise (frontmatter, corpo e references) per nome, parola chiave o bisogno operativo. Ritorna le skill più rilevanti con estratti e indicazioni di caricamento. Usalo quando nessuna skill nota corrisponde al task o per ritrovare dettagli operativi (procedure, errori, recovery).", + parameters: Type.Object({ + query: Type.String({ description: "Parole chiave o descrizione del bisogno operativo (anche in italiano)" }), + limit: Type.Optional(Type.Number({ description: "Max risultati (default 5)" })), + }), + async execute(_id, params) { + const hits = searchSkills(params.query, Math.max(1, params.limit ?? 5)); + if (hits.length === 0) { + return { + content: [{ type: "text", text: "Nessuna skill corrisponde alla ricerca." }], + details: { hits: 0 }, + }; + } + const text = hits + .map( + (h, i) => + `${i + 1}. ${h.entry.name} (score ${h.score})\n ${h.entry.description}\n Caricamento: /skill:${h.entry.name} | SKILL.md: ${h.entry.skillMdPath}\n Estratto: ${h.excerpt}` + ) + .join("\n\n"); + return { + content: [{ type: "text", text }], + details: { + hits: hits.length, + skills: hits.map((h) => ({ name: h.entry.name, path: h.entry.skillMdPath, score: h.score })), + }, + }; + }, + }); + + pi.registerTool({ + name: "skill_info", + label: "Skill info", + description: + "Mostra il corpo completo di una skill (frontmatter, SKILL.md, elenco references). Usalo dopo skill_search per leggere la procedura prima di eseguire il task.", + parameters: Type.Object({ + name: Type.String({ description: "Nome della skill (es. sap-timesheet)" }), + }), + async execute(_id, params) { + const idx = buildIndex(); + const entry = idx.entries.find((e) => e.name === params.name || e.skillMdPath.endsWith(params.name)); + if (!entry) { + return { + content: [{ type: "text", text: `Skill "${params.name}" non trovata. Usa skill_search per l'elenco.` }], + details: { found: false }, + }; + } + const body = entry.body.length > MAX_BODY_IN_RESULT ? entry.body.slice(0, MAX_BODY_IN_RESULT) + "\n…[troncato]" : entry.body; + const refs = entry.references.map((r) => `- ${r.path}`).join("\n") || "(nessuna)"; + return { + content: [ + { + type: "text", + text: `# ${entry.name}\n\n${entry.description}\n\nCaricamento: /skill:${entry.name}\n\n## SKILL.md\n${body}\n\n## References\n${refs}`, + }, + ], + details: { found: true, name: entry.name, path: entry.skillMdPath, references: entry.references.map((r) => r.path) }, + }; + }, + }); + + pi.registerCommand("skill-sync", { + description: "Ricostruisce l'indice delle skill del Skill Hub", + handler: async (_name, ctx) => { + const idx = buildIndex(true); + ctx.ui.notify( + `Skill Hub: ${idx.entries.length} skill indicizzate (${idx.entries.map((e) => e.name).join(", ")})`, + "info", + ); + }, + }); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..de54858 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "pi-skill-hub", + "version": "0.1.0", + "description": "Skill condivise pi: distribuzione via Gitea, ricerca indicizzata (skill_search) e caricamento dinamico. Le skill vivono qui; evidenze e dettagli macchina in qmem.", + "keywords": ["pi-package", "skills", "registry", "skill-hub"], + "license": "MIT", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + }, + "pi": { + "extensions": ["./extensions"], + "skills": ["./skills"] + } +} \ No newline at end of file diff --git a/skills/sap-timesheet/SKILL.md b/skills/sap-timesheet/SKILL.md new file mode 100644 index 0000000..e30ef49 --- /dev/null +++ b/skills/sap-timesheet/SKILL.md @@ -0,0 +1,122 @@ +--- +name: sap-timesheet +description: > + Compila, corregge e verifica il timesheet mensile SAP CATS (S/4HANA WebGUI + s4t.sap.telespazio.com, Manager desktop PPMDT → Inserimento Time Sheet) per il + dipendente BENEDETTO MATTEO (21432). Usare quando l'utente chiede di inserire + ore, completare/correggere il mese precedente o corrente, caricare le ore dal + file Excel "Ore Team Fortuna", o verificare ore inserite vs teoriche. NON usare + per gestione trasferte, ferie/assenze, autorizzazione timesheet di altri + dipendenti o questioni payroll. +--- + +# Timesheet SAP — completamento mese precedente e inserimento mese corrente + +## Outcome +Timesheet mensile salvato in SAP con **totale righe = ore teoriche del periodo** +(e forecast 8h/giorno lavorativo sui giorni futuri), verificato con rilettura +post-salvataggio, con evidenza osservata (DOM reale) di ogni valore scritto. + +## Scope +- Correzione/completamento del **mese precedente**: portare le ore inserite in + linea con le ore teoriche ora generate (giorni mancanti + eccessi). +- Inserimento del **mese corrente**: righe dati ripartite per WBS (percentuali + dal file Excel ore team), giorni passati = teoriche, giorni lavorativi dopo + oggi = 8,00 (forecast). +- Verifica post-salvataggio e riconciliazione totali. + +NON usare per: approvare timesheet di terzi, gestire assenze, trasferte, o +modifiche a mesi chiusi da altro personale. + +## Preconditions +- Firefox con BiDi attivo: `browser_start(profile="main")` (profilo principale, + login reali). Bridge su 127.0.0.1:8787, token `firefox-bidi-local`. +- Sessione SAP autenticata (SSO Entra; l'MFA è manuale dell'utente). +- Helper: `scripts/bridge_helper.py` (in questa skill; funzioni `ev`, `click_ctx`, + `type_text`, `cell_center`, `cell_text`, `dialogs`, `write_cell`). +- Se serve lo storico dei valori: qmem project `sap-timesheet`. +- Fonte ore: Excel "Ore Team Fortuna - .xlsx", Foglio3, colonne + CdC/Reparto/Cognome/Nome/WBE/Perc (righe BENEDETTO MATTEO). + +## Non-negotiable gates +1. **Piano approvato dall'utente PRIMA di scrivere** in SAP: presentare la + tabella giorno-per-giorno (valori per riga WBS) e attendere conferma esplicita. +2. **Ore teoriche = limite superiore** (riga r1 della griglia): mai superarle. + Giorni passati → valore teorico esatto; mai 8,00 tondi su giornate parziali. +3. **Mai ESC**: chiude la transazione. Dialoghi → pulsante "Continuare"/Invio. +4. **Verifica = DOM reale** (BiDi eval), mai screenshot interpretato: ogni valore + scritto è "Observed" solo se riletto dalla riga TOTALI (r2) o dopo re-render. + Non convertire mai Unknown/Assumption in Observed. +5. **Salvataggio solo dopo** riconciliazione: somma giorni per riga == totale + riga, somma righe == totale generale; e **dopo approvazione utente**. +6. **Verifica post-salvataggio obbligatoria** (riaprire la view e rileggere). +7. Segregazione: l'agente **inserisce ma non approva**; il salvataggio finale + viene eseguito solo con l'utente informato (attestazione del titolare). + +## Workflow A — completamento/correzione mese precedente +1. Naviga: Easy Access → dblclick preferito "Risorse umane -> Manager's Desktop" + (dispatchEvent dblclick su `tree#C105#2#1#1#i`) → sezione Time Sheet → + click destro su riga "BENEDETTO MATTEO" → "Inserimento Time Sheet". +2. Imposta il periodo del mese precedente con i pulsanti **Videata + precedente/successiva** (`M0:46:1::1:46` / `:1:49`, spostano di un mese). + I campi data intestazione NON accettano digitazione sintetica. +3. Leggi su DOM: riga teoriche (r1), riga totali (r2), righe dati (r3+). + Costruisci il delta per ogni giorno: `inserite` vs `teoriche`. +4. Presenta il piano (giorni mancanti con valore teorico esatto; eccessi da + ridurre) e chiedi conferma. +5. Scrivi le celle con la ricetta di `references/procedura-tecnica.md` §2, + verificando ogni cella sulla riga totali (r2). +6. Salva (§Salvataggio) e verifica (§Verifica). + +## Workflow B — inserimento mese corrente con ripartizione WBS +1. Apri la view sul mese corrente; leggi teoriche (r1) e righe esistenti. +2. Calcola il piano: per ogni giorno con teoriche > 0 → ore = teorica; per i + giorni lavorativi **dopo oggi** → 8,00; giorni con teorica 0 nel passato e + weekend → 0. Oggi stesso escluso (si completa a fine mese col Workflow A). +3. Ripartisci per riga WBS con le percentuali dell'Excel (es. 70/20/10 su + 185.102.01.01 / 225.489.64.50 / 235.900.10), arrotondando al centesimo e + **bilanciando** perché la somma giornaliera = teorica del giorno. +4. Presenta la tabella (giorno × riga WBS) e ottieni l'approvazione. +5. Crea le righe: compila la prima riga (c4 TdA=ORE, c6 WBS, c8 UM=H; c2 CdC e + c3 CA vengono auto-compilate da SAP dopo l'inserimento dei giorni), poi + **seleziona la riga (colonna 0) → menu "..." → Copiare riga** per le successive, + correggendo solo la WBS e i giorni. +6. Scrivi i giorni di ogni riga (ricetta in references), verifica per riga e + salva (§Salvataggio) + verifica (§Verifica). + +## Salvataggio +- Pulsante "Salvare Evidenziato" (`M0:50::btn[11]`). +- Errori E bloccano il salvataggio (riportarli all'utente); i warning W si + superano con "Continuare". +- Esito OK = la view si chiude tornando al Manager desktop (nessun popup). +- Se compare il dialogo di enqueue "La matricola 21432 è attualmente + impegnata…" → premere Invio e vedi `references/errori-noti.md` §Lock. + +## Verifica (exit criteria) +- [ ] Riaperta la view e **rilegta la griglia** dal DOM reale. +- [ ] Somma giorni per riga == totale riga; somma righe == totale generale. +- [ ] Nessun giorno supera le teoriche; nessun giorno mancante rispetto al piano. +- [ ] Valori registrati in qmem (project `sap-timesheet`) con i totali finali. +- [ ] Rischi/limitazioni dichiarati (es. teoriche future non ancora generate). + +## Resource loading +| Condizione | Carica/esegui | +|---|---| +| Prima scrittura o ambiente cambiato | `references/procedura-tecnica.md` | +| Errore SAP, lock, pagina bianca, menu chiuso | `references/errori-noti.md` | +| Analisi di controllo/conformità richiesta | `references/best-practice-controlli.md` | +| Operazioni ponte BiDi ripetitive | `scripts/bridge_helper.py` (python3) | + +## Automation boundary +- Lettura griglia/navigazione: autonoma. +- Scrittura celle: dopo approvazione del piano (una volta per sessione). +- Salvataggio: dopo riconciliazione OK; se compaiono errori E → fermarsi e + riportarli; i warning W si confermano con "Continuare". +- Mai ripetere un salvataggio fallito senza rianalisi: rischio duplicati + (idempotenza: verificare sempre cosa è già persistito prima di riscrivere). + +## Esempio (completamento agosto 2026, reale) +Teoriche 107,18 vs inserite 61,05 → mancanti 04,06,07,19,20,21.08=8,00 e +26.08=8,28; eccessi 24.08 8,00→6,87 e 31.08 8,00→6,98. Totale finale 107,18 = +teoriche. Verificato con rilettura post-salvataggio. Cattiva risposta da evitare: +"dichiarare completato" senza rilettura, o correggere eccessi senza approvazione. \ No newline at end of file diff --git a/skills/sap-timesheet/references/best-practice-controlli.md b/skills/sap-timesheet/references/best-practice-controlli.md new file mode 100644 index 0000000..b7016dc --- /dev/null +++ b/skills/sap-timesheet/references/best-practice-controlli.md @@ -0,0 +1,32 @@ +# Allineamento alle best practice di settore (SAP CATS + agent skills) + +Fonti: documentazione SAP (help.sap.com — CATS lifecycle, target hours check, +enhancement CATS0003/0006/0008, CATS_DA/CADO per audit); linee guida Agent +Skills (agentskills.io, Anthropic, Microsoft Agent Framework) su progressive +disclosure, hard gates, evidence standard e automation boundary. Verificate con +ricerca web (Perplexity, 2026-09). + +## Come la skill mappa i controlli CATS +| Controllo di settore | Come è implementato nella skill | +|---|---| +| Lifecycle CATS: Capture → Validate → Release → Approve → Transfer → Reconcile | L'agente opera su Capture/Validate (Inserimento Time Sheet); Release/Approval restano all'utente (attestazione), Transfer è a valle | +| Ore teoriche/target hours come vincolo di immissione (R1) | Regola non negoziabile: mai superare la riga teoriche; giornate parziali al valore esatto | +| Validation hooks CATS0003/0006 lato SAP | La skill non aggira le validazioni: errori E → stop e report; warning W → "Continuare" solo dopo lettura del testo | +| "RPA non corregge silenziosamente fatti di business" | I valori proposti derivano dal file Excel + teoriche SAP; ogni scostamento è proposto all'utente, mai autocorretto | +| Human-in-the-loop / approval gate | Piano giorno-per-giorno approvato prima di scrivere; salvataggio dopo riconciliazione | +| Idempotenza e no-duplicati | Prima di riscrivere si rilegge sempre lo stato persistito (R2); mai ri-salvare a caso dopo un errore | +| Correlation ID / audit trail | Ogni sessione registra in qmem (project sap-timesheet): valori inseriti, totali, errori, esito verifica; il documento CATS ha numero univoco lato SAP | +| Segregation of duties (entry ≠ approval) | L'agente non ha ruolo di approvazione; il titolare conferma il piano e il salvataggio | +| Least privilege / credenziali | Si usa il profilo principale dell'utente via SSO; nessuna credenziale in script/prompt; BiDi solo loopback 127.0.0.1 | +| Verifica come exit criterion | Rilettura post-salvataggio obbligatoria con riconciliazione somme | +| Preferire API/scraping controllato | Non esistendo interfaccia utente alternativa per il profilo dipendente, si automatizza la WebGUI con click reali e verifica DOM (documentato; nessun bypass di controlli SAP) | + +## Limiti dichiarati (residui) +- L'automazione scrive con le credenziali dell'utente: ogni salvataggio è da + considerarsi inserimento dell'utente stesso → l'attestazione finale è sua. +- La ripartizione percentuale (70/20/10) deriva dall'Excel del team: è + responsabilità dell'utente che il file sia quello approvato dal CdC. +- Gli arrotondamenti al centesimo sono bilanciati per riga; le differenze di + arrotondamento residue restano visibili nei totali mensili SAP. +- CATS_DA (CADO) permette il controllo a posteriori dei documenti CATSDB: + consigliata una riconciliazione mensile a campione dopo il transfer. \ No newline at end of file diff --git a/skills/sap-timesheet/references/errori-noti.md b/skills/sap-timesheet/references/errori-noti.md new file mode 100644 index 0000000..2e31484 --- /dev/null +++ b/skills/sap-timesheet/references/errori-noti.md @@ -0,0 +1,57 @@ +# Errori noti e recovery (catalogo casi reali) + +## 1. Lock enqueue: "La matricola 21432 è attualmente impegnata dall'utente MBENEDETTO" +- Sintomo: aprendo "Inserimento Time Sheet" appare un popup Informazione + ("PREMERE INVIO PER CONTINUARE"); la schermata resta vuota/gray "SAP". +- Causa: un'altra sessione server-side dello stesso utente tiene l'enqueue + (es. istanza chiusa bruscamente o app morta). +- Recovery verificata: premere Invio → si torna al Manager desktop. Attendere + il decadimento; se persiste >15 min: **logoff completo** (Esci → conferma + "Sì") e rientro (SSO trasparente, di solito senza MFA). Se sopravvive anche + al re-login, il lock è tenuto da un'ALTRA sessione SAP dell'utente (SAP Logon + su altro PC/browser): chiedere all'utente di chiuderla, oppure attendere il + decay, o richiedere lo sblocco a chi gestisce SM12. +- Nessun dato è perso: il lock blocca solo l'apertura in scrittura. + +## 2. Pagina SAP bianca / app chiusa +- Sintomo: screenshot bianco, `document.body` vuoto, title "". +- Causa tipica: attivazione accidentale della voce di menu **"Chiudi + applicazione"** nell'overflow del toolbar (coordinate fisse del menu!). +- Recovery: `location.reload()` → Easy Access → rifare la navigazione. I dati + NON salvati sono persi: ricostruire dal piano (che è sempre in memoria/Excel). + +## 3. Menu overflow "..." — voci con coordinate variabili +- Il menu ricorda lo scroll precedente: **rilocalizzare sempre la voce + ("Copiare riga") via DOM subito prima del click** e verificare che non sia + "Chiudi applicazione". Dopo l'apertura della view le voci visibili sono + diverse (alcuni pulsanti esistono nel DOM ma con rect 0x0). + +## 4. Celle che non registrano +- Digitazione diretta nei campi data intestazione: non funziona → usare Videata + precedente/successiva. +- Placeholder "Vuoto" di un periodo senza righe: la prima riga va creata con + "Copiare riga" dal periodo precedente (o compilata con la ricetta §2 della + procedura tecnica; il CdC/CA si auto-compilano dopo l'inserimento dei giorni). +- Display vuoto dell'ultima cella modificata: artefatto, verificare su r2/c9. +- Sequenza che rompe il commit: usare F2 sulle celle giorno al posto di Invio. + Giorni → Invio (+Invio di conferma su popup); intestazione → F2 se Invio non basta. + +## 5. Errori SAP di salvataggio (bloccanti E) +- "ore maggiori di quelle effettuate" → un giorno supera le teoriche: ridurre al + valore teorico. +- "Inserire anche una tipo di attività mittente" → manca TdA (ORE) sulla riga. +- "Versione 0 non definita" → CdC digitato a mano: preferire la riga copiata. +- "Unità di misura X non creata" → UM errata (usare H). +- I warning W non bloccano: si confermano con "Continuare" + (es. "è presente una missione, verificare attribuzione"). + +## 6. Enqueue e teoriche progressive +- Le teoriche dei giorni futuri vengono generate progressivamente: a metà mese + la somma teoriche è parziale (agosto: 61,05 h il 22.08 → 107,18 h a fine mese). +- Per questo il mese corrente si inserisce con forecast 8h sui giorni dopo + oggi e si COMPLETA il mese successivo col Workflow A. + +## 7. Crash della pagina / sessione caduta +- "Maximum number of active sessions" → pkill firefox e `browser_start(main)`. +- Dopo il reload ripartire da Easy Access; la sezione Manager desktop ricorda + l'ultima usata (spesso non serve ricliccare "Time Sheet"). \ No newline at end of file diff --git a/skills/sap-timesheet/references/procedura-tecnica.md b/skills/sap-timesheet/references/procedura-tecnica.md new file mode 100644 index 0000000..ee54046 --- /dev/null +++ b/skills/sap-timesheet/references/procedura-tecnica.md @@ -0,0 +1,69 @@ +# Procedura tecnica — SAP WebGUI CATS via bridge BiDi + +Tutto lo stato SAP si legge/scrive tramite il bridge BiDi (`127.0.0.1:8787`, +header `Authorization: Bearer firefox-bidi-local`), helper in +`scripts/bridge_helper.py`. Il modello non legge le immagini: la verità è il DOM. + +## 1. Mappatura griglia (M0:46:2:1) +- Righe: **r1 = ore teoriche**, **r2 = totali (autorevole per verifica)**, + **r3.. = righe dati** (le "Vuoto" sono placeholder). +- Colonne riga dati: + - c1 TR/marker riga, c0 = colonna di selezione riga (0-based, a sinistra) + - c2 CdC mittente (CATSD-SKOSTL) — auto-compilata da SAP (493) + - c3 CA / area contabile (CATSD-KOKRS) — auto-compilata (EGEO); non digitare + - c4 TdA (CATSD-LSTAR) = ORE (obbligatoria) + - c6 Elemento WBS destinatario (CATSD-RPROJ) + - c7 Tp.A/P (vuoto), c8 UM (CATSD-UNIT) = H + - c9 Totale riga +- Giorni: **colonna = 9 + numero del giorno** (c10 = giorno 01, c25 = giorno 16…). +- L'header visivo è sfasato rispetto agli ID cella: fidarsi solo di lsdata/SID. + +## 2. Ricetta di scrittura cella (verificata) +1. `scrollIntoView({block:'center', inline:'nearest'})` + 500-600ms; SE il punto + resta fuori viewport (x<0 o >1376), trascinare il thumb della hscroll custom + (`M0:46:2:1_hscroll-hdl`) verso il lato giusto e rilleggere il rect. Mai + coordinate fuori viewport (HTTP 500 "Move target out of bounds"). +2. Click reale BiDi: pointerMove → pointerDown → pausa 90ms → pointerUp → 450ms. +3. Digitazione: Ctrl+A (\ue009 + 'a' + \ue00a) → caratteri uno a uno con pause + 110ms → **Invio \ue007**. +4. Se resta aperto il popup "N proposte disponibili" (value-help storico): + confermare con un ulteriore Invio, o cliccare l'item storico + (`M0:46:2:1:::_TALB`); in extremis F2 (\ue002) committa. +5. ATTENZIONE: **Invio funziona sulle celle giorno; sulle celle intestazione + (TdA/WBS/UM) spesso serve F2.** Se dopo la digitazione il display resta + vuoto è spesso un artefatto: verificare sulla riga TOTALI (r2) o dopo il + click sulla cella successiva (il blur committa). +6. Verifica per ogni cella: `tot == atteso` sulla riga r2 della stessa colonna + (con più righe dati, tot = somma: verificare la riga con `r.c9` dopo + re-render, o i totali come somma attesa). +7. Il display dell'ULTIMA cella modificata spesso legge '' (artefatto): non + considerarlo fallimento se i totali tornano. + +## 3. Navigazione +- Easy Access → dblclick preferito "Risorse umane -> Manager's Desktop": + `el.dispatchEvent(new MouseEvent('dblclick', {bubbles:true}))` su + `tree#C105#2#1#1#i` (il dblclick nativo BiDi clickCount NON funziona). +- Sezione Time Sheet: pulsante toolbar "Time Sheet" (cambia contesto Manager desktop). +- View acquisizione: click destro (button 2) sulla riga BENEDETTO + (`tree#C121#1#1#mg`, cercare per testo) → voce "Inserimento Time Sheet". +- Cambio periodo: pulsanti **Videata precedente** `M0:46:1::1:46` / + **Videata successiva** `M0:46:1::1:49` (±1 mese). I campi data + (`M0:46:1::1:21/34`) non accettano key sintetici. +- Riga nuova: selezionare la riga di partenza (click su colonna 0, verifica + classe `lsSTTDScSel2BgColor`) → menu "..." (toolbar, ~x=965,y=72) → + **"Copiare riga"**. Individuare la voce di menu FRESA ad ogni apertura + (il menu ricorda lo scroll: coordinate fisse possono centrare + "Chiudi applicazione" → vedi errori-noti §3). + +## 4. Salvataggio e verifica +- "Salvare Evidenziato" `M0:50::btn[11]`; OK = view chiusa su Manager desktop. +- Riaprire la view, riallineare il periodo (Videata precedente/successiva) e + rileggere tutta la griglia; riconciliare le somme. +- Attenzione: dopo riapertura il periodo torna al mese corrente. + +## 5. Helper +`scripts/bridge_helper.py`: `ev(expr)` eval JS; `click_ctx(x,y[,button])`; +`type_text(text, commit)` (Ctrl+A + char-per-char 110ms + commit); +`cell_center(r,c)` (scrollIntoView + rect); `cell_text(r,c)`; `dialogs()`; +`write_cell(r,c,text)`. Chiavi unicode: Invio `\ue007`, F2 `\ue002`, Ctrl `\ue009`. +Mai inviare `value: ""` a input.performActions (il bridge rifiuta). \ No newline at end of file diff --git a/skills/sap-timesheet/scripts/bridge_helper.py b/skills/sap-timesheet/scripts/bridge_helper.py new file mode 100644 index 0000000..498d6de --- /dev/null +++ b/skills/sap-timesheet/scripts/bridge_helper.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Helper SAP timesheet via bridge BiDi (127.0.0.1:8787, token firefox-bidi-local).""" +import json, sys, time, urllib.request + +BRIDGE = "http://127.0.0.1:8787" +TOKEN = "firefox-bidi-local" +HDR = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"} + +def post(path, payload): + req = urllib.request.Request(BRIDGE + path, data=json.dumps(payload).encode(), headers=HDR, method="POST") + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + +def ev(expr): + return post("/eval", {"expression": expr, "awaitPromise": True}) + +def cmd(method, params): + return post("/cmd", {"method": method, "params": params}) + +def click(x, y, button=0): + return cmd("input.performActions", {"context": _ctx(), "actions": [{"id": "m", "type": "pointer", "parameters": {"pointerType": "mouse"}, "actions": [ + {"type": "pointerMove", "x": x, "y": y, "origin": "viewport"}, + {"type": "pointerDown", "button": button}, {"type": "pause", "duration": 90}, + {"type": "pointerUp", "button": button}, {"type": "pause", "duration": 450}]}]}) + +_CTX = None +def _ctx(): + global _CTX + if _CTX is None: + _CTX = post("/status", {})["context"] if False else None + # /status è GET + return _CTX + +def status(): + req = urllib.request.Request(BRIDGE + "/status", headers=HDR) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read().decode()) + +def ctx(): + global _CTX + if _CTX is None: + _CTX = status()["context"] + return _CTX + +def click_ctx(x, y, button=0): + return cmd("input.performActions", {"context": ctx(), "actions": [{"id": "m", "type": "pointer", "parameters": {"pointerType": "mouse"}, "actions": [ + {"type": "pointerMove", "x": x, "y": y, "origin": "viewport"}, + {"type": "pointerDown", "button": button}, {"type": "pause", "duration": 90}, + {"type": "pointerUp", "button": button}, {"type": "pause", "duration": 450}]}]}) + +CTRL = "\ue009"; ENTER = "\ue007"; F2 = "\ue002" + +def type_text(text, commit=ENTER): + acts = [{"type": "keyDown", "value": CTRL}, {"type": "keyDown", "value": "a"}, {"type": "keyUp", "value": "a"}, {"type": "keyUp", "value": CTRL}, {"type": "pause", "duration": 200}] + for ch in text: + acts += [{"type": "keyDown", "value": ch}, {"type": "keyUp", "value": ch}, {"type": "pause", "duration": 110}] + acts += [{"type": "pause", "duration": 250}, {"type": "keyDown", "value": commit}, {"type": "keyUp", "value": commit}, {"type": "pause", "duration": 400}] + return cmd("input.performActions", {"context": ctx(), "actions": [{"id": "k", "type": "key", "actions": acts}]}) + +def cell_center(r, c): + out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); if (!e) return JSON.stringify({{err:'nofound'}}); e.scrollIntoView({{block:'center', inline:'center'}}); return 'ok'; }})()") + time.sleep(0.6) + out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); const b = e.getBoundingClientRect(); return JSON.stringify({{x: Math.round(b.x + b.width/2), y: Math.round(b.y + b.height/2)}}); }})()") + v = out.get("result", out) + if isinstance(v, str): + v = json.loads(v) + return v + +def dialogs(): + out = ev("""(() => { const d = [...document.querySelectorAll('[role="dialog"], .urDlg, [class*="Dialog"], [class*="dialog"]')].filter(e => e.getBoundingClientRect().width > 0).map(e => (e.textContent||'').trim().slice(0,120)); return JSON.stringify(d); })()""") + v = out.get("result", "") + return json.loads(v) if isinstance(v, str) else v + +def cell_text(r, c): + out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); return e ? (e.textContent||'').trim() : null; }})()") + v = out.get("result") + return v + +def write_cell(r, c, text, expected=None): + """Scrive text nella cella [r,c]: click reale, Ctrl+A, digitazione, Invio. Ritorna dict esito.""" + p = cell_center(r, c) + if "err" in p: + return {"error": p["err"]} + click_ctx(p["x"], p["y"]) + time.sleep(0.2) + type_text(text) + time.sleep(0.5) + val = cell_text(r, c) + exp = expected if expected is not None else text + if val != exp: + # retry con F2 come commit + type_text(text, commit=F2) + time.sleep(0.5) + val = cell_text(r, c) + return {"cell": f"[{r},{c}]", "sent": text, "read": val, "ok": val == exp, "dialogs": dialogs()} + +if __name__ == "__main__": + what = sys.argv[1] + if what == "grid": + cols = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(2, 41)) + rows = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [1, 2, 3] + res = {} + for r in rows: + res[r] = {c: cell_text(r, c) for c in cols} + print(json.dumps(res, ensure_ascii=False)) + elif what == "dialogs": + print(json.dumps(dialogs(), ensure_ascii=False)) + elif what == "write": + r, c, text = int(sys.argv[2]), int(sys.argv[3]), sys.argv[4] + print(json.dumps(write_cell(r, c, text), ensure_ascii=False)) + elif what == "center": + r, c = int(sys.argv[2]), int(sys.argv[3]) + print(json.dumps(cell_center(r, c))) + elif what == "click": + x, y = int(sys.argv[2]), int(sys.argv[3]) + b = int(sys.argv[4]) if len(sys.argv) > 4 else 0 + click_ctx(x, y, b) + print("clicked", x, y, "btn", b) \ No newline at end of file