feat(erp): verser les pièces juridiques de 1_DOCUMENTS dans la GED

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) <[email protected]>
This commit is contained in:
2026-08-13 19:14:14 +02:00
co-authored by Claude Opus 5
parent 9253ac8d4c
commit 23b0d87aff
3 changed files with 529 additions and 0 deletions
@@ -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 @- <<JSON
{"filename":"piece.pdf","modulepart":"tax","subdir":"<ID DE L'OBJET>",
"filecontent":"<BASE64>","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.
+136
View File
@@ -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 DUSAGE 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",
},
];
+301
View File
@@ -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<Map<string, { id: number; docs: number }>> {
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<Set<string>> {
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();
}