diff --git a/test/replayExercise.ts b/test/replayExercise.ts new file mode 100644 index 0000000..b8b4fe2 --- /dev/null +++ b/test/replayExercise.ts @@ -0,0 +1,151 @@ +/* + Rejeu d'un exercice à partir de son manifeste — pour l'instant EN LECTURE SEULE. + + CE QU'IL FAIT AUJOURD'HUI. Il calcule l'état que produirait le rejeu, corrections + comprises, et le confronte au RÉFÉRENT : les mouvements bancaires. Qonto et Wise + sont hors de Dolibarr, on ne peut pas les réécrire ; si le rejeu les reproduit au + centime et à la date, il décrit le même exercice. C'est le seul test qui vaille. + + CE QU'IL NE FAIT PAS ENCORE, et pourquoi. Un rejeu en écriture suppose une + instance VIERGE. `ops/sandbox/sandbox-lifecycle.sh` ne sait que `refresh-from-prod` + — un re-seed iso-prod, qui réimporte précisément les défauts qu'on veut corriger. + Écrire le rejeu sans cette capacité produirait des doublons, pas une reconstruction. + La capacité manquante est donc un préalable, pas un détail d'implémentation. + + LES CORRECTIONS QUE LE REJEU APPLIQUERAIT. Elles sont déclarées ici, pas éparpillées + dans le code : chacune nomme le défaut, sa cause et son remède. C'est ce que lira + un comptable repreneur pour comprendre pourquoi le livre reconstruit diffère de + l'ancien. + + Usage : + DENO_TLS_CA_STORE=system DOLAPIKEY=… \ + deno run -A test/replayExercise.ts --manifest …/manifest.json [--url …] +*/ +import "load_dotenv"; + +const argv = Deno.args; +const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d); + +const manifestPath = pick("--manifest", "manifest-2026.json"); +const base = pick("--url", Deno.env.get("DOLIBARR_URL") ?? "https://erp.arcodange.lab"); +const key = Deno.env.get("DOLAPIKEY") ?? ""; + +type Any = Record; +type Line = { date: string; amount: number; label: string; generic_label: boolean }; +type Account = { id: string; label: string; balance: number; lines: Line[] }; + +const manifest = JSON.parse(await Deno.readTextFile(manifestPath)) as { + meta: Any; exclusions: Any[]; accounts: Account[]; + invoices: Any[]; supplierInvoices: Any[]; tiers: Any[]; +}; + +/** + * Corrections que le rejeu appliquerait. Déclaratives : le défaut, sa cause, son + * remède. Une correction sans cause écrite est une réécriture silencieuse. + */ +const CORRECTIONS = [ + { + id: "libelles-generiques", + defaut: "20 des 37 mouvements bancaires portent « (SupplierInvoicePayment) » ou " + + "« (CustomerInvoicePayment) », libellés générés par Dolibarr", + cause: "aucun libellé n'est fourni à l'enregistrement du règlement ; Dolibarr retombe " + + "sur une clé de traduction", + remede: "libeller chaque règlement d'après sa pièce — référence du fournisseur ou du client, " + + "objet, période", + portee: "grand livre : ces libellés le traversent tels quels et y sont illisibles pour un " + + "repreneur comme pour un inspecteur", + }, + { + id: "auxiliaire-indemnites", + defaut: "les 7 paiements divers d'indemnité d'occupation ne se journalisent pas", + cause: "compte auxiliaire vide sur la ligne de charge ; le journal de banque exige une " + + "contrepartie identifiée (« Tiers inconnu et code comptable auxiliaire non défini »)", + remede: "renseigner subledger_account = 455110 — vérifié en sandbox, l'écriture journalise", + portee: "sans cela le compte courant reste hors du grand livre : 1 483,23 EUR absents", + }, + { + id: "tva-ovh-non-deductible", + defaut: "FAF2025001 enregistrée 6,08 HT + 1,22 TVA, la TVA figurant en déductible " + + "(CA3 ligne 20, période 2025-10)", + cause: "facture émise au nom personnel du fondateur, la société n'étant pas immatriculée ; " + + "une facture adressée à un particulier n'ouvre aucun droit à déduction", + remede: "enregistrer 7,30 EUR TTC au taux 0 — la charge est reprise pour son montant TTC " + + "(décision n° 3, reprise L. 210-6)", + portee: "TVA déductible de l'exercice : 353,76 → 352,54 EUR. Aucune déclaration déposée " + + "(CA12 annuelle, exercice clos le 31/12) — la correction ne coûte rien", + }, +] as const; + +async function api(path: string): Promise { + const r = await fetch(`${base}/api/index.php${path}`, { headers: { DOLAPIKEY: key } }); + if (!r.ok) return null; + try { return await r.json() as T; } catch { return null; } +} + +const iso = (ts: unknown): string | null => { + const n = Number(ts); + return n ? new Date(n * 1000).toISOString().slice(0, 10) : null; +}; + +console.log(`manifeste : ${manifestPath}`); +console.log(`exercice : ${manifest.meta.exercice}`); +console.log(`cible : ${base}\n`); + +// --- 1. le référent : la banque --------------------------------------------- +console.log("=== CONTRÔLE BANCAIRE — le rejeu reproduit-il les mouvements ? ===\n"); + +let totalAttendu = 0, totalTrouve = 0, ecarts = 0; +for (const a of manifest.accounts) { + const live = (await api(`/bankaccounts/${a.id}/lines?limit=1000`) ?? []) + .map((l) => ({ date: iso(l.datev ?? l.dateo), amount: Number(l.amount) })) + .filter((l) => l.date); + + // Appariement par (date, montant) : ce que la banque impose, et rien de plus. + // Le libellé ne participe PAS à l'appariement — c'est justement ce qu'on corrige. + const reste = [...live]; + const manquants: Line[] = []; + for (const m of a.lines) { + const i = reste.findIndex((l) => l.date === m.date && Math.abs(l.amount - m.amount) < 0.005); + if (i >= 0) reste.splice(i, 1); else manquants.push(m); + } + totalAttendu += a.lines.length; + totalTrouve += a.lines.length - manquants.length; + ecarts += manquants.length + reste.length; + + const verdict = manquants.length === 0 && reste.length === 0 ? "conforme" : "ÉCART"; + console.log(` ${a.label.padEnd(32)} ${String(a.lines.length).padStart(3)} mouvements ${a.balance.toFixed(2).padStart(10)} EUR ${verdict}`); + for (const m of manquants) console.log(` manquant à la cible : ${m.date} ${m.amount.toFixed(2)} ${m.label}`); + for (const r of reste) console.log(` en trop à la cible : ${r.date} ${r.amount.toFixed(2)}`); +} + +console.log(`\n ${totalTrouve}/${totalAttendu} mouvements appariés à la date et au centime` + + (ecarts ? ` — ${ecarts} écart(s)` : " — aucun écart")); + +// --- 2. ce que le rejeu changerait ------------------------------------------ +console.log("\n=== CORRECTIONS QUE LE REJEU APPLIQUERAIT ===\n"); +for (const c of CORRECTIONS) { + console.log(` ▸ ${c.id}`); + console.log(` défaut : ${c.defaut}`); + console.log(` cause : ${c.cause}`); + console.log(` remède : ${c.remede}`); + console.log(` portée : ${c.portee}\n`); +} + +// --- 3. ce que le rejeu écarterait ------------------------------------------ +console.log("=== PIÈCES ÉCARTÉES ===\n"); +for (const e of manifest.exclusions) { + console.log(` ⊘ ${e.ref} (${Number(e.total_ttc).toFixed(2)} EUR, ${e.date})`); + console.log(` ${String(e.motif).replace(/(.{92})/g, "$1\n ")}\n`); +} + +// --- 4. verdict -------------------------------------------------------------- +const genericCount = manifest.accounts.flatMap((a) => a.lines).filter((l) => l.generic_label).length; +console.log("=== VERDICT ===\n"); +console.log(` manifeste complet vis-à-vis de la banque : ${ecarts === 0 ? "OUI" : "NON — " + ecarts + " écart(s)"}`); +console.log(` corrections à appliquer : ${CORRECTIONS.length}`); +console.log(` libellés à réécrire : ${genericCount}`); +console.log(` pièces écartées : ${manifest.exclusions.length}`); +console.log(`\n Le rejeu EN ÉCRITURE reste bloqué : il exige une instance vierge, que`); +console.log(` sandbox-lifecycle.sh ne sait pas produire (refresh-from-prod uniquement).`); + +Deno.exit(ecarts === 0 ? 0 : 1);