From 23b0d87affe2a2b09f26acbaae85097a4e59454a Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Thu, 13 Aug 2026 19:14:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(erp):=20verser=20les=20pi=C3=A8ces=20jurid?= =?UTF-8?q?iques=20de=201=5FDOCUMENTS=20dans=20la=20GED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'opérateur tient ses documents hors ERP, dans 1_DOCUMENTS versionné en git. Ce dépôt reste l'exemplaire de référence ; la GED en reçoit le sous-ensemble dont un lecteur des comptes a besoin — ce qui justifie une charge, ce qui prouve que la société existe, ce qui fixe les termes entre la société et son gérant. Le manifeste est déclaratif (gedManifest.ts) : ajouter un document, c'est ajouter une ligne. Les exclusions sont délibérées et commentées — pièces d'identité (données personnelles, aucun lecteur comptable n'en a besoin, l'ERP est exposé), correspondance administrative, et le bail sous-jacent (8,6 Mo contre un plafond de 2 Mo ; le recompresser altérerait le rendu d'une pièce juridique avec un outil non vérifié). Les factures de logement sont versées comme JUSTIFICATIFS du forfait de 220 EUR de la convention d'occupation, jamais comme charges : la convention les inclut forfaitairement et exclut toute régularisation. Les enregistrer séparément serait un double emploi. Le répertoire le dit dans sa description. Cinq pièges ont coûté cher et sont consignés dans RUNBOOK_ged.md : - l'API REST MENT sur modulepart=ecm. Elle répond le nom du fichier — donc succès — sans rien déposer que la GED sache retrouver : arbre vide et 404 sur le chemin qu'on vient d'écrire. Elle accepte de surcroît n'importe quel subdir. La GED manuelle ne s'alimente que par l'UI, qui écrit ET indexe ; - un répertoire ne se crée que par l'UI ; « existe déjà » vaut succès ; - l'arbre est replié et monté en JS, ses ancres portent href="#", le chemin vit dans rel et l'id dans le onclick, et les enfants n'arrivent qu'au dépliage ; - au-delà de 2 Mo le formulaire accepte, n'écrit rien et n'affiche aucune erreur ; - la case « écraser » ne s'applique pas : chaque passe dupliquait. D'où le principe de vérification : 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 contrôle qui a révélé l'échec silencieux de l'API. Répété en sandbox, puis appliqué en production : 5 répertoires, 10 pièces, chacune relue par son nom dans la GED. Co-Authored-By: Claude Opus 5 (1M context) --- .../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(); +}