feat(erp): manifeste d'exercice, rejeu à blanc, gardes manquantes, trunk réconcilié #92
@@ -40,10 +40,16 @@ Le document le dit expressément, et les pièces sont produites sur demande. La
|
|||||||
règle est donc *pouvoir répondre simplement en cas d'enquête*, jamais *anticiper
|
règle est donc *pouvoir répondre simplement en cas d'enquête*, jamais *anticiper
|
||||||
l'enquête en publiant*.
|
l'enquête en publiant*.
|
||||||
|
|
||||||
**Limite qui inverse la règle :** un flux financier entre la société et son
|
**Limite qui inverse la règle — et elle est étroite :** ne se déclare, même non
|
||||||
dirigeant se déclare, même non demandé, dès lors que le document porte
|
demandé, que ce qui rendrait le document **faux** si on le taisait. Le critère
|
||||||
précisément sur l'absence d'un tel flux. Taire ce qui contredirait le document
|
est la véracité de ce qui est affirmé, pas l'exhaustivité.
|
||||||
n'est pas de la discrétion, c'est une omission.
|
|
||||||
|
Une attestation d'absence de rémunération n'a pas à mentionner une indemnité
|
||||||
|
d'occupation : celle-ci n'est pas une rémunération, elle ne contredit donc rien.
|
||||||
|
La mentionner, c'est publier une relation financière que personne n'a demandée —
|
||||||
|
et un lecteur pressé y verrait une rémunération là où il n'y en a pas. Une
|
||||||
|
première rédaction l'avait fait, en s'abritant derrière la transparence ; c'était
|
||||||
|
une erreur d'analyse : la discrétion et la sincérité ne s'opposaient pas ici.
|
||||||
|
|
||||||
### 2. Permanence des méthodes
|
### 2. Permanence des méthodes
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
|||||||
|
/*
|
||||||
|
Extrait d'une instance Dolibarr le MANIFESTE D'UN EXERCICE : la description
|
||||||
|
complète et ordonnée des faits qui le composent.
|
||||||
|
|
||||||
|
POURQUOI. Un exercice ouvert n'a d'engagement envers personne — aucun compte
|
||||||
|
déposé, aucune liasse, aucun FEC remis. Tant qu'il en va ainsi, une erreur de
|
||||||
|
MODÉLISATION (mauvais compte, auxiliaire manquant, libellé illisible) se
|
||||||
|
corrige mieux en rejouant depuis les pièces qu'en la contournant : la piste
|
||||||
|
d'audit fiable demande que l'écriture reflète sa pièce, et rejouer resserre ce
|
||||||
|
lien là où une rustine le distend.
|
||||||
|
|
||||||
|
Ce fichier ne fait que LIRE. Il produit le manifeste ; le rejeu est un autre
|
||||||
|
outil, et il ne s'exécute que sur une cible vierge.
|
||||||
|
|
||||||
|
CE QUI EST REJOUABLE, ET CE QUI NE L'EST PAS. La ligne de partage n'est pas
|
||||||
|
technique, elle est juridique :
|
||||||
|
|
||||||
|
rejouable : factures fournisseur enregistrées, lignes bancaires, paiements
|
||||||
|
divers, charges sociales, tiers — tout ce qui est interne ;
|
||||||
|
FIGÉ : toute pièce déjà remise à un tiers (facture client ENVOYÉE,
|
||||||
|
dépôt au greffe, déclaration transmise) et tout exercice clos.
|
||||||
|
|
||||||
|
Une facture partie chez le client existe chez lui : aucun rejeu ne l'efface.
|
||||||
|
Le manifeste les extrait quand même — il décrit l'exercice — mais les marque
|
||||||
|
`delivered: true`, et le rejeu devra les reproduire à l'identique plutôt que
|
||||||
|
les recalculer.
|
||||||
|
|
||||||
|
LE RÉFÉRENT DE VÉRIFICATION. Les mouvements bancaires sont la seule partie de
|
||||||
|
l'exercice qu'on ne peut pas réécrire : Qonto et Wise sont hors de Dolibarr.
|
||||||
|
Le manifeste les embarque donc intégralement, et le test d'acceptation d'un
|
||||||
|
rejeu est qu'ils soient retrouvés au centime et à la date.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
DENO_TLS_CA_STORE=system \
|
||||||
|
DOLIBARR_URL=https://erp.arcodange.lab DOLAPIKEY=$(cat test/....key) \
|
||||||
|
deno run -A test/extractExercise.ts --year 2026 --out manifest.json
|
||||||
|
|
||||||
|
`DENO_TLS_CA_STORE=system` n'est pas décoratif : les hôtes du lab portent un
|
||||||
|
certificat signé par une autorité interne, présente dans le trousseau système
|
||||||
|
mais absente du magasin que Deno embarque. Sans lui, `fetch` échoue sur
|
||||||
|
« invalid peer certificate: UnknownIssuer » — alors que curl et Playwright,
|
||||||
|
qui lisent le trousseau système, passent sans rien dire.
|
||||||
|
*/
|
||||||
|
import "load_dotenv";
|
||||||
|
|
||||||
|
const argv = Deno.args;
|
||||||
|
const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d);
|
||||||
|
|
||||||
|
const base = pick("--url", Deno.env.get("DOLIBARR_URL") ?? "https://erp.arcodange.lab");
|
||||||
|
const key = Deno.env.get("DOLAPIKEY") ?? "";
|
||||||
|
const year = Number(pick("--year", "2026"));
|
||||||
|
const out = pick("--out", `manifest-${year}.json`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pièces volontairement écartées du rejeu, avec leur motif.
|
||||||
|
*
|
||||||
|
* Une exclusion n'est légitime que si la pièce N'A JAMAIS ÉTÉ REMISE À UN TIERS.
|
||||||
|
* Le motif est consigné dans le manifeste : un auditeur doit pouvoir lire
|
||||||
|
* pourquoi une référence manque, sans avoir à le deviner ni à nous croire.
|
||||||
|
*/
|
||||||
|
const EXCLUSIONS: Record<string, string> = {
|
||||||
|
"FAC001-CL00001":
|
||||||
|
"Référence malformée (CL00001 au lieu de CL0001001) — défaut de configuration du masque. "
|
||||||
|
+ "JAMAIS REMISE AU CLIENT : vérifié le 2026-08-14, aucune trace d'envoi ; KissMetrics a été "
|
||||||
|
+ "facturé via Wise en février 2026, et le document qu'il détient référence FAC001-CL0001001. "
|
||||||
|
+ "La séquence FAC reste continue (FAC001 à FAC008), la réémission portant elle aussi FAC001.",
|
||||||
|
"AVC001-CL0001001":
|
||||||
|
"Avoir émis le 24/02/2026 pour annuler FAC001-CL00001. Sans objet dès lors que la facture "
|
||||||
|
+ "annulée n'a jamais quitté la société : il ne documentait qu'un faux pas interne, non un "
|
||||||
|
+ "événement commercial. La séquence AVC devient vide.",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
console.error("DOLAPIKEY requis (lecture seule).");
|
||||||
|
Deno.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dolibarr rend des epochs ; on les fige en ISO dans le fuseau du serveur. */
|
||||||
|
const iso = (ts: unknown): string | null => {
|
||||||
|
const n = Number(ts);
|
||||||
|
if (!n) return null;
|
||||||
|
return new Date(n * 1000).toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inYear = (ts: unknown) => {
|
||||||
|
const d = iso(ts);
|
||||||
|
return !!d && d.startsWith(String(year));
|
||||||
|
};
|
||||||
|
|
||||||
|
type Any = Record<string, unknown>;
|
||||||
|
|
||||||
|
console.log(`source : ${base}`);
|
||||||
|
console.log(`exercice : ${year}\n`);
|
||||||
|
|
||||||
|
// --- tiers ------------------------------------------------------------------
|
||||||
|
const tiers = (await api<Any[]>("/thirdparties?limit=500") ?? []).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
code_client: t.code_client,
|
||||||
|
code_fournisseur: t.code_fournisseur,
|
||||||
|
client: t.client,
|
||||||
|
fournisseur: t.fournisseur,
|
||||||
|
country_id: t.country_id,
|
||||||
|
tva_intra: t.tva_intra,
|
||||||
|
code_compta: t.code_compta,
|
||||||
|
code_compta_fournisseur: t.code_compta_fournisseur,
|
||||||
|
}));
|
||||||
|
console.log(`tiers : ${tiers.length}`);
|
||||||
|
|
||||||
|
// --- factures client --------------------------------------------------------
|
||||||
|
// `delivered: true` : elles sont parties chez le client. Un rejeu les reproduit
|
||||||
|
// à l'identique — référence, date, montant — il ne les recalcule pas.
|
||||||
|
const invoices = [];
|
||||||
|
const excluded: Any[] = [];
|
||||||
|
for (const f of await api<Any[]>("/invoices?limit=500") ?? []) {
|
||||||
|
if (!inYear(f.date)) continue;
|
||||||
|
const motif = EXCLUSIONS[String(f.ref)];
|
||||||
|
if (motif) {
|
||||||
|
excluded.push({ ref: f.ref, date: iso(f.date), total_ttc: f.total_ttc, motif });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payments = await api<Any[]>(`/invoices/${f.id}/payments`) ?? [];
|
||||||
|
invoices.push({
|
||||||
|
ref: f.ref,
|
||||||
|
socid: f.socid,
|
||||||
|
date: iso(f.date),
|
||||||
|
date_lim_reglement: iso(f.date_lim_reglement),
|
||||||
|
type: f.type,
|
||||||
|
total_ht: f.total_ht,
|
||||||
|
total_tva: f.total_tva,
|
||||||
|
total_ttc: f.total_ttc,
|
||||||
|
multicurrency_code: f.multicurrency_code,
|
||||||
|
statut: f.statut,
|
||||||
|
paye: f.paye,
|
||||||
|
delivered: true,
|
||||||
|
lines: (f.lines as Any[] ?? []).map((l) => ({
|
||||||
|
desc: l.desc, subprice: l.subprice, qty: l.qty, tva_tx: l.tva_tx, product_type: l.product_type,
|
||||||
|
})),
|
||||||
|
payments: payments.map((p) => ({ amount: p.amount, type: p.type, date: p.date, ref: p.ref })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`factures client : ${invoices.length} (figées — déjà remises au client)`);
|
||||||
|
for (const e of excluded) console.log(` ⊘ exclue : ${e.ref} (${e.total_ttc} EUR) — ${String(e.motif).slice(0, 72)}…`);
|
||||||
|
|
||||||
|
// --- factures fournisseur ---------------------------------------------------
|
||||||
|
const supplierInvoices = [];
|
||||||
|
for (const f of await api<Any[]>("/supplierinvoices?limit=500") ?? []) {
|
||||||
|
if (!inYear(f.date)) continue;
|
||||||
|
const payments = await api<Any[]>(`/supplierinvoices/${f.id}/payments`) ?? [];
|
||||||
|
supplierInvoices.push({
|
||||||
|
ref: f.ref,
|
||||||
|
ref_supplier: f.ref_supplier,
|
||||||
|
socid: f.socid,
|
||||||
|
date: iso(f.date),
|
||||||
|
total_ht: f.total_ht,
|
||||||
|
total_tva: f.total_tva,
|
||||||
|
total_ttc: f.total_ttc,
|
||||||
|
statut: f.statut,
|
||||||
|
paye: f.paye,
|
||||||
|
lines: (f.lines as Any[] ?? []).map((l) => ({
|
||||||
|
desc: l.desc, subprice: l.subprice, qty: l.qty, tva_tx: l.tva_tx, product_type: l.product_type,
|
||||||
|
})),
|
||||||
|
payments: payments.map((p) => ({ amount: p.amount, type: p.type, date: p.date, ref: p.ref })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`factures fournisseur : ${supplierInvoices.length}`);
|
||||||
|
|
||||||
|
// --- comptes et mouvements bancaires ---------------------------------------
|
||||||
|
// LE RÉFÉRENT. Tout le reste se vérifie contre ces lignes.
|
||||||
|
const accounts = [];
|
||||||
|
let bankLineCount = 0;
|
||||||
|
for (const a of await api<Any[]>("/bankaccounts") ?? []) {
|
||||||
|
const lines = (await api<Any[]>(`/bankaccounts/${a.id}/lines?limit=1000`) ?? [])
|
||||||
|
.map((l) => ({
|
||||||
|
date: iso(l.datev ?? l.dateo),
|
||||||
|
amount: Number(l.amount),
|
||||||
|
label: l.label,
|
||||||
|
// Les libellés générés par Dolibarr — « SupplierInvoicePayment »,
|
||||||
|
// « CustomerInvoicePayment » — traversent le grand livre tels quels et y
|
||||||
|
// sont illisibles. Le rejeu doit les remplacer par le libellé de la pièce.
|
||||||
|
generic_label: /^\(?(Customer|Supplier)InvoicePayment\)?$/.test(String(l.label ?? "")),
|
||||||
|
num_releve: l.num_releve,
|
||||||
|
}))
|
||||||
|
.filter((l) => l.date)
|
||||||
|
.sort((x, y) => String(x.date).localeCompare(String(y.date)));
|
||||||
|
bankLineCount += lines.length;
|
||||||
|
accounts.push({
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
ref: a.ref,
|
||||||
|
account_number: a.account_number,
|
||||||
|
accountancy_journal: a.accountancy_journal,
|
||||||
|
currency_code: a.currency_code,
|
||||||
|
balance: Number(lines.reduce((s, l) => s + l.amount, 0).toFixed(2)),
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`comptes bancaires : ${accounts.length} (${bankLineCount} mouvements — référent de vérification)`);
|
||||||
|
|
||||||
|
const generics = accounts.flatMap((a) => a.lines).filter((l) => l.generic_label).length;
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
meta: {
|
||||||
|
exercice: year,
|
||||||
|
source: base,
|
||||||
|
extracted_at: new Date().toISOString(),
|
||||||
|
// Un exercice clos ne se rejoue pas : la clôture est la limite dure.
|
||||||
|
replayable: true,
|
||||||
|
note: "Manifeste d'exercice — description des faits, non des écritures comptables. "
|
||||||
|
+ "Les factures client sont marquées delivered:true : elles existent chez le client "
|
||||||
|
+ "et se reproduisent à l'identique, jamais ne se recalculent.",
|
||||||
|
},
|
||||||
|
exclusions: excluded,
|
||||||
|
anomalies: {
|
||||||
|
libelles_generiques: generics,
|
||||||
|
detail: generics
|
||||||
|
? "lignes bancaires portant un libellé Dolibarr générique — à remplacer au rejeu"
|
||||||
|
: "aucune",
|
||||||
|
},
|
||||||
|
tiers,
|
||||||
|
invoices,
|
||||||
|
supplierInvoices,
|
||||||
|
accounts,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Deno.writeTextFile(out, JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
const totalClient = invoices.reduce((s, f) => s + Number(f.total_ttc ?? 0), 0);
|
||||||
|
const totalFourn = supplierInvoices.reduce((s, f) => s + Number(f.total_ttc ?? 0), 0);
|
||||||
|
console.log(`\n--- récapitulatif ---`);
|
||||||
|
console.log(` produits facturés : ${totalClient.toFixed(2)} EUR`);
|
||||||
|
console.log(` achats facturés : ${totalFourn.toFixed(2)} EUR`);
|
||||||
|
for (const a of accounts) console.log(` ${String(a.label).padEnd(32)} ${a.balance.toFixed(2)} EUR`);
|
||||||
|
console.log(` libellés génériques : ${generics} ← à corriger au rejeu`);
|
||||||
|
console.log(`\nmanifeste écrit : ${out}`);
|
||||||
Reference in New Issue
Block a user