feat(erp): manifeste d'exercice, rejeu à blanc, gardes manquantes, trunk réconcilié #92
@@ -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