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]>
302 lines
13 KiB
TypeScript
302 lines
13 KiB
TypeScript
/*
|
|
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();
|
|
}
|