From 216dc0213b00cb837f25ceda550f03c58d885b98 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Thu, 13 Aug 2026 19:15:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(erp):=20verser=20les=20pi=C3=A8ces=20jurid?= =?UTF-8?q?iques=20de=201=5FDOCUMENTS=20dans=20la=20GED=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gabriel Radureau --- .../dolibarr-sandbox-write/RUNBOOK_ged.md | 92 ++++++ test/gedManifest.ts | 136 ++++++++ test/provisionGed.ts | 301 ++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 .claude/skills/dolibarr-sandbox-write/RUNBOOK_ged.md create mode 100644 test/gedManifest.ts create mode 100644 test/provisionGed.ts diff --git a/.claude/skills/dolibarr-sandbox-write/RUNBOOK_ged.md b/.claude/skills/dolibarr-sandbox-write/RUNBOOK_ged.md new file mode 100644 index 0000000..3fdf943 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/RUNBOOK_ged.md @@ -0,0 +1,92 @@ +# Runbook — verser un document dans la GED Dolibarr + +Public : un agent, quel que soit son modèle, ou l'opérateur. + +La question à se poser en premier n'est pas « comment téléverser » mais **« à +quoi ce document se rattache-t-il ? »**. La réponse décide de tout, et l'un des +deux chemins ne marche pas du tout comme son API le prétend. + +## Deux GED, pas une + +Dolibarr expose deux arbres, et ils n'ont rien en commun : + +| Arbre | URL | Contenu | Comment y écrire | +| --- | --- | --- | --- | +| **Répertoires d'objets** | `/ecm/index_auto.php` | pièces rattachées à une facture, un tiers, une charge | **API REST**, `modulepart` de l'objet | +| **Répertoires manuels** | `/ecm/index.php` | ce qui n'a pas d'objet : statuts, KBIS, registre des décisions | **UI uniquement** | + +**Règle :** une pièce qui justifie un fait comptable se rattache à son objet, +jamais à la GED manuelle. L'appel de cotisations URSSAF va sur les charges, une +facture fournisseur sur sa facture. La GED manuelle est le dernier recours, pour +ce qui ne se rattache à rien. + +## Rattacher à un objet — API + +```bash +curl -s -X POST https://erp.arcodange.lab/api/index.php/documents/upload \ + -H "DOLAPIKEY: $(cat test/.ai_agent_prod_prod_write.key)" \ + -H 'Content-Type: application/json' -d @- <", + "filecontent":"","fileencoding":"base64","overwriteifexists":1} +JSON +``` + +`subdir` est l'**identifiant nu** de l'objet (`2`), rien d'autre. Vérifier sur +l'onglet Documents de l'objet que le compteur a bougé. + +## Verser dans la GED manuelle — script + +```bash +cd test +DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab deno run -A provisionGed.ts +``` + +Production, double opt-in comme toute écriture de production : + +```bash +DOLIBARR_ADDRESS=https://erp.arcodange.lab \ +ARCO_ALLOW_PRODUCTION=erp.arcodange.lab \ +ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \ + deno run -A provisionGed.ts +``` + +`--dry-run` liste ce qui serait versé sans rien écrire, et vérifie déjà que +chaque pièce du manifeste existe sur le disque. + +Ce qui est versé est déclaré dans **`test/gedManifest.ts`** — répertoires et +pièces. Ajouter un document, c'est ajouter une ligne au manifeste et relancer : +le script est idempotent, une pièce déjà présente n'est pas re-versée. + +## Les cinq pièges, tous rencontrés + +1. **L'API ment sur `modulepart=ecm`.** Elle répond le nom du fichier — donc + succès — et ne dépose rien que la GED sache retrouver : l'arbre affiche « Pas + de documents téléversés » et `/documents/download` répond **404** sur le + chemin qu'on vient d'écrire. Elle accepte de surcroît **n'importe quel + `subdir`** sans broncher. Pour la GED manuelle, **passer par l'UI**, qui écrit + au bon endroit *et* inscrit la pièce à l'index — c'est l'index qui alimente + compteurs et recherche. Une pièce non indexée est une pièce perdue. +2. **Un répertoire ne se crée que par l'UI**, sur + `/ecm/dir_add_card.php?action=create&module=ecm`. Le formulaire répond + « existe déjà » quand c'est le cas : cela vaut succès pour un script + idempotent, ce n'est pas une erreur. +3. **L'arbre est replié et monté en JS.** `innerText` sur son conteneur renvoie + vide alors que les répertoires existent ; ses ancres portent `href="#"`, le + chemin vit dans l'attribut `rel`, et l'identifiant de section dans le + `onclick`. Les sous-répertoires n'arrivent en DOM qu'au **dépliage**. Lire + trop tôt, ou chercher un lien `section=`, fait conclure à un arbre vide. +4. **Plafond de 2 Mo** (`MAX_FILE_SIZE`). Au-delà, le formulaire accepte la + soumission, n'écrit rien et **n'affiche aucune erreur** : le fichier + disparaît en silence. Le script contrôle la taille avant d'envoyer. Pour + relever la limite : `MAIN_UPLOAD_DOC` côté Dolibarr, `upload_max_filesize` + côté PHP. +5. **La case « écraser » ne s'applique pas de façon fiable** : chaque passe + ajoutait un doublon. L'idempotence se joue sur le **nom du fichier**, relu + dans le répertoire avant et après. + +## Vérifier + +Toujours par les **noms**, jamais par un compteur : un compteur juste peut +recouvrir deux exemplaires d'une pièce et l'absence d'une autre. C'est ce que +fait le script en fin de course, et c'est ce qui a mis au jour l'échec silencieux +de l'API. diff --git a/test/gedManifest.ts b/test/gedManifest.ts new file mode 100644 index 0000000..3ce8019 --- /dev/null +++ b/test/gedManifest.ts @@ -0,0 +1,136 @@ +/* + Which documents of 1_DOCUMENTS belong in the ERP, and where. + + This is a MANIFEST, not a mirror. 1_DOCUMENTS is the operator's own filing + cabinet — versioned in git, organised the way he thinks. The ERP holds the + subset that a reader of the accounts needs to make sense of them: what + justifies a charge, what proves the company exists, what fixes the terms of an + arrangement between the company and its gérant. + + DELIBERATE EXCLUSIONS. Copying everything would be easier and worse: + - identity papers (CNI, justificatif de domicile) — personal data, no + accounting reader needs them, and the ERP is web-facing; + - `documents/__france_travail`, `documents/00_creation/journal|impots|ursaff` + — administrative correspondence, not evidence of a booked fact; + - the housing invoices are included, but as EVIDENCE OF A FORFAIT, never as + charges — see the note on that folder below. +*/ + +export type GedDir = { + /** Directory label as it appears in the GED tree. */ + label: string; + /** Parent label, or null for a root directory. */ + parent: string | null; + desc: string; +}; + +export type GedFile = { + /** Path relative to the 1_DOCUMENTS root. */ + src: string; + /** Directory label it belongs to. */ + dir: string; + /** Filename in the GED — ASCII, no spaces: the upload API and the URLs that + * serve the file both handle them poorly, and a document nobody can link to + * is a document nobody will read. */ + as: string; +}; + +export const DIRS: GedDir[] = [ + { + label: "Juridique", + parent: null, + desc: "Pièces constitutives et registres de la société. Copie de travail — l'original fait foi dans 1_DOCUMENTS, versionné en git.", + }, + { + label: "Identite", + parent: "Juridique", + desc: "Statuts, KBIS, registre des bénéficiaires effectifs.", + }, + { + label: "Decisions", + parent: "Juridique", + desc: "Registre des décisions de l'associé unique. Numérotation chronologique, jamais réutilisée.", + }, + { + label: "Domiciliation", + parent: "Juridique", + desc: "Siège social au domicile du gérant : convention d'indemnité d'occupation, autorisation du propriétaire, plan, notification syndic, bail sous-jacent.", + }, + { + label: "Domiciliation-justificatifs", + parent: "Juridique", + desc: "Base de calcul du forfait de 220 EUR/mois. CES FACTURES NE SONT PAS DES CHARGES DE LA SOCIETE : la convention les inclut forfaitairement dans l'indemnité et exclut toute régularisation. Les enregistrer séparément serait un double emploi.", + }, +]; + +export const FILES: GedFile[] = [ + // --- Identité --- + { + src: "documents/01_identite/Statuts_SARLU_ARCODANGE.pdf", + dir: "Identite", + as: "statuts-sarlu-arcodange.pdf", + }, + { + src: "documents/01_identite/Extrait KBIS_ARCODANGE.pdf", + dir: "Identite", + as: "extrait-kbis-arcodange.pdf", + }, + { + src: "documents/02_registres/beneficiaires_effectifs_RBE/RBE_10110895_.pdf", + dir: "Identite", + as: "registre-beneficiaires-effectifs.pdf", + }, + + // --- Registre des décisions --- + { + src: "documents/02_registres/decisions/0001_domiciliation_evry_coucouronnes.pdf", + dir: "Decisions", + as: "0001-decision-associe-unique-domiciliation.pdf", + }, + + // --- Domiciliation --- + { + src: "documents/00_creation/siege/AUTORISATION D’USAGE PROFESSIONNEL ET DE SOUS-LOCATION PARTIELLE.pdf", + dir: "Domiciliation", + as: "autorisation-proprietaire-usage-professionnel.pdf", + }, + { + src: "documents/00_creation/siege/planDomiciliationARCODANGE.pdf", + dir: "Domiciliation", + as: "plan-domiciliation.pdf", + }, + { + src: "documents/00_creation/siege/notification_syndic.pdf", + dir: "Domiciliation", + as: "notification-syndic-copropriete.pdf", + }, + // EXCLU — le bail sous-jacent (Joël → Gabriel Radureau, 60 m²) pèse 8,6 Mo : + // un scan 11 pages en 5100×7016 pts. L'ERP plafonne le téléversement à 2 Mo + // (`MAX_FILE_SIZE`). Le recompresser reviendrait à altérer le rendu d'une + // pièce juridique avec un outil dont personne n'a vérifié la sortie ; il reste + // donc dans 1_DOCUMENTS, versionné en git, qui en est de toute façon + // l'exemplaire de référence. Pour le verser malgré tout : relever + // `MAIN_UPLOAD_DOC` côté Dolibarr et `upload_max_filesize` côté PHP. + // Ce n'est pas la pièce opérante : la convention d'indemnité et l'autorisation + // du propriétaire le sont, et toutes deux sont versées. + + // --- Justificatifs du forfait --- + // Le forfait de 220 EUR couvre « quote-part de valeur locative, électricité, + // chauffage et accès à internet ». Ces trois pièces montrent que le montant + // n'a pas été choisi au hasard — c'est tout ce qu'on leur demande. + { + src: "factures/logement/electricite_76_50_TTC_BiMestriel.pdf", + dir: "Domiciliation-justificatifs", + as: "totalenergies-electricite-bimestriel.pdf", + }, + { + src: "factures/logement/internet_orange_janvier_29_99_TTC_mensuel.pdf", + dir: "Domiciliation-justificatifs", + as: "orange-fibre-29-99-mensuel.pdf", + }, + { + src: "factures/logement/assuranceHabitationAssocie_83_18_annuel.pdf", + dir: "Domiciliation-justificatifs", + as: "assurance-habitation-83-18-annuel.pdf", + }, +]; diff --git a/test/provisionGed.ts b/test/provisionGed.ts new file mode 100644 index 0000000..b685b2c --- /dev/null +++ b/test/provisionGed.ts @@ -0,0 +1,301 @@ +/* + Verse dans la GED Dolibarr les pièces déclarées par gedManifest.ts. + + POURQUOI CE N'EST PAS UN SIMPLE UPLOAD : la GED de Dolibarr est pilotée par + les objets. `/ecm/index_auto.php` expose les documents rattachés à une facture, + un tiers, une charge ; `/ecm/index.php` n'expose que les répertoires MANUELS, + enregistrés en base. Déposer un fichier via l'API dans un chemin libre le pose + bien sur le disque, mais aucun écran ne le montrera jamais — l'API accepte + n'importe quel `subdir` sans broncher. Vérifié le 2026-08-13 : un dépôt dans + `ecm/social/urssaf/2026` laissait l'arbre GED vide. + + Il faut donc CRÉER le répertoire par l'UI (seule voie qui l'inscrive en base), + puis y téléverser par l'API. C'est ce que fait ce script. + + Les pièces qui justifient un fait comptable n'ont rien à faire ici : elles se + rattachent à leur objet. Le PDF de l'appel URSSAF est attaché aux charges, une + facture fournisseur à sa facture. La GED manuelle est pour ce qui n'a pas + d'objet : statuts, KBIS, registre des décisions, convention de domiciliation. + + Idempotent : un répertoire déjà présent n'est pas recréé, un fichier déjà + présent est réécrit à l'identique (`overwriteifexists`). + + Tout passe par l'UI — répertoires ET fichiers. L'API REST n'est pas une + alternative ici : voir le commentaire de `upload()`, elle répond succès sans + rien déposer que la GED sache retrouver. + + Usage : + deno run -A test/provisionGed.ts --docs ~/Work/Arcodange/1_DOCUMENTS [--dry-run] +*/ +import "load_dotenv"; +import { chromium, type Page } from "playwright"; +import login from "./scripts/login.ts"; +import { assertSandbox } from "./scripts/guard.ts"; +import { DIRS, FILES } from "./gedManifest.ts"; + +const argv = Deno.args; +const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d); + +const docsRoot = pick("--docs", `${Deno.env.get("HOME")}/Work/Arcodange/1_DOCUMENTS`) + .replace(/^~/, Deno.env.get("HOME") ?? "~"); +const dryRun = argv.includes("--dry-run"); + +const dolibarrAddress = assertSandbox(); + +console.log(`cible : ${dolibarrAddress}`); +console.log(`source : ${docsRoot}`); +console.log(`manifeste : ${DIRS.length} répertoires, ${FILES.length} pièces\n`); + +// Une pièce absente du disque est une erreur de manifeste, pas un incident à +// ignorer : on refuse de partir plutôt que de livrer un versement partiel dont +// personne ne saura ce qui manque. +const missing = FILES.filter((f) => { + try { + return !Deno.statSync(`${docsRoot}/${f.src}`).isFile; + } catch { + return true; + } +}); +if (missing.length) { + console.error("INTROUVABLES — le manifeste ne correspond plus au disque :"); + for (const m of missing) console.error(` ${m.src}`); + Deno.exit(1); +} + +if (dryRun) { + for (const d of DIRS) console.log(` répertoire ${d.parent ? d.parent + "/" : ""}${d.label}`); + for (const f of FILES) console.log(` pièce ${f.dir}/${f.as} ← ${f.src}`); + console.log("\n--dry-run : rien n'est écrit."); + Deno.exit(0); +} + +/** + * L'arbre GED, lu tel qu'il est : chemin de chaque répertoire → nombre de + * documents qu'il porte. + * + * DEUX PIÈGES, tous deux rencontrés : + * - l'arbre est un arbre jQuery REPLIÉ. `innerText` sur son conteneur renvoie + * une chaîne vide alors que les répertoires existent — conclure de là qu'ils + * manquent fait relancer la création, qui répond « existe déjà », et le + * script annonce l'échec d'une opération réussie ; + * - ses ancres portent `href="#"`, pas `section=`. Le chemin vit dans + * l'attribut `rel` (« Juridique/Identite/ »). Chercher un lien `section=` + * ne ramène rien, quel que soit le contenu réel de l'arbre. + * Les nœuds repliés restent dans le DOM : `evaluateAll` les atteint. + */ +async function readTree(page: Page): Promise> { + await page.goto(`${dolibarrAddress}/ecm/index.php`); + // L'arbre est monté par jQuery APRÈS le chargement : lire tout de suite ne + // ramène rien et fait conclure à un arbre vide. + await page.waitForSelector("li.lidirecm", { timeout: 15000 }).catch(() => {}); + + // Et il ne monte QUE la racine : les sous-répertoires arrivent par ajax au + // dépliage. Sans cette boucle, tout ce qui n'est pas à la racine est déclaré + // absent alors qu'il existe. On déplie jusqu'à stabilisation, en bornant les + // passes — un arbre qui ne se stabilise pas est un bug, pas une raison de + // tourner indéfiniment. + for (let pass = 0; pass < 6; pass++) { + const collapsed = page.locator("li.directory.collapsed > a.fmdirlia"); + const n = await collapsed.count(); + if (n === 0) break; + for (let i = 0; i < n; i++) { + await collapsed.nth(i).click({ timeout: 5000 }).catch(() => {}); + } + await page.waitForTimeout(600); + } + + const rows = await page.locator("li.lidirecm").evaluateAll( + // Deno n'embarque pas la lib DOM : le callback s'exécute dans le navigateur. + (lis: { + querySelector(s: string): { getAttribute(n: string): string | null } | null; + querySelectorAll(s: string): ArrayLike<{ textContent: string | null }>; + }[]) => + lis.map((li) => { + const a = li.querySelector("a.fmdirlia"); + const cells = Array.from(li.querySelectorAll("td.right")); + // L'identifiant de section ne vit que dans le handler : loadandshowpreview('chemin',ID) + const id = Number((a?.getAttribute("onclick") ?? "").match(/,\s*(\d+)\s*\)/)?.[1] ?? "0"); + return { + path: (a?.getAttribute("rel") ?? "").replace(/\/$/, ""), + id, + docs: Number((cells[0]?.textContent ?? "").trim() || "0"), + }; + }), + ); + return new Map(rows.filter((r) => r.path).map((r) => [r.path, { id: r.id, docs: r.docs }])); +} + +/** Chemin d'un répertoire du manifeste, tel que l'arbre et l'API le nomment. */ +const pathOf = (label: string): string => { + const d = DIRS.find((x) => x.label === label); + return d?.parent ? `${pathOf(d.parent)}/${label}` : label; +}; + +/** + * Crée un répertoire. Le formulaire est la seule autorité sur l'existant : + * il répond « existe déjà », ce qui vaut succès pour un script idempotent. + */ +async function createDir(page: Page, label: string, parent: string | null, desc: string): Promise<"créé" | "déjà présent"> { + await page.goto(`${dolibarrAddress}/ecm/dir_add_card.php?action=create&module=ecm`); + await page.fill('input[name="label"]', label); + await page.fill('textarea[name="desc"]', desc).catch(() => {}); + if (parent) { + // Le select des parents porte les répertoires déjà créés ; on l'apparie sur + // le libellé visible, jamais sur un id qui diffère d'une instance à l'autre. + const opts = await page.locator('select[name="catParent"] option').evaluateAll( + (os: { textContent: string | null; getAttribute(n: string): string | null }[]) => + os.map((o) => ({ + text: (o.textContent ?? "").trim(), + value: o.getAttribute("value") ?? "", + })), + ); + const hit = opts.find((o) => o.text === parent) ?? opts.find((o) => o.text.includes(parent)); + if (!hit) { + throw new Error(`parent « ${parent} » absent du select (options : ${opts.map((o) => o.text).join(", ")})`); + } + await page.selectOption('select[name="catParent"]', hit.value); + } + await page.locator('input[name="create"], input[type="submit"]').first().click(); + await page.waitForLoadState("networkidle"); + const body = (await page.locator("body").innerText()).replace(/\s+/g, " "); + if (/existe d[ée]j[àa]/i.test(body)) return "déjà présent"; + const err = body.match(/Erreur[^.]{0,160}/i); + if (err) throw new Error(`${label} : ${err[0]}`); + return "créé"; +} + +/** + * Téléverse une pièce dans un répertoire de la GED, par le formulaire de l'UI. + * + * POURQUOI PAS L'API. `POST /documents/upload` avec `modulepart=ecm` répond le + * nom du fichier — donc succès — et ne dépose RIEN que la GED sache retrouver : + * l'arbre affiche « Pas de documents téléversés » et `/documents/download` + * répond 404 sur le chemin qu'on vient d'écrire. Vérifié le 2026-08-13 sur les + * onze pièces. Le formulaire de l'UI, lui, écrit au bon endroit ET inscrit le + * fichier à l'index — c'est cet index qui alimente les compteurs et la + * recherche. Une pièce non indexée est une pièce perdue. + * + * Le renommage passe par une copie temporaire : le formulaire envoie le nom du + * fichier sur disque, il n'offre aucun champ pour le choisir. + */ +/** Noms des fichiers réellement présents dans un répertoire de la GED. */ +async function filesIn(page: Page, sectionId: number): Promise> { + await page.goto(`${dolibarrAddress}/ecm/index.php?section=${sectionId}&file_manager=1`); + await page.waitForTimeout(800); + const hrefs = await page.locator('a[href*="document.php"]').evaluateAll( + (as: { getAttribute(n: string): string | null }[]) => + as.map((a) => (a.getAttribute("href") ?? "").match(/file=([^&]+)/)?.[1] ?? "").filter(Boolean), + ); + return new Set(hrefs.map((h) => decodeURIComponent(h).split("/").pop() ?? "")); +} + +async function upload(page: Page, f: typeof FILES[number], sectionId: number, tmp: string): Promise<"versé" | "déjà présent"> { + // Idempotence par le NOM, pas par la case « écraser » du formulaire : celle-ci + // ne s'applique pas de façon fiable et chaque passe ajoutait un doublon — deux + // exemplaires du même statut dans la GED, personne ne sachant lequel fait foi. + if ((await filesIn(page, sectionId)).has(f.as)) return "déjà présent"; + + const staged = `${tmp}/${f.as}`; + await Deno.copyFile(`${docsRoot}/${f.src}`, staged); + + await page.goto(`${dolibarrAddress}/ecm/index.php?section=${sectionId}&file_manager=1`); + await page.waitForSelector('input[name="userfile[]"]', { timeout: 15000 }); + + // Au-delà de MAX_FILE_SIZE, le formulaire accepte la soumission, n'écrit rien + // et n'affiche aucune erreur : le fichier disparaît sans bruit. C'est ainsi + // qu'un bail de 8,6 Mo a été porté « versé » alors qu'il ne l'était pas. + const limit = Number( + await page.locator('input[name="MAX_FILE_SIZE"]').first().getAttribute("value") ?? "0", + ); + const size = (await Deno.stat(staged)).size; + if (limit && size > limit) { + throw new Error( + `${f.as} : ${(size / 1048576).toFixed(1)} Mo dépasse la limite de ` + + `${(limit / 1048576).toFixed(1)} Mo. Relever MAIN_UPLOAD_DOC (Dolibarr) ` + + `et upload_max_filesize (PHP), ou retirer la pièce du manifeste.`, + ); + } + + await page.setInputFiles('input[name="userfile[]"]', staged); + await page.locator('input[name="overwritefile"]').check().catch(() => {}); + await Promise.all([ + page.waitForLoadState("networkidle"), + page.locator('input[name="sendit"]').click(), + ]); + + const body = (await page.locator("body").innerText()).replace(/\s+/g, " "); + const err = body.match(/Erreur[^.]{0,160}/i); + if (err) throw new Error(`${f.as} : ${err[0]}`); + + // Relire : le formulaire sait échouer sans le dire. + if (!(await filesIn(page, sectionId)).has(f.as)) { + throw new Error(`${f.as} : absent du répertoire après téléversement`); + } + return "versé"; +} + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); + +try { + await login.doAdminLogin({ + page, + dolibarrAddress, + adminCredentials: { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", + }, + }); + + // Les parents d'abord : le select ne peut proposer que ce qui existe déjà. + const ordered = [...DIRS].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0)); + for (const d of ordered) { + const state = await createDir(page, d.label, d.parent, d.desc); + console.log(` ${state === "créé" ? "+" : "="} ${pathOf(d.label)} (${state})`); + } + + console.log(""); + const sections = await readTree(page); + const tmp = await Deno.makeTempDir({ prefix: "arcodange-ged-" }); + try { + for (const f of FILES) { + const dir = sections.get(pathOf(f.dir)); + if (!dir) throw new Error(`répertoire ${pathOf(f.dir)} introuvable dans l'arbre`); + const state = await upload(page, f, dir.id, tmp); + console.log(` ${state === "versé" ? "+" : "="} ${pathOf(f.dir)}/${f.as} (${state})`); + } + } finally { + await Deno.remove(tmp, { recursive: true }).catch(() => {}); + } + + // Vérification finale par les NOMS, jamais par un compteur : un compteur juste + // peut recouvrir deux exemplaires d'une pièce et l'absence d'une autre. + const bad: string[] = []; + const extra: string[] = []; + for (const d of DIRS) { + const path = pathOf(d.label); + const sec = sections.get(path); + if (!sec) { + bad.push(`${path} : absent de l'arbre`); + continue; + } + const present = await filesIn(page, sec.id); + const wanted = FILES.filter((f) => f.dir === d.label).map((f) => f.as); + for (const w of wanted) if (!present.has(w)) bad.push(`${path}/${w} : absent`); + for (const p of present) if (!wanted.includes(p)) extra.push(`${path}/${p}`); + } + if (extra.length) { + console.warn("\nPièces présentes hors manifeste (à examiner, rien n'a été supprimé) :"); + for (const e of extra) console.warn(` ${e}`); + } + if (bad.length) { + console.error("\nVERSEMENT INCOMPLET :"); + for (const b of bad) console.error(` ${b}`); + Deno.exit(1); + } + console.log(`\n${DIRS.length} répertoires, ${FILES.length} pièces — chaque pièce vérifiée par son nom dans la GED.`); +} finally { + await context.close(); + await browser.close(); +}