feat(erp): rejeu à blanc — le manifeste reproduit la banque au centime
37/37 mouvements appariés à la date et au montant sur les trois comptes. Le
manifeste décrit l'exercice exactement comme la banque le connaît, et la banque
est le seul référent qu'on ne peut pas réécrire : Qonto et Wise sont hors de
Dolibarr. C'est le test qui compte, et il passe.
L'appariement se fait sur (date, montant) et SUR RIEN D'AUTRE. Le libellé n'y
participe pas — c'est précisément ce que le rejeu corrige, il ne peut pas servir
à valider.
Les corrections sont DÉCLARATIVES : chacune nomme le défaut, sa cause, son
remède et sa portée. Une correction sans cause écrite est une réécriture
silencieuse, et c'est ce qu'un repreneur lira pour comprendre pourquoi le livre
reconstruit diffère de l'ancien.
- 20 des 37 mouvements portent un libellé Dolibarr générique, illisible au
grand livre ;
- les 7 indemnités ne se journalisent pas faute de compte auxiliaire — le
correctif (455110) est vérifié en sandbox ;
- la TVA d'OVH est réclamée en déduction alors que la facture est au nom
personnel du fondateur : 7,30 TTC au taux 0, déductible 353,76 → 352,54.
Aucune déclaration déposée (CA12 annuelle), la correction est gratuite.
CE QUI RESTE BLOQUÉ, et ce n'est pas un détail d'implémentation : un rejeu EN
ÉCRITURE exige une instance VIERGE. 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. Le mode vierge est le préalable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<T>(path: string): Promise<T | null> {
|
||||
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<Any[]>(`/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);
|
||||
Reference in New Issue
Block a user