diff --git a/ansible/arcodange/erp/README.md b/ansible/arcodange/erp/README.md
index 289e04a..4830a4f 100644
--- a/ansible/arcodange/erp/README.md
+++ b/ansible/arcodange/erp/README.md
@@ -1,3 +1,7 @@
# Ansible Collection - arcodange.erp
Documentation for the collection.
+
+```sh
+ ansible-playbook playbooks/recurrentBackup.yml -i ~/.ansible/collections/ansible_collections/arcodange/factory/inventory -vv
+ ```
\ No newline at end of file
diff --git a/fleet/profile/decisions/adc-011-tenue-comptable-et-discretion.md b/fleet/profile/decisions/adc-011-tenue-comptable-et-discretion.md
index 216c0a5..bfeee6c 100644
--- a/fleet/profile/decisions/adc-011-tenue-comptable-et-discretion.md
+++ b/fleet/profile/decisions/adc-011-tenue-comptable-et-discretion.md
@@ -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
l'enquête en publiant*.
-**Limite qui inverse la règle :** un flux financier entre la société et son
-dirigeant se déclare, même non demandé, dès lors que le document porte
-précisément sur l'absence d'un tel flux. Taire ce qui contredirait le document
-n'est pas de la discrétion, c'est une omission.
+**Limite qui inverse la règle — et elle est étroite :** ne se déclare, même non
+demandé, que ce qui rendrait le document **faux** si on le taisait. Le critère
+est la véracité de ce qui est affirmé, pas l'exhaustivité.
+
+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
diff --git a/fleet/profile/exercices/2026/manifest.json b/fleet/profile/exercices/2026/manifest.json
new file mode 100644
index 0000000..6fe5e16
--- /dev/null
+++ b/fleet/profile/exercices/2026/manifest.json
@@ -0,0 +1,1117 @@
+{
+ "meta": {
+ "exercice": 2026,
+ "source": "https://erp.arcodange.lab",
+ "extracted_at": "2026-08-14T15:54:36.583Z",
+ "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": [
+ {
+ "ref": "FAC001-CL00001",
+ "date": "2026-01-29",
+ "total_ttc": "510.00000000",
+ "motif": "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."
+ },
+ {
+ "ref": "AVC001-CL0001001",
+ "date": "2026-01-29",
+ "total_ttc": "-510.00000000",
+ "motif": "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."
+ }
+ ],
+ "anomalies": {
+ "libelles_generiques": 20,
+ "detail": "lignes bancaires portant un libellé Dolibarr générique — à remplacer au rejeu"
+ },
+ "tiers": [
+ {
+ "id": "1",
+ "name": "KissMetrics",
+ "code_client": "CL0001",
+ "code_fournisseur": null,
+ "client": "1",
+ "fournisseur": "0",
+ "country_id": "11",
+ "tva_intra": "",
+ "code_compta": null,
+ "code_compta_fournisseur": null
+ },
+ {
+ "id": "2",
+ "name": "Wise Europe SA",
+ "code_client": null,
+ "code_fournisseur": "FO0001",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "2",
+ "tva_intra": "",
+ "code_compta": null,
+ "code_compta_fournisseur": "401WISEEU"
+ },
+ {
+ "id": "3",
+ "name": "Greffe du tribunal de commerce Évry",
+ "code_client": null,
+ "code_fournisseur": "FO0002",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR09326467370",
+ "code_compta": null,
+ "code_compta_fournisseur": "401GREFFE"
+ },
+ {
+ "id": "4",
+ "name": "YOLAW",
+ "code_client": null,
+ "code_fournisseur": "FO0003",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR18753892926",
+ "code_compta": null,
+ "code_compta_fournisseur": "401YOLAW"
+ },
+ {
+ "id": "5",
+ "name": "Medialex",
+ "code_client": null,
+ "code_fournisseur": "FO0004",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "",
+ "code_compta": null,
+ "code_compta_fournisseur": "401MEDIAL"
+ },
+ {
+ "id": "6",
+ "name": "Qonto",
+ "code_client": null,
+ "code_fournisseur": "FO0005",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR10819489626",
+ "code_compta": null,
+ "code_compta_fournisseur": "401QONTO"
+ },
+ {
+ "id": "7",
+ "name": "OVH",
+ "code_client": null,
+ "code_fournisseur": "FO0006",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR22424761419",
+ "code_compta": null,
+ "code_compta_fournisseur": "401OVH"
+ },
+ {
+ "id": "8",
+ "name": "La Poste",
+ "code_client": null,
+ "code_fournisseur": "FO0007",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR39356000000",
+ "code_compta": null,
+ "code_compta_fournisseur": "401LAPOST"
+ },
+ {
+ "id": "9",
+ "name": "Infogreffe",
+ "code_client": null,
+ "code_fournisseur": "FO0008",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR69338885718",
+ "code_compta": null,
+ "code_compta_fournisseur": "401INFOGR"
+ },
+ {
+ "id": "10",
+ "name": "Darnis Operations",
+ "code_client": null,
+ "code_fournisseur": "FO0009",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": "1",
+ "tva_intra": "FR51920267531",
+ "code_compta": null,
+ "code_compta_fournisseur": "401DARNIS"
+ },
+ {
+ "id": "11",
+ "name": "Anthropic Ireland Limited",
+ "code_client": null,
+ "code_fournisseur": "FO0010",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": null,
+ "tva_intra": "",
+ "code_compta": null,
+ "code_compta_fournisseur": "401ANTHRO"
+ },
+ {
+ "id": "12",
+ "name": "Mistral AI SAS",
+ "code_client": null,
+ "code_fournisseur": "FO0011",
+ "client": "0",
+ "fournisseur": "1",
+ "country_id": null,
+ "tva_intra": "",
+ "code_compta": null,
+ "code_compta_fournisseur": "401MISTRA"
+ }
+ ],
+ "invoices": [
+ {
+ "ref": "FAC001-CL0001001",
+ "socid": "1",
+ "date": "2026-01-29",
+ "date_lim_reglement": "2026-02-08",
+ "type": "0",
+ "total_ht": "510.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "510.00000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "TVA non applicable – Article 259-1 du CGI – Prestation de services localisée hors de France (USA)",
+ "subprice": "510.00000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "510.00000000",
+ "type": "VIR",
+ "date": "2026-02-05 12:00:00",
+ "ref": "REC002-####002"
+ }
+ ]
+ },
+ {
+ "ref": "FAC002-CL0001002",
+ "socid": "1",
+ "date": "2026-02-23",
+ "date_lim_reglement": "2026-03-30",
+ "type": "0",
+ "total_ht": "5100.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "5100.00000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "TVA non applicable – Article 259-1 du CGI – Prestation de services localisée hors de France (USA)",
+ "subprice": "510.00000000",
+ "qty": "10",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "5100.00000000",
+ "type": "VIR",
+ "date": "2026-03-12 12:00:00",
+ "ref": "REC003-CL00002"
+ }
+ ]
+ },
+ {
+ "ref": "FAC003-CL0001003",
+ "socid": "1",
+ "date": "2026-02-23",
+ "date_lim_reglement": "2026-03-30",
+ "type": "0",
+ "total_ht": "2550.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2550.00000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "TVA non applicable – Article 259-1 du CGI – Prestation de services localisée hors de France (USA)",
+ "subprice": "510.00000000",
+ "qty": "5",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "2550.00000000",
+ "type": "VIR",
+ "date": "2026-04-20 12:00:00",
+ "ref": "REC001-CL0001001"
+ }
+ ]
+ },
+ {
+ "ref": "FAC004-CL0001004",
+ "socid": "1",
+ "date": "2026-05-22",
+ "date_lim_reglement": "2026-06-29",
+ "type": "0",
+ "total_ht": "2145.92000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2145.92000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "Conseil et accompagnement infrastructure cloud - Cycle M1 (part fixe) - periode du 23/04/2026 au 23/05/2026.\nTVA non applicable - Articles 259, 1 et 283-2 du CGI - prestation de services a un preneur assujetti etabli hors UE (Etats-Unis).",
+ "subprice": "2145.92000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "2145.92000000",
+ "type": "VIR",
+ "date": "2026-05-29 00:00:00",
+ "ref": "REC002-######001"
+ }
+ ]
+ },
+ {
+ "ref": "FAC005-CL0001005",
+ "socid": "1",
+ "date": "2026-05-22",
+ "date_lim_reglement": "2026-08-22",
+ "type": "0",
+ "total_ht": "2575.11000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2575.11000000",
+ "multicurrency_code": "EUR",
+ "statut": "1",
+ "paye": "0",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "Conseil et accompagnement infrastructure cloud - Cycle M1 (part differee) - periode du 23/04/2026 au 23/05/2026.\nPaiement differe a 3 mois - echeance 23/08/2026.\nTVA non applicable - Articles 259, 1 et 283-2 du CGI - prestation de services a un preneur assujetti etabli hors UE (Etats-Unis).",
+ "subprice": "2575.11000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": []
+ },
+ {
+ "ref": "FAC006-CL0001006",
+ "socid": "1",
+ "date": "2026-06-22",
+ "date_lim_reglement": "2026-07-30",
+ "type": "0",
+ "total_ht": "2145.92000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2145.92000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "Conseil et accompagnement infrastructure cloud - Cycle M2 (part fixe) - periode du 23/05/2026 au 23/06/2026.\nTVA non applicable - Articles 259, 1 et 283-2 du CGI - prestation de services a un preneur assujetti etabli hors UE (Etats-Unis).",
+ "subprice": "2145.92000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "2145.92000000",
+ "type": "VIR",
+ "date": "2026-06-25 00:00:00",
+ "ref": "REC002-######002"
+ }
+ ]
+ },
+ {
+ "ref": "FAC007-CL0001007",
+ "socid": "1",
+ "date": "2026-06-22",
+ "date_lim_reglement": "2026-09-22",
+ "type": "0",
+ "total_ht": "2575.11000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2575.11000000",
+ "multicurrency_code": "EUR",
+ "statut": "1",
+ "paye": "0",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "Conseil et accompagnement infrastructure cloud - Cycle M2 (part differee) - periode du 23/05/2026 au 23/06/2026.\nPaiement differe a 3 mois - echeance 23/09/2026.\nTVA non applicable - Articles 259, 1 et 283-2 du CGI - prestation de services a un preneur assujetti etabli hors UE (Etats-Unis).",
+ "subprice": "2575.11000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": []
+ },
+ {
+ "ref": "FAC008-CL0001008",
+ "socid": "1",
+ "date": "2026-07-22",
+ "date_lim_reglement": "2026-08-21",
+ "type": "0",
+ "total_ht": "2185.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "2185.00000000",
+ "multicurrency_code": "EUR",
+ "statut": "2",
+ "paye": "1",
+ "delivered": true,
+ "lines": [
+ {
+ "desc": "Conseil et accompagnement infrastructure cloud — Cycle M3 (part fixe) — période d'exécution du 23/06/2026 au 23/07/2026. Contrat cadre du 23/04/2026, art. 6 ; avenant art. 2 et 4. Montant contractuel 2 500,00 USD converti à 2 185,00 € (taux du jour du paiement).\nPrestation de services au sens des articles 259, 1° et 283-2 du Code général des impôts. TVA française non applicable — preneur assujetti établi hors de l'Union européenne (États-Unis). Auto-liquidation par le preneur dans son pays d'établissement le cas échéant. / Services rendered pursuant to Articles 259, 1° and 283-2 of the French General Tax Code. French VAT not applicable — recipient is a taxable person established outside the European Union (United States). Reverse charge by the recipient in its country of establishment if applicable.",
+ "subprice": "2185.00000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "2185.00000000",
+ "type": "VIR",
+ "date": "2026-07-20 00:00:00",
+ "ref": "REC003-######003"
+ }
+ ]
+ }
+ ],
+ "supplierInvoices": [
+ {
+ "ref": "FAF2026001",
+ "ref_supplier": "PLAN_ORDER_CHECKOUT-invoice-23917601",
+ "socid": "2",
+ "date": "2026-01-25",
+ "total_ht": "50.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "50.00000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Ouverture compte - Exonération de TVA – Article 261 C du CGI",
+ "subprice": "50.00000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "50.00000000",
+ "type": "VIR",
+ "date": "2026-01-26 12:00:00",
+ "ref": "REF2026001"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026002",
+ "ref_supplier": "26004975",
+ "socid": "3",
+ "date": "2026-01-08",
+ "total_ht": "47.69000000",
+ "total_tva": "9.54000000",
+ "total_ttc": "57.23000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Déclaration relative au bénéficiaire effectif lors de la demande
\r\nd'immatriculation 16,951",
+ "subprice": "16.95000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ },
+ {
+ "desc": "Immatriculation Personne Morale sur création",
+ "subprice": "23.30000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ },
+ {
+ "desc": "Dépôts d'actes ou de pièces",
+ "subprice": "6.36000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ },
+ {
+ "desc": "Acte de greffe (Réclamation de renseignements ou pièces manquantes (C.
\r\ncom., R 123-97 al 2))",
+ "subprice": "1.08000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "55.93000000",
+ "type": "CB",
+ "date": "2026-01-09 12:00:00",
+ "ref": "REF2026002"
+ },
+ {
+ "amount": "1.30000000",
+ "type": "CB",
+ "date": "2026-01-15 12:00:00",
+ "ref": "REF2026003"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026003",
+ "ref_supplier": "1091237202601",
+ "socid": "4",
+ "date": "2026-01-03",
+ "total_ht": "1.66000000",
+ "total_tva": "0.33000000",
+ "total_ttc": "1.99000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Modèle Statuts EURL",
+ "subprice": "1.66000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "1.99000000",
+ "type": "CB",
+ "date": "2026-01-04 12:00:00",
+ "ref": "REF2026004"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026004",
+ "ref_supplier": "74389798",
+ "socid": "5",
+ "date": "2026-01-08",
+ "total_ht": "124.00000000",
+ "total_tva": "24.80000000",
+ "total_ttc": "148.80000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "AVIS DE CONSTITUTION SARLU ARCODANGE
\r\nIdentifiant annonce: 22565141
\r\nparution le 10/01/2026 sur 20 Minutes Essonne",
+ "subprice": "124.00000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "148.80000000",
+ "type": "CB",
+ "date": "2026-01-09 12:00:00",
+ "ref": "REF2026005"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026005",
+ "ref_supplier": "06-01-26-payment-366753",
+ "socid": "6",
+ "date": "2026-01-05",
+ "total_ht": "169.00000000",
+ "total_tva": "33.80000000",
+ "total_ttc": "202.80000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Pack Qonto Basic
\r\nVotre pack inclut :
\r\n- Prestation dépôt de capital en ligne- 12 mois d'abonnement Qonto au
\r\nforfait Basic (à partir de la date devérification réglementaire de votre
\r\nnouvelle entreprise",
+ "subprice": "169.00000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "202.80000000",
+ "type": "CB",
+ "date": "2026-01-06 12:00:00",
+ "ref": "REF2026006"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026006",
+ "ref_supplier": "210000003184C",
+ "socid": "8",
+ "date": "2026-01-11",
+ "total_ht": "8.43000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "8.43000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Lettre recommandée: 22 g
\r\nExonération de TVA, article 261-4-1° du CGI",
+ "subprice": "8.43000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "8.43000000",
+ "type": "CB",
+ "date": "2026-01-12 12:00:00",
+ "ref": "REF2026007"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026007",
+ "ref_supplier": "60117-ZMHMT",
+ "socid": "9",
+ "date": "2026-01-16",
+ "total_ht": "2.67000000",
+ "total_tva": "0.53000000",
+ "total_ttc": "3.20000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Extrait KBIS et frais d'emission
\r\nFormat electronique",
+ "subprice": "2.67000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "3.20000000",
+ "type": "CB",
+ "date": "2026-01-17 12:00:00",
+ "ref": "REF2026008"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026008",
+ "ref_supplier": "F1040",
+ "socid": "10",
+ "date": "2026-02-27",
+ "total_ht": "510.00000000",
+ "total_tva": "102.00000000",
+ "total_ttc": "612.00000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Apport d'affaire Arcodange ",
+ "subprice": "510.00000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "612.00000000",
+ "type": "VIR",
+ "date": "2026-03-13 12:00:00",
+ "ref": "REF2026009"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026009",
+ "ref_supplier": "F1042",
+ "socid": "10",
+ "date": "2026-02-27",
+ "total_ht": "255.00000000",
+ "total_tva": "51.00000000",
+ "total_ttc": "306.00000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Apport d'affaire Arcodange ",
+ "subprice": "510.00000000",
+ "qty": "0.5",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "306.00000000",
+ "type": "VIR",
+ "date": "2026-05-10 12:00:00",
+ "ref": "REF2026010"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026010",
+ "ref_supplier": "F1046",
+ "socid": "10",
+ "date": "2026-06-28",
+ "total_ht": "219.50000000",
+ "total_tva": "43.90000000",
+ "total_ttc": "263.40000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Apport d affaire Arcodange - Projet — juin 2026 (Hiway F1046)",
+ "subprice": "219.50000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "263.40000000",
+ "type": "VIR",
+ "date": "2026-06-29 00:00:00",
+ "ref": "REF2026012"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026011",
+ "ref_supplier": "9BF0758D-695749",
+ "socid": "11",
+ "date": "2026-04-11",
+ "total_ht": "180.00000000",
+ "total_tva": "0.00000000",
+ "total_ttc": "180.00000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Claude Pro — abonnement avril 2026 (facture 9BF0758D-695749, autoliquidation UE)",
+ "subprice": "180.00000000",
+ "qty": "1",
+ "tva_tx": "0.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "180.00000000",
+ "type": "CB",
+ "date": "2026-04-13 00:00:00",
+ "ref": "REF2026014"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026012",
+ "ref_supplier": "MSTRL-API-814045-001",
+ "socid": "12",
+ "date": "2026-04-01",
+ "total_ht": "143.90000000",
+ "total_tva": "28.78000000",
+ "total_ttc": "172.68000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "",
+ "subprice": "0.00000000",
+ "qty": "0",
+ "tva_tx": "0.0000",
+ "product_type": "0"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "172.68000000",
+ "type": "CB",
+ "date": "2026-04-03 00:00:00",
+ "ref": "REF2026013"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026013",
+ "ref_supplier": "F1045",
+ "socid": "10",
+ "date": "2026-06-29",
+ "total_ht": "214.70000000",
+ "total_tva": "42.94000000",
+ "total_ttc": "257.64000000",
+ "statut": "2",
+ "paye": "1",
+ "lines": [
+ {
+ "desc": "Apport d'affaire Arcodange - Projet — juin 2026 (facture Hiway F1045 du 30/06/2026)",
+ "subprice": "214.70000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": [
+ {
+ "amount": "257.64000000",
+ "type": "VIR",
+ "date": "2026-06-29 00:00:00",
+ "ref": "REF2026011"
+ }
+ ]
+ },
+ {
+ "ref": "FAF2026014",
+ "ref_supplier": "F1048",
+ "socid": "10",
+ "date": "2026-08-30",
+ "total_ht": "218.50000000",
+ "total_tva": "43.70000000",
+ "total_ttc": "262.20000000",
+ "statut": "1",
+ "paye": "0",
+ "lines": [
+ {
+ "desc": "Apport d'affaire Arcodange — commission 10 % sur encaissement KM du 20/07/2026",
+ "subprice": "218.50000000",
+ "qty": "1",
+ "tva_tx": "20.0000",
+ "product_type": "1"
+ }
+ ],
+ "payments": []
+ }
+ ],
+ "accounts": [
+ {
+ "id": "1",
+ "label": "QONTO",
+ "ref": "QON1",
+ "account_number": "51211",
+ "accountancy_journal": "BQ1",
+ "currency_code": "EUR",
+ "balance": 4158.28,
+ "lines": [
+ {
+ "date": "2026-01-06",
+ "amount": 1000,
+ "label": "(Solde initial)",
+ "generic_label": false,
+ "num_releve": "202601"
+ },
+ {
+ "date": "2026-01-25",
+ "amount": -50,
+ "label": "ouverture compte WISE",
+ "generic_label": false,
+ "num_releve": "202601"
+ },
+ {
+ "date": "2026-03-12",
+ "amount": 5000,
+ "label": "virement du compte international vers le compte principal",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-03-12",
+ "amount": -612,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-04-02",
+ "amount": -172.68,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-04-12",
+ "amount": -180,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-05-09",
+ "amount": -306,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-06-28",
+ "amount": -257.64,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-06-28",
+ "amount": -263.4,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ }
+ ]
+ },
+ {
+ "id": "2",
+ "label": "WISE EURO",
+ "ref": "WIS2",
+ "account_number": "51212",
+ "accountancy_journal": "BQ2",
+ "currency_code": "EUR",
+ "balance": 9636.84,
+ "lines": [
+ {
+ "date": "2026-01-25",
+ "amount": 0,
+ "label": "Solde initial",
+ "generic_label": false,
+ "num_releve": "202601"
+ },
+ {
+ "date": "2026-01-25",
+ "amount": 50,
+ "label": "ouverture compte WISE",
+ "generic_label": false,
+ "num_releve": "202601"
+ },
+ {
+ "date": "2026-01-25",
+ "amount": -50,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": "202601"
+ },
+ {
+ "date": "2026-02-04",
+ "amount": 510,
+ "label": "",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-02-04",
+ "amount": 510,
+ "label": "",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-02-04",
+ "amount": -510,
+ "label": "(CustomerInvoicePaymentBack)",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-03-11",
+ "amount": 5100,
+ "label": "(CustomerInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-03-12",
+ "amount": -5000,
+ "label": "virement du compte international vers le compte principal",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-04-19",
+ "amount": 2550,
+ "label": "(CustomerInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-05-28",
+ "amount": 2145.92,
+ "label": "(CustomerInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-06-24",
+ "amount": 2145.92,
+ "label": "(CustomerInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-07-19",
+ "amount": 2185,
+ "label": "(CustomerInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ }
+ ]
+ },
+ {
+ "id": "3",
+ "label": "G.RADUREAU Compte Courant Asso",
+ "ref": "CCA1",
+ "account_number": "45511",
+ "accountancy_journal": "CCA1",
+ "currency_code": "EUR",
+ "balance": -1912.98,
+ "lines": [
+ {
+ "date": "2025-10-23",
+ "amount": -7.3,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-03",
+ "amount": -1.99,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-05",
+ "amount": -202.8,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-08",
+ "amount": -55.93,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-08",
+ "amount": -148.8,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-11",
+ "amount": -8.43,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-14",
+ "amount": -1.3,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-15",
+ "amount": 0,
+ "label": "Solde initial",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-16",
+ "amount": -3.2,
+ "label": "(SupplierInvoicePayment)",
+ "generic_label": true,
+ "num_releve": null
+ },
+ {
+ "date": "2026-01-30",
+ "amount": -163.23,
+ "label": "Indemnité d'occupation — janvier 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-02-27",
+ "amount": -220,
+ "label": "Indemnité d'occupation — février 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-03-30",
+ "amount": -220,
+ "label": "Indemnité d'occupation — mars 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-04-29",
+ "amount": -220,
+ "label": "Indemnité d'occupation — avril 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-05-30",
+ "amount": -220,
+ "label": "Indemnité d'occupation — mai 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-06-29",
+ "amount": -220,
+ "label": "Indemnité d'occupation — juin 2026",
+ "generic_label": false,
+ "num_releve": null
+ },
+ {
+ "date": "2026-07-30",
+ "amount": -220,
+ "label": "Indemnité d'occupation — juillet 2026",
+ "generic_label": false,
+ "num_releve": null
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/ops/backup/dolibarr-backup.sh b/ops/backup/dolibarr-backup.sh
index 5ebfbf0..f932cae 100755
--- a/ops/backup/dolibarr-backup.sh
+++ b/ops/backup/dolibarr-backup.sh
@@ -37,6 +37,29 @@ TMP_S3_SECRET="dolibarr-backup-s3-temp"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
+# --- garde de cluster -------------------------------------------------------
+# Ce script lit des secrets, crée des Jobs et peut RESTAURER une base. Lancé sur
+# le mauvais contexte kubectl, il part sur l'infrastructure de quelqu'un d'autre.
+# Le contexte courant d'une station de travail n'est pas une garantie : il suffit
+# d'un `K config use-context` oublié. On l'épingle donc, et on vérifie une
+# empreinte POSITIVE du homelab avant d'agir — mêmes garde-fous que
+# ops/sandbox/sandbox-lifecycle.sh.
+ERP_KUBE_CONTEXT="${ERP_KUBE_CONTEXT:-default}"
+K() { kubectl --context "$ERP_KUBE_CONTEXT" "$@"; }
+
+assert_arcodange_cluster() {
+ kubectl config get-contexts -o name 2>/dev/null | grep -qx "$ERP_KUBE_CONTEXT" \
+ || die "kube-context '$ERP_KUBE_CONTEXT' n'existe pas (définir ERP_KUBE_CONTEXT)"
+ for ns in erp erp-sandbox "$S3_SRC_NS"; do
+ K get ns "$ns" >/dev/null 2>&1 \
+ || die "le contexte '$ERP_KUBE_CONTEXT' n'a pas de namespace '$ns' — refus de s'y exécuter.
+ Ce script lit des secrets et peut restaurer une base ; il ne doit viser que le homelab Arcodange.
+ Contexte courant : '$(kubectl config current-context 2>/dev/null)'.
+ Définir ERP_KUBE_CONTEXT sur le contexte du homelab et réessayer."
+ done
+ log "garde de cluster OK — contexte '$ERP_KUBE_CONTEXT' (namespaces erp/erp-sandbox/$S3_SRC_NS)"
+}
+
CMD="${1:-}"; shift || true
ENV="prod"; KEY=""; KIND=""; YES=0
while [[ $# -gt 0 ]]; do
@@ -72,11 +95,11 @@ SH
copy_s3_secret() {
command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it"
- kubectl get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \
+ K get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \
- | kubectl apply -f - >/dev/null
+ | K apply -f - >/dev/null
}
-cleanup_secret() { kubectl delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; }
+cleanup_secret() { K delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; }
# b64-encode an in-container script (host vars already substituted by the caller)
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
@@ -87,8 +110,8 @@ run_backup() {
copy_s3_secret
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")"
- kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
- kubectl apply -f - >/dev/null </dev/null 2>&1 || true
+ K apply -f - >/dev/null </dev/null 2>&1 \
- || die "backup Job did not complete — kubectl logs -n $NS job/dolibarr-backup"
- kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
- kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
+ K wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
+ || die "backup Job did not complete — K logs -n $NS job/dolibarr-backup"
+ K logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
+ K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
log "Backup complete."
}
@@ -135,8 +158,8 @@ echo "db/:"; S3 ls "s3://$BUCKET/$PREFIX/db/" || echo " (empty)"
echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)"
EOF
)"
- kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
- kubectl apply -f - >/dev/null </dev/null 2>&1 || true
+ K apply -f - >/dev/null </dev/null 2>&1 || true
- kubectl logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
- kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
+ K wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
+ K logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
+ K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
}
@@ -167,8 +190,8 @@ run_restore() {
copy_s3_secret
local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox)
log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)"
- kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true
- kubectl wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true
+ K scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true
+ K wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true
local SCRIPT VOLS="[]" MOUNTS="[]"
if [[ "$KIND" == "db" ]]; then
@@ -197,8 +220,8 @@ EOF
)"
fi
local B64; B64="$(b64 "$SCRIPT")"
- kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
- kubectl apply -f - >/dev/null </dev/null 2>&1 || true
+ K apply -f - >/dev/null </dev/null 2>&1; then
- kubectl logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
- kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
+ if ! K wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then
+ K logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
+ K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
die "restore Job did not complete"
fi
- kubectl logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
- kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
+ K logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
+ K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
log "Scaling ${DEPLOY} back to 1"
- kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
+ K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
log "Restore complete."
}
+assert_arcodange_cluster
+
case "$CMD" in
backup) run_backup ;;
list) run_list ;;
diff --git a/ops/sandbox/sandbox-lifecycle.sh b/ops/sandbox/sandbox-lifecycle.sh
index 7761d24..a0112a2 100755
--- a/ops/sandbox/sandbox-lifecycle.sh
+++ b/ops/sandbox/sandbox-lifecycle.sh
@@ -35,6 +35,14 @@ PG_IMAGE="postgres:16-alpine"
DOC_ROOT="/var/www/documents" # dolibarr_main_data_root
TMP_PROD_SECRET="prod-db-ro-temp" # transient copy of prod creds, deleted on exit
+# ATTENTION — NE JAMAIS ÉDITER CE FICHIER PENDANT QU'IL S'EXÉCUTE.
+# Bash lit ses scripts au fil de l'eau : modifier le fichier en cours de route
+# décale les offsets et corrompt l'exécution. Constaté le 2026-08-14 — un
+# refresh-from-prod en cours s'est mis à afficher les messages de blank_sandbox
+# puis a fini sur « syntax error ». Le travail était fait, mais la trace mentait
+# sur ce qui tournait : sur un script qui purge des bases, c'est inacceptable.
+# Attendre la fin, ou éditer une copie.
+
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
@@ -173,9 +181,111 @@ sync_documents() {
log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)"
}
+# --- mode vierge -------------------------------------------------------------
+# Vide la base du bac à sable pour qu'un rejeu d'exercice reparte de zéro.
+# `refresh-from-prod` réimporterait précisément les défauts que le rejeu doit
+# corriger : reconstruire suppose donc une base vide, pas iso-prod.
+#
+# CETTE COMMANDE N'EST QU'UNE ÉTAPE. Seule, elle laisse le bac à sable sans
+# schéma — Dolibarr sert alors une page de login sur une base vide. La chaîne
+# complète, déjà outillée dans test/ :
+#
+# 1. sandbox-lifecycle.sh blank --yes purge la base, retire install.lock
+# 2. cd test && deno run -A main.ts pilote l'installeur (/install/), la
+# société, l'affichage et les modules
+# 3. touch install.lock dans le pod reverrouille (recette du test/README.md)
+# 4. deno run -A provisionSandbox.ts agent d'écriture, droits, clé API
+#
+# CE QUI REND L'OPÉRATION SÛRE : elle est ANNULABLE. `refresh-from-prod` restaure
+# un bac à sable iso-prod en trois minutes. On peut donc casser sans regret — et
+# c'est la seule raison pour laquelle une commande qui détruit une base entière
+# est acceptable ici.
+#
+# Elle ne vise QUE $SB_NS. Le namespace de production n'apparaît nulle part dans
+# cette fonction, et la garde de cluster refuse tout contexte qui n'est pas le
+# homelab.
+blank_sandbox() {
+ [ "${1:-}" = "--yes" ] || die "blank détruit toutes les données du bac à sable — relancer avec --yes"
+ trap restore_state EXIT
+
+ log "Pause du self-heal ArgoCD (sinon le scale-to-0 est repris en quelques secondes)"
+ set_selfheal false
+
+ log "erp-sandbox à 0 réplique (accès exclusif à la base)"
+ K scale deploy erp-sandbox -n "$SB_NS" --replicas=0 >/dev/null
+ K wait --for=delete pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=120s >/dev/null 2>&1 || true
+
+ log "Job de purge (DROP OWNED, aucune restauration)"
+ K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
+ K apply -f - >/dev/null </dev/null 2>&1 \
+ || die "le Job de purge n'a pas abouti — voir : K logs -n $SB_NS job/sandbox-blank"
+ K logs -n "$SB_NS" job/sandbox-blank | sed 's/^/ /'
+ K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
+
+ # Le verrou d'installation vit sur le VOLUME DOCUMENTS, que la purge de la base
+ # ne touche pas. Tant qu'il est là, Dolibarr saute l'installeur et sert une page
+ # de login sur un schéma inexistant : l'instance a l'air vivante et ne l'est pas.
+ # Le retirer laisse l'entrypoint de l'image réinstaller un schéma neuf.
+ log "Retrait du verrou d'installation (sinon Dolibarr sert un login sur une base sans schéma)"
+ K scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null
+ K wait --for=condition=ready pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=180s >/dev/null 2>&1 || true
+ POD=$(sb_pod)
+ [ -n "$POD" ] && K exec -n "$SB_NS" "$POD" -- rm -f /var/www/documents/install.lock >/dev/null 2>&1 || true
+
+ log "Redémarrage pour déclencher l'installation"
+ K rollout restart deploy erp-sandbox -n "$SB_NS" >/dev/null
+ K rollout status deploy erp-sandbox -n "$SB_NS" --timeout=300s >/dev/null 2>&1 || true
+
+ log "Réarmement du self-heal ArgoCD"
+ set_selfheal true
+
+ log "Base vidée et verrou retiré. Vérifier que le schéma se reconstruit :"
+ log " curl -s https://erp-sandbox.arcodange.lab/api/index.php/status"
+ log "Annulation à tout moment : $0 refresh-from-prod"
+}
+
case "${1:-}" in
refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;;
+ blank) assert_arcodange_cluster; shift; blank_sandbox "${1:-}" ;;
sync-documents) assert_arcodange_cluster; sync_documents ;;
refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;;
- *) echo "usage: $0 {refresh-from-prod|sync-documents|refresh}" >&2; exit 2 ;;
+ *) echo "usage: $0 {refresh-from-prod|sync-documents|refresh|blank --yes}" >&2; exit 2 ;;
esac
diff --git a/static/config/company.json b/static/config/company.json
index 253fb1b..d78af01 100644
--- a/static/config/company.json
+++ b/static/config/company.json
@@ -6,8 +6,8 @@
"ville": "Evry Courcouronnes",
"pays": "France (FR)",
"telephonePortable": "0664574539",
- "siteWeb": "gitea.arcodange.duckdns.org",
- "email": "arcodange@gmail.com",
+ "siteWeb": "arcodange.fr",
+ "email": "gabrielradureau@arcodange.fr",
"logoFilePath": "$IMG/logo512.png"
},
"ID": {
@@ -22,5 +22,5 @@
"numTva": "FR00999657455",
"rcs_rm": "R.C.S. Évry",
"moisDebutExercice": "Janvier"
- }
-}
\ No newline at end of file
+ }
+}
diff --git a/test/extractExercise.ts b/test/extractExercise.ts
new file mode 100644
index 0000000..b92264c
--- /dev/null
+++ b/test/extractExercise.ts
@@ -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 = {
+ "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(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;
+ }
+}
+
+/** 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;
+
+console.log(`source : ${base}`);
+console.log(`exercice : ${year}\n`);
+
+// --- tiers ------------------------------------------------------------------
+const tiers = (await api("/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("/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(`/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("/supplierinvoices?limit=500") ?? []) {
+ if (!inYear(f.date)) continue;
+ const payments = await api(`/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("/bankaccounts") ?? []) {
+ const lines = (await api(`/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}`);
diff --git a/test/main.ts b/test/main.ts
index 5149f84..7c22e2d 100644
--- a/test/main.ts
+++ b/test/main.ts
@@ -2,6 +2,7 @@ import "load_dotenv";
import { chromium } from "playwright";
import initialSetup from "./scripts/admin/initialSetup.ts";
import login from "./scripts/login.ts";
+import { assertSandbox } from "./scripts/guard.ts";
import companySetup, { Company } from "./scripts/admin/companySetup.ts";
import path from "node:path";
import displaySetup from "./scripts/admin/displaySetup.ts";
@@ -10,8 +11,14 @@ import moduleSetup from "./scripts/admin/moduleSetup.ts";
/*
Initialisation
*/
-const dolibarrAddress = Deno.env.get("DOLIBARR_ADDRESS") ||
- "https://erp.arcodange.duckdns.org";
+// GARDE D'HÔTE. main.ts pilote l'ASSISTANT D'INSTALLATION (`/install/`) : c'est
+// le script le plus destructeur du dépôt. Pointé sur la production avec
+// l'environnement ambiant — et `test/.env` porte justement DOLIBARR_ADDRESS sur
+// la PRODUCTION — il relancerait l'installeur sur l'ERP réel.
+// assertSandbox() n'autorise que le bac à sable, sauf double opt-in explicite
+// (ARCO_ALLOW_PRODUCTION + ARCO_PROD_CONFIRM) pour l'installation initiale d'un
+// nouvel environnement, qui reste un geste d'opérateur.
+const dolibarrAddress = assertSandbox();
const debug = true;
const DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined";
const adminCredentials = {
@@ -20,7 +27,7 @@ const adminCredentials = {
};
const rootFolderPath = Deno.env.get("ROOT_FOLDER") ||
- "/Users/gabrielradureau/Desktop/ARCODANGE/erp";
+ "/Users/gabrielradureau/Work/Arcodange/erp";
const imgFolderPath = Deno.env.get("IMG_FOLDER") ||
path.join(rootFolderPath, "static/img");
const configFolderPath = Deno.env.get("CONFIG_FOLDER") ||
@@ -71,7 +78,7 @@ const globalCtx = {
try {
// await displaySetup.setupDisplay(globalCtx);
- await moduleSetup.configureModule(globalCtx, {moduleName: 'TIERS' ,enabled: false, onConfiguration: async () => {
+ await moduleSetup.configureModule(globalCtx, {moduleName: 'TIERS' , enabled: true, onConfiguration: async () => {
await globalCtx.page.screenshot({path: 'save.png'})
}});
} catch (error) {
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);