feat(erp): verser les pièces juridiques de 1_DOCUMENTS dans la GED #88
@@ -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.
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user