Skill: trendex/rapprochement-bancaire (version 1.0.0)

--- name: rapprochement-bancaire description: Moteur comptable quotidien du cabinet. Analyse les dossiers clients déjà classés par organisation-documents, rapproche les paiements avec les factures, valide la TVA, maintient un unique `rapprochement.json` par client (factures + lignes bancaires non rapprochées + anomalies + relances, groupés par période mensuelle). Le travail réel est fait par scripts/main.py (qui appelle scripts/extract.py). Ne traite jamais les e-mails, ne classe jamais de documents. license: Interne — usage privé OpenClaw --- # Skill `rapprochement-bancaire` > Moteur d'état. Travaille uniquement sur l'arborescence `clients/<slug>/...` produite par `organisation-documents`. > **Le travail réel est fait par `scripts/main.py`** (qui appelle `scripts/extract.py` pour toute l'extraction de texte). Ce skill = quand le lancer + comment rendre compte. --- ## ⚠️ Règle absolue — EXÉCUTION DU SCRIPT (non négociable) **Pour chaque invocation, la SEULE action correcte est d'exécuter la commande :** ```bash python3 scripts/main.py <racine_clients> ``` puis de lire le `rapprochement.json` de chaque client + le `compta_batch_report_<date>.json` consolidé, et de relayer au comptable. ### ❌ INTERDIT - **Réimplémenter le rapprochement en Python inline / pseudo-code.** La logique (Pass 1 par référence, Pass 2 fuzzy, validation TVA, détection des anomalies, statuts overdue/partial, génération des relances, regroupement par période) est dans `scripts/main.py`. La refaire à la main est garanti de diverger. - **Écrire `rapprochement.json` à la main.** C'est le script qui le fait, dans son format exact (objet `{ client, generated_at, periods[] }` ; chaque période contient `invoices[]`, `unmatched_bank_lines[]`, `period_anomalies[]`, `relances[]` ; chaque facture porte son champ `anomalies[]`). Toute autre forme (liste plate, dict keyé par `invoice_id`, champs `payments` / `amount_paid` ad hoc) casse les lectures ultérieures. - **Modifier le format du fichier de sortie.** Le format est contractuel entre ce skill et ses consommateurs (backend de provisioning, agent OpenClaw, dashboards). - **Recréer les anciens fichiers `followup.json` / `relances.json` / `anomalies.json`.** Ils ont été remplacés par le `rapprochement.json` unique. Le script les supprime à chaque run s'ils traînent. ### ✅ OBLIGATOIRE 1. Exécuter la commande shell ci-dessus. 2. Lire le fichier produit. 3. Relayer au comptable un tableau lisible par client (factures payées / en attente / partielles / overdue, relances, anomalies — en mettant en avant les **bloquantes**). ### Si le script échoue Signale l'erreur exacte (stderr). Ne rejoue PAS le batch à la main. > Pourquoi : on a déjà eu un run où l'agent a produit le fichier de sortie au mauvais format (dict avec clés `"in/N"`, champs `payments`/`amount_paid`/`amount_remaining` au lieu de `bank_matched`/`status`). Conséquence : aucun outil ni skill aval ne pouvait l'exploiter. Le script génère le bon format à tous les coups. --- ## Exécution ```bash python3 scripts/main.py [<racine_clients>] # racine_clients par défaut : ~/.openclaw/workspace/clients ``` À lancer : - une fois par jour (idéalement la nuit), sur tous les clients ; - ou immédiatement après un trigger `rapprochement-bancaire` émis par `organisation-documents`. Le script : 1. **Parcourt** `clients/*` en ignorant les dossiers techniques `_*` (`_a-identifier`, `_incomplet`, `_non-attribue`, `_cabinet`). 2. **Périodes traitées** : mois courant + mois précédent, plus tout mois ancien non verrouillé (`batch.lock.json` absent). Un mois verrouillé n'est pas retraité. 3. **Factures** : nom de fichier conventionnel (`AAAA-MM-JJ_N°Facture_Contrepartie_MontantTTC.pdf`) → `invoice_id` + montant. Si le nom n'est pas exploitable, lecture du contenu via `extract.py`. Si toujours rien → anomalie de période `facture_illisible`. 4. **Relevés bancaires** : transactions extraites ligne par ligne via `extract.py` (`DATE | LIBELLÉ | MONTANT | CR/DB`), avec la référence facture si le libellé contient `REF <id>` ou `FACT <id>`. Aucune transaction extractible → anomalie de période `releve_non_parseable`. 5. **Rapprochement** (montant comparé en valeur absolue : une facture `out` est encaissée par un crédit, une `in` réglée par un débit) : - **Pass 1** — réf facture : transaction dont `invoice_ref == facture.invoice_id`. Montant exact (±1 €) → `paid` ; montant inférieur → `partial` (conserve `amount_paid`, `amount_remaining`) ; supérieur → `paid` + `overpaid_by`. - **Pass 2** — fuzzy : `|montant| ±1 €` ET similarité libellé / contrepartie ≥ 0.6. - Aucun match + échéance dépassée → `overdue`. 6. **Validation TVA** de chaque facture : si `|TVA déclarée − TVA attendue| / TVA attendue > 5 %` (TVA attendue = `TOTAL HT × taux`) → anomalie `tva_incorrecte` rattachée à la facture (TVA 0 % / exonération ignorée). 7. **Anomalies** : voir tableau ci-dessous. Les anomalies *rattachables à une facture* (`tva_incorrecte`, `invoice_overdue`) vivent dans le champ `anomalies[]` de la facture. Les anomalies *non rattachables* (`doublon_paiement`, `releve_non_parseable`, `facture_illisible`) vont dans `period_anomalies[]` de leur période. Les lignes bancaires non rapprochées (`facture_manquante`, `paiement_orphelin`) vont dans `unmatched_bank_lines[]` de la période où la transaction a été observée. 8. **Relances** : `overdue` / `partial` / `unpaid` hors délai → step selon ancienneté, dans `relances[]` de la période de la facture. 9. Écrit `clients/<slug>/rapprochement.json` (un seul fichier par client) et un rapport consolidé `compta_batch_report_<date>.json`. **Après l'exécution**, lire le fichier et relayer au comptable un tableau lisible par client (factures payées / en attente, relances, anomalies — en mettant en avant les anomalies **bloquantes**). Jamais de chemins techniques. --- ## Format de sortie (`rapprochement.json`) ```json { "client": "acme-sa", "generated_at": "2026-05-20", "periods": [ { "period": "2026-05", "locked": false, "invoices": [ { "invoice_id": "F-001", "type": "out", "amount": 1200.00, "status": "paid", "bank_matched": true, "matched_tx": "VIR ACME REF F-001", "issued_date": "2026-05-03", "due_date": "2026-06-02", "counterparty": "ACME", "counterparty_name": "ACME SA", "source_file": "clients/acme-sa/2026/05/invoices/out/...", "anomalies": [] } ], "unmatched_bank_lines": [ { "type": "facture_manquante", "invoice_ref": "F-099", "label": "VIR REF F-099", "amount": 2500, "date": "2026-05-12", "blocking": true } ], "period_anomalies": [ { "type": "doublon_paiement", "label": "VIR ACME", "amount": 850, "date": "2026-05-09", "blocking": true } ], "relances": [ { "invoice_id": "F-007", "counterparty": "DUPONT", "amount": 450, "due_date": "2026-04-02", "days_late": 48, "step": 2, "status": "pending", "next_action_date": "2026-05-25" } ] } ] } ``` --- ## Statuts de facture | Statut | Sens | |--------|------| | `unpaid` | non échue, aucun paiement | | `paid` | paiement confirmé (rapprochement réussi) | | `partial` | paiement partiel — `amount_paid` + `amount_remaining` conservés | | `overdue` | échéance dépassée, aucun paiement | --- ## Anomalies **Bloquantes** (empêchent la clôture de la période) : | Type | Où | Condition | |------|----|-----------| | `doublon_paiement` | `period_anomalies` | même date + montant + libellé | | `tva_incorrecte` | `invoices[].anomalies` | écart TVA calculée / déclarée > 5 % | | `facture_manquante` | `unmatched_bank_lines` | une ligne du relevé cite un n° de facture (`REF`/`FACT`) absent du dossier, montant > 1 000 € | | `paiement_orphelin` | `unmatched_bank_lines` | crédit > 1 000 € sans aucune référence ni facture | **Non bloquantes** (signalées, clôture possible) : | Type | Où | Condition | |------|----|-----------| | `facture_manquante` | `unmatched_bank_lines` | n° de facture cité au relevé mais absent, montant ≤ 1 000 € | | `paiement_orphelin` | `unmatched_bank_lines` | crédit ≤ 1 000 € sans référence | | `releve_non_parseable` | `period_anomalies` | aucune transaction extractible d'un relevé | | `facture_illisible` | `period_anomalies` | facture dont ni le nom ni le contenu ne donnent n° + montant | | `invoice_overdue` | `invoices[].anomalies` | facture non payée, échéance dépassée | > `facture_manquante` ≠ `paiement_orphelin` : le premier = paiement qui **cite** un n° de facture qu'on n'a pas reçu (le client a oublié de transmettre la pièce) ; le second = encaissement sans aucune référence. --- ## Relances (`periods[].relances`) | Ancienneté du retard | Step | |----------------------|------| | ≤ 30 j | 1 | | ≤ 60 j | 2 | | ≤ 90 j | 3 | | > 90 j | escalation | Pour `partial` : mention explicite `"Solde restant dû : X,XX €"`. --- ## Clôture de période Le script crée `clients/<slug>/<AAAA>/<MM>/batch.lock.json` quand : aucune anomalie **bloquante** sur la période, hash des fichiers stable depuis 7 jours, aucun statut `unpaid`/`overdue` non justifié. Les périodes verrouillées ne sont jamais retraitées sauf changement de hash. --- ## Règles critiques - Ne jamais supprimer de données. Ne jamais retraiter un mois verrouillé. - `rapprochement.json` est un **cache reconstructible** : les PDFs classés restent la vérité. Le batch peut tout recalculer depuis zéro. - `clients.json` est lu seulement (maintenu par `organisation-documents`), jamais écrit ici. - Toute l'extraction de texte passe par `scripts/extract.py` — source unique, pas de logique de parsing dupliquée ici. - Le matching peut être inter-période : un paiement de mai peut solder une facture de janvier. La facture reste dans la période de son `issued_date` (status `paid`), la transaction n'apparaît pas en `unmatched_bank_lines` de mai. --- ## Philosophie ``` organisation-documents → classe les pièces, déduit clients.json rapprochement-bancaire → rapproche, valide, reconstruit l'état comptable (scripts/main.py) relances → décision différée ``` Le système doit pouvoir être recalculé intégralement à partir des documents classés.

path: README.md
content: # rapprochement-bancaire

> Claude skill for **French accounting firms** (`cabinets comptables`). Reads the `clients/<slug>/...` tree produced by [`organisation-documents`](https://github.com/developers-trendex/organisation-documents), reconciles each invoice with its bank-statement transaction, validates VAT, and writes a single `rapprochement.json` per client (invoices + unmatched bank lines + anomalies + relances, grouped by monthly period) plus a consolidated report. Deterministic script-driven.

## What it does

For each invocation, the skill runs **one command** :

```bash
python3 scripts/main.py <clients_root>
```

That script:

1. Walks every client folder under `<clients_root>` (skipping `_a-identifier/`, `_incomplet/`, `_non-attribue/`, `_cabinet/`).
2. For each active period (current + previous month, plus any older month without `batch.lock.json`) :
   - Reads invoices from `invoices/in/` and `invoices/out/`.
   - Reads transactions from `bank-statements/` via `scripts/extract.py` (one line per transaction, with the `REF`/`FACT <id>` reference captured when present).
3. Reconciles in two passes (amount compared in absolute value — an `out` invoice is settled by a credit, an `in` by a debit) :
   - **Pass 1** — direct match by invoice reference (`REF` / `FACT` in the bank label). Exact amount (±1 €) → `paid` ; lower amount → `partial` with `amount_paid` and `amount_remaining` ; higher → `paid` with `overpaid_by`.
   - **Pass 2** — fuzzy : `|amount| ±1 €` **and** counterparty-name similarity ≥ 0.6.
   - No match + due date passed → `overdue`.
4. Validates VAT on each invoice : if `|VAT declared − TOTAL HT × rate| / expected > 5 %`, flags a blocking `tva_incorrecte` anomaly on the invoice (exempted-VAT invoices are silently ignored).
5. Detects other anomalies (see below).
6. Generates relances based on lateness (`overdue`, `partial`, `unpaid` past due).
7. Writes one `rapprochement.json` per client + `compta_batch_report_<date>.json` consolidated.

The agent only **reads the JSON file and relays the summary to the accountant** — payments, late invoices, anomalies (highlighting blocking ones), no technical paths.

## Output format

A single `rapprochement.json` per client, grouped by monthly period:

```json
{
  "client": "acme-sa",
  "generated_at": "2026-05-20",
  "periods": [
    {
      "period": "2026-05",
      "locked": false,
      "invoices": [
        { "invoice_id": "F-001", "type": "out", "amount": 1200.00,
          "status": "paid", "bank_matched": true,
          "matched_tx": "VIR ACME REF F-001",
          "issued_date": "2026-05-03", "due_date": "2026-06-02",
          "counterparty": "ACME", "counterparty_name": "ACME SA",
          "anomalies": [] }
      ],
      "unmatched_bank_lines": [
        { "type": "facture_manquante", "invoice_ref": "F-099",
          "label": "VIR REF F-099", "amount": 2500, "date": "2026-05-12",
          "blocking": true }
      ],
      "period_anomalies": [
        { "type": "doublon_paiement", "label": "VIR ACME",
          "amount": 850, "date": "2026-05-09", "blocking": true }
      ],
      "relances": [
        { "invoice_id": "F-007", "step": 2, "days_late": 48,
          "amount": 450, "next_action_date": "2026-05-25", "status": "pending" }
      ]
    }
  ]
}
```

Cross-month matching is supported (a May payment can settle a January invoice). The invoice stays in its issue-month period with `status: paid` ; the transaction is not reported as orphan in May.

## Statuses

| Status     | Meaning                                                                   |
| ---------- | ------------------------------------------------------------------------- |
| `unpaid`   | Not yet due, no payment                                                   |
| `paid`     | Payment confirmed                                                         |
| `partial`  | Partial payment — `amount_paid` + `amount_remaining` kept                 |
| `overdue`  | Due date passed, no payment                                               |

## Anomalies

**Blocking** (period cannot be locked) :

| Type                | Field                          | Condition                                                                                       |
| ------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `doublon_paiement`  | `period_anomalies`             | same date + amount + label                                                                      |
| `tva_incorrecte`    | `invoices[].anomalies`         | calculated/declared VAT gap > 5 %                                                                |
| `facture_manquante` | `unmatched_bank_lines`         | a bank line references an invoice number (`REF`/`FACT`) absent from the folder, amount > 1 000 €|
| `paiement_orphelin` | `unmatched_bank_lines`         | credit > 1 000 € with no reference and no matching invoice                                       |

**Non-blocking** (signaled, period can still be locked) :

| Type                  | Field                  | Condition                                                                                       |
| --------------------- | ---------------------- | ----------------------------------------------------------------------------------------------- |
| `facture_manquante`   | `unmatched_bank_lines` | as above, amount ≤ 1 000 €                                                                      |
| `paiement_orphelin`   | `unmatched_bank_lines` | credit ≤ 1 000 € with no reference                                                              |
| `releve_non_parseable`| `period_anomalies`     | no transaction extractable from a statement PDF                                                  |
| `facture_illisible`   | `period_anomalies`     | neither filename nor PDF content yield a number + amount                                         |
| `invoice_overdue`     | `invoices[].anomalies` | unpaid past due date                                                                            |

> `facture_manquante` ≠ `paiement_orphelin` : the former is a payment that **cites** an invoice number we never received (client forgot to send it) ; the latter is a credit with no reference at all.

## Relances

| Lateness                | Step          |
| ----------------------- | ------------- |
| ≤ 30 days               | 1             |
| ≤ 60 days               | 2             |
| ≤ 90 days               | 3             |
| > 90 days               | `escalation`  |

A `partial` invoice gets a relance with explicit `Solde restant dû : X,XX €`.

## Period closing

A period is locked (`batch.lock.json`) when : no blocking anomaly, file hashes stable for 7 days, no unjustified `unpaid` / `overdue`. Locked periods are never reprocessed unless a file hash changes.

## Prerequisite

The script calls `pdftotext` (package `poppler-utils`). Install it once on the runtime :

```bash
apt install poppler-utils
```

## Output files

```
clients/<slug>/
├── rapprochement.json            ← single file: invoices + unmatched bank lines + anomalies + relances, grouped by period
└── <AAAA>/<MM>/batch.lock.json   ← present when the period is closed
```

Plus a consolidated `compta_batch_report_<YYYY-MM-DD>.json` at the workspace root.

> **Migration note.** Previous versions produced three separate files (`followup.json`, `relances.json`, `anomalies.json`). The current script removes them on each run if they linger.

## Companion skill

[`organisation-documents`](https://github.com/developers-trendex/organisation-documents) — receives, identifies the firm's clients, and classifies documents into the tree this skill consumes.

## Files

| File                              | Purpose                                                                |
| --------------------------------- | ---------------------------------------------------------------------- |
| `SKILL.md`                        | Skill definition (French) — the "when and how to relay" layer          |
| `scripts/main.py`                 | Entrypoint — reconciliation, anomalies, relances, report               |
| `scripts/extract.py`              | Deterministic extractor (only place that touches `pdftotext`)          |
| `references/matching-rules.md`    | Reference — matching cascade, normalization, edge cases                |
| `references/structure-cible.md`   | Path / naming contract (duplicated from `organisation-documents`)      |

## License

Internal — OpenClaw private use.


path: references/csv-schema.md
content: # Référence — Schéma CSV de rapprochement & état persisté

> Référence chargée à la demande par `rapprochement-bancaire`. Format du CSV, enum `anomalie`, schéma de `rapprochement-state.json`.

---

## Localisation du CSV

```
clients/<slug>/<AAAA>/<MM>/rapprochement.csv
```

Un fichier par (client, mois). Réécrit en entier à chaque run du mois concerné (idempotent). Pas d'append.

---

## Encodage et locale

- **Encoding** : UTF-8 avec BOM (`﻿` en tête) — pour Excel français qui sinon mojibake.
- **Séparateur** : `;` (semicolon) — convention française, évite la confusion avec décimales `,`.
- **Décimal** : `,` (virgule).
- **Date** : `AAAA-MM-JJ` (ISO 8601, lisible par tout tableur).
- **Saut de ligne** : `\r\n` (CRLF) — compatibilité Windows / Excel.
- **Quotes** : `"` autour des champs qui contiennent `;`, `"`, ou `\r\n`. Doubler les `"` internes (`""`).

---

## Colonnes du CSV

| #  | Colonne                | Type          | Description                                                                                  |
| -- | ---------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| 1  | `ligne`                | int           | Numéro de ligne séquentiel (1, 2, 3, …) — facilite la référence dans les conversations       |
| 2  | `source`               | enum          | `transaction` (ligne de relevé), `facture_non_payee` (facture sans transaction)              |
| 3  | `compte`               | string        | Banque ou compte (ex : `BNP-courant`, `Qonto-pro`) — vide si `source=facture_non_payee`      |
| 4  | `date`                 | date          | Date transaction OU date d'émission de la facture                                            |
| 5  | `libelle`              | string        | Libellé brut du relevé ou raison sociale + n° facture                                       |
| 6  | `montant`              | decimal       | Signé (`+` crédit, `-` débit)                                                                |
| 7  | `categorie_auto`       | enum          | `match` / `frais_bancaires` / `rh_charges` / `transfert_interne` / `non_categorise`         |
| 8  | `match_facture`        | string        | Numéro de facture matchée — vide si non-match                                                |
| 9  | `match_emetteur`       | string        | Raison sociale de la facture matchée                                                         |
| 10 | `match_mois_origine`   | string        | `AAAA-MM` du mois d'origine de la facture si différent du mois courant — vide sinon          |
| 11 | `confidence`           | enum          | `fort` / `moyen` / `faible` / `aucun`                                                        |
| 12 | `anomalie`             | enum          | cf. table ci-dessous — vide si aucune                                                        |
| 13 | `montant_attendu`      | decimal       | Montant TTC de la facture matchée — vide si non-match                                        |
| 14 | `ecart`                | decimal       | `montant - montant_attendu` — vide si non-match                                              |
| 15 | `candidats`            | string        | Liste de numéros de facture séparés par `|` si `anomalie=match_ambigu`                       |
| 16 | `commentaire`          | string        | Champ libre, éditable par le comptable. Préservé entre runs                                  |

---

## Enum `anomalie`

| Valeur                      | Sens                                                                                                |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| (vide)                      | Aucune anomalie                                                                                     |
| `paiement_orphelin`         | Transaction débit sans facture in correspondante                                                    |
| `encaissement_sans_facture` | Transaction crédit sans facture out correspondante                                                  |
| `facture_non_payee`         | Facture out avec échéance dépassée et pas de paiement détecté                                       |
| `montant_incoherent`        | Match trouvé mais `abs(ecart) > tolerance` (typiquement escompte ou frais)                          |
| `doublon_paiement`          | Plusieurs transactions matchent la même facture avec somme > `montantTTC`                           |
| `paiement_partiel`          | Transaction crédit < `montantTTC` de la facture matchée                                             |
| `match_ambigu`              | Plusieurs candidats avec scores proches (écart < 0.10) — non choisi automatiquement                 |
| `facture_double_classement` | La facture matchée est référencée dans 2 mois différents de `index.json` — inconsistance à corriger |
| `libelle_suspect`           | Mots-clés louches (URL raccourcie, IBAN inconnu) — flag manuel, pas bloquant                        |

---

## Tri des lignes

1. `date` croissante.
2. À date égale, `source=transaction` avant `source=facture_non_payee`.
3. À source égale, `compte` alphabétique puis `ligne` (ordre du relevé).

Permet une lecture chronologique pour le comptable.

---

## Préservation du `commentaire`

La colonne `commentaire` est la **seule** qui survit entre les runs. À chaque réécriture :

1. Lire le CSV existant en mémoire.
2. Indexer les commentaires par `(date, montant, libelle_hash)`.
3. Après le matching, restaurer les commentaires sur les lignes qui correspondent.
4. Les lignes nouvelles ont `commentaire` vide.

Si le comptable édite manuellement d'autres colonnes → écrasées au prochain run. Une bannière en tête du fichier le rappelle :

```
# rapprochement.csv — ACME SA — 2026/04
# Généré par rapprochement-bancaire — réécrit à chaque run.
# SEULE la colonne 'commentaire' est préservée entre runs. Autres modifs perdues.
```

---

## Exemple de CSV

```csv
ligne;source;compte;date;libelle;montant;categorie_auto;match_facture;match_emetteur;match_mois_origine;confidence;anomalie;montant_attendu;ecart;candidats;commentaire
1;transaction;BNP-courant;2026-04-03;"VIR FAVEUR TRENDEX TECH F-2026-03-007";5800,00;match;F-2026-03-007;Trendex Tech;2026-03;fort;;5800,00;0,00;;
2;transaction;BNP-courant;2026-04-05;"PRLV ORANGE PRO F-2026-04-1287";-348,50;match;F-2026-04-1287;Orange Pro;;fort;;-348,50;0,00;;
3;transaction;BNP-courant;2026-04-12;"VIR FAVEUR FOO SAS";1248,00;match;F-2026-03-008;Foo SAS;2026-03;moyen;;1248,00;0,00;;
4;transaction;BNP-courant;2026-04-15;"CB SNCF PARIS-LYON";-84,50;match;;SNCF;;faible;;;;;
5;transaction;BNP-courant;2026-04-22;"PRLV URSSAF";-1870,00;rh_charges;;;;aucun;;;;;
6;transaction;BNP-courant;2026-04-28;"CHQ N° 4521 - FOURN MAT";-720,00;non_categorise;;;;aucun;paiement_orphelin;;;;à investiguer
7;facture_non_payee;;2026-04-15;"F-2026-04-013 - ACME Corp";5800,00;;F-2026-04-013;ACME Corp;;aucun;facture_non_payee;5800,00;;;
```


path: references/matching-rules.md
content: # Référence — Règles de matching transactions ↔ factures

> Référence chargée à la demande par `rapprochement-bancaire`. Détaille la cascade, la fenêtre de date, le scoring de confiance et les cas limites.

---

## Normalisation préalable

### Côté transaction (extraite du relevé)

| Champ                | Normalisation                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `montant`            | Décimal, signé (`+` crédit, `-` débit), arrondi 0,01 €                                                       |
| `date`               | ISO 8601 (`YYYY-MM-DD`)                                                                                       |
| `libelle`            | Trim, espaces multiples → simple espace, lowercase                                                            |
| `libelle_tokens`     | `libelle` splitté par espaces / ponctuation, tokens ≥ 3 caractères, sans stop-words bancaires (`vir`, `prlv`, `cb`, `chq`, `faveur`, `de`, `du`, `de la`) |

### Côté facture (extraite de l'index)

| Champ                | Normalisation                                                                              |
| -------------------- | ------------------------------------------------------------------------------------------ |
| `montantTTC`         | Décimal positif (le sens vient du `categorie`: `achat` = débit, `vente` = crédit)         |
| `dateEmission`       | ISO 8601                                                                                   |
| `emetteur_normalise` | lowercase, accents retirés, suffixes juridiques retirés (`SA`, `SAS`, `SARL`, `EURL`, `Ltd`) |
| `numeroFacture`      | Conservé tel quel + variante numérique pure (`F-2026-04-1287` → aussi `20260412 87`)      |

---

## Cascade de matching (par transaction)

Pour chaque transaction, parcourir les factures du mois (et du mois précédent + suivant — cf. fenêtre date) et calculer un **score** :

```
score = match_montant × poids_montant
      + match_label × poids_label
      + match_date × poids_date
```

Poids par défaut :

- `poids_montant` : 0.5
- `poids_label` : 0.3
- `poids_date` : 0.2

### Score `match_montant`

| Condition                                                           | Score |
| ------------------------------------------------------------------- | ----- |
| `abs(montant - montantTTC) ≤ 0.01`                                  | 1.0   |
| `abs(montant - montantTTC) / montantTTC < 0.02` (escompte typique)  | 0.6   |
| Autre                                                               | 0.0   |

**Sens** : `montant > 0` → ne peut matcher qu'une facture `vente`. `montant < 0` → ne peut matcher qu'une facture `achat`. Pas de cross-match.

### Score `match_label`

Cascade — première règle qui matche :

| Condition                                                                    | Score |
| ---------------------------------------------------------------------------- | ----- |
| `libelle` contient `numeroFacture` (variante numérique pure incluse)         | 1.0   |
| `libelle_tokens` contient un token de `emetteur_normalise` (longueur ≥ 4)    | 0.8   |
| `libelle_tokens` contient un préfixe / suffixe de l'émetteur (Levenshtein ≤ 2) | 0.5   |
| Aucun                                                                        | 0.0   |

### Score `match_date`

| Condition                                            | Score |
| ---------------------------------------------------- | ----- |
| `abs(date - dateEmission) ≤ 3 j`                     | 1.0   |
| `abs(date - dateEmission) ≤ 30 j`                    | 0.7   |
| `abs(date - dateEmission) ≤ 60 j`                    | 0.3   |
| Au-delà                                              | 0.0   |

---

## Classification du score final

| Score agrégé | Confiance | Action                                                            |
| ------------ | --------- | ----------------------------------------------------------------- |
| ≥ 0.85       | `fort`    | Ligne CSV matchée + update `followup.md` (`payée <date>`)         |
| 0.65 – 0.85  | `moyen`   | Ligne CSV matchée + update `followup.md` (`payée ? <date>`, avec `?`) |
| 0.45 – 0.65  | `faible`  | Ligne CSV `match suggéré` mais **pas** d'update followup          |
| < 0.45       | aucun     | Transaction reste `unmatched`                                     |

Si une transaction a **plusieurs candidats ex-aequo** (écart < 0.10 entre top-2) → marquer `match_ambigu` dans le CSV et ne pas écrire dans followup. Lister les N candidats dans la colonne `candidats`.

---

## Fenêtre de date et débordement de mois

Une facture émise le 28/03 peut être payée le 02/04. Donc :

- Charger les factures des **3 mois** : précédent, courant, suivant, autour du mois de la transaction.
- Si match avec une facture d'un autre mois → l'écrire dans le CSV du mois de la **transaction**, mais annoter `facture_mois_origine` = mois de la facture.
- Mettre à jour le `followup.md` du **mois de la facture** (pas celui de la transaction).

---

## Cas limites

### Frais bancaires / commissions

Transaction avec `libelle` contenant un mot-clé d'agios :

- `frais`, `commission`, `cotisation`, `agios`, `interets`, `tenue de compte`

→ Pas de matching tenté. Annoter `categorie_auto: frais_bancaires` dans le CSV. Ne pas alerter (comportement normal).

### Salaires / charges sociales

Mots-clés : `salaire`, `urssaf`, `dsn`, `cipav`, `madelin`, `mutuelle`, `prevoyance`.

→ Pas de matching. Annoter `categorie_auto: rh_charges`. Pas d'alerte par défaut, mais flag possible si montant aberrant vs historique (v0.3+).

### Virements internes

Mots-clés : `virement interne`, `vir entre comptes`, `transfert`.

→ Pas de matching. Annoter `categorie_auto: transfert_interne`. Pas d'alerte.

### Encaissements partiels

Si `montant < montantTTC` ET `montant > 0` ET match label + date OK :

- Annoter `paiement_partiel` dans le CSV.
- Calculer pourcentage : `(montant / montantTTC) × 100`.
- Update `followup.md` : `partielle (X%)`.
- Au prochain run, chercher les transactions complémentaires pour atteindre 100 %.

### Multi-paiements pour une facture

Une facture peut être payée en plusieurs fois (acompte + solde). Si plusieurs transactions matchent la même facture avec sens cohérent et somme = `montantTTC` → OK, listées comme un groupe dans le CSV avec un identifiant `match_group`.

Si somme > `montantTTC` → anomalie `doublon_paiement`.

### Devise étrangère

Hors scope v0.1. Si une facture USD et un encaissement EUR matchent par hasard sur le montant → faux positif. Mitigation v0.2 : taux de change + tolérance plus large.

### Relevés multi-comptes

Un client peut avoir plusieurs comptes (cf. `bank-statements/2026-04_BNP-courant.pdf` et `bank-statements/2026-04_Qonto-pro.pdf`). Toutes les transactions de tous les comptes du mois sont mises en pool commun pour le matching. Le CSV indique la source dans une colonne `compte`.

### Reclassement d'une facture après matching

Si une facture est reclassée par `organisation-documents` (mauvais client, mauvaise catégorie) APRÈS un matching :

- Le `rapprochement-state.json` détecte le changement de chemin et marque le mois `dirty: true`.
- Au prochain run, le matching est recalculé pour ce mois → le CSV est régénéré, `followup.md` mis à jour.
- L'ancien match est purgé de l'état précédent.

---

## Anti-faux-positifs

Sources connues de faux positifs et garde-fous :

| Source                                                          | Garde-fou                                                                                  |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 2 fournisseurs facturent le même montant rond (`100,00 €`)      | Required : label OU date proche, jamais montant seul                                       |
| Abonnement mensuel identique → faux match sur le mauvais mois   | Fenêtre date prioritaire si plusieurs candidats même montant                               |
| Émetteur générique (`Amazon`) qui matche partout                | Liste noire de tokens trop génériques : `paiement`, `achat`, `commande`, `service`, `web` |
| Transaction de l'année précédente reposté (relevé re-généré)    | Si date < `mois - 90 j` → ignorée                                                          |

**Règle absolue** : un match `fort` ne doit jamais écraser une donnée déjà éditée par le comptable dans `followup.md` (statut paiement, prochaine relance). En cas de conflit, garder la valeur humaine et lister le conflit dans le CSV avec `anomalie=libelle_suspect` + commentaire automatique « override manuel respecté ».


path: references/structure-cible.md
content: # Référence — Structure cible & conventions de nommage

> Référence chargée à la demande par `organisation-documents`. Spec exhaustive du classement.
> Source de vérité pour les autres skills qui écrivent dans le même arbre (`relances`, `facturation`, `rapprochement-bancaire`).

---

## Arborescence complète

```
~/.openclaw/workspace/
├── clients/
│   ├── <slug>/                                # un dossier = un client réel (existant ou auto-créé)
│   │   ├── contrats/                          # niveau client — contrats pluriannuels
│   │   │   └── <AAAA-MM-JJ>_<Type>_<Contrepartie>.<ext>
│   │   ├── <AAAA>/
│   │   │   └── <MM>/
│   │   │       ├── bank-statements/           # toujours un dossier (multi-comptes possibles)
│   │   │       │   └── <AAAA-MM>_<BanqueOuCompte>.<ext>
│   │   │       ├── invoices/
│   │   │       │   ├── in/                    # factures reçues (achats)
│   │   │       │   │   └── <AAAA-MM-JJ>_<Émetteur>_<MontantTTC>.<ext>
│   │   │       │   └── out/                   # factures émises (ventes)
│   │   │       │       └── <AAAA-MM-JJ>_<Destinataire>_<MontantTTC>.<ext>
│   │   │       ├── notes-de-frais/
│   │   │       │   └── <AAAA-MM-JJ>_<Collaborateur>_<Émetteur>_<MontantTTC>.<ext>
│   │   │       ├── autres/
│   │   │       │   └── <AAAA-MM-JJ>_<Description>.<ext>
│   │   │       ├── relances.md                # maintenu par skill `relances`
│   │   │       └── followup.md                # maintenu par skill `facturation`
│   │   ├── _archive-suppression/              # RGPD soft-delete (grâce 30 j)
│   │   ├── index.json                         # index par client
│   │   └── audit.log                          # déplacements / renommages
│   └── index-global.json
├── .pending-attribution/                      # parking technique HORS clients/
│   ├── <AAAA-MM-JJ-réception>_<hash-court>.<ext>
│   └── pending-attribution.json
└── clients.json
```

### Pas de slug réservé dans `clients/`

Tout dossier sous `clients/` correspond à un client réel — existant ou auto-créé en `draft-auto-created`. Aucune entité technique (`_cabinet`, `_non-attribue`, etc.) ne pollue cet arbre.

### `.pending-attribution/`

Parking technique pour les documents que la cascade d'identification n'a pas su attribuer (expéditeur générique sans signal exploitable). Vit à la racine du workspace, **hors** `clients/`.

| Élément                          | Rôle                                                                              |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `<date-réception>_<hash>.<ext>`  | Le fichier physique, renommé pour la traçabilité (pas le filename d'origine)      |
| `pending-attribution.json`       | Métadonnées extraites partielles + question à poser à l'utilisateur               |

Une fois la réponse utilisateur reçue (« rattacher à `acme-sa` » ou « créer client `nouveau-client` »), le fichier est **déplacé** vers son chemin cible définitif dans `clients/<slug>/…` et l'entrée correspondante de `pending-attribution.json` est purgée.

---

## Mapping `categorie` → dossier

| `categorie`      | Dossier cible                            | Vocab comptable    | Niveau   |
| ---------------- | ---------------------------------------- | ------------------ | -------- |
| `achat`          | `<AAAA>/<MM>/invoices/in/`               | Achats             | mois     |
| `vente`          | `<AAAA>/<MM>/invoices/out/`              | Ventes             | mois     |
| `bank-statement` | `<AAAA>/<MM>/bank-statements/`           | Relevés bancaires  | mois     |
| `note-de-frais`  | `<AAAA>/<MM>/notes-de-frais/`            | Notes de frais     | mois     |
| `contrat`        | `contrats/` (racine `<slug>/`)           | Contrats           | client   |
| `autre`          | `<AAAA>/<MM>/autres/`                    | Autres             | mois     |

L'enum machine est en kebab-case singulier (`achat`, pas `Achats`). Le vocab capitalisé pluriel est réservé aux **messages affichés au comptable** ; il n'apparaît jamais dans un chemin ni un payload.

---

## Conventions de nommage

### Slug client

- lowercase
- accents retirés (`é` → `e`, `ç` → `c`, etc.)
- espaces → `-`
- caractères non alphanumériques → `-`
- pas de `-` consécutifs, pas de `-` en début/fin
- dérivé du **domaine** de l'expéditeur, pas de l'email (`trendex.tech` → `trendex-tech`)

### Composants de nom de fichier

| Token            | Règle                                                                                                  |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `AAAA-MM-JJ`     | `dateEmission` du document (ISO 8601), pas date de réception                                          |
| `AAAA-MM`        | `dateEmission` tronquée au mois — utilisé pour les relevés bancaires (couvre généralement un mois)    |
| `Émetteur`       | Raison sociale source, 10 premiers caractères significatifs, sans accents ni espaces (`Orange Pro` → `OrangePro`) |
| `Destinataire`   | Idem, pour les factures émises (`out/`) — c'est le client final du client du cabinet                  |
| `Collaborateur`  | Prénom ou prénom+initiale, sans accents (`Thomas`, `ThomasM`)                                         |
| `MontantTTC`     | Sans séparateur de milliers, point décimal, sans symbole (`348.50`, pas `348,50 €`)                   |
| `BanqueOuCompte` | Nom court de banque + suffixe compte optionnel (`BNP-courant`, `Qonto-pro`)                           |
| `Type` (contrat) | `MSA`, `NDA`, `Bail`, `CDI`, `Avenant`, etc. — vocabulaire court figé                                 |
| `Contrepartie`   | Slug ou raison sociale courte de l'autre partie au contrat                                            |
| `Description`    | Slug court décrivant le document quand aucune catégorie ne s'applique (`Statuts`, `KBis`, `Procuration`) |

### Séparateur

`_` entre composants. `-` à l'intérieur d'un composant. Jamais d'espace.

### Extension

Préservée du document source en lowercase (`.pdf`, `.jpg`, `.png`, `.eml`, `.csv`, `.ofx`, `.xml`).

---

## Cas spéciaux

### Multi-comptes bancaires

Un client peut avoir plusieurs comptes (courant, livret pro, devises). `bank-statements/` est toujours un dossier, jamais un fichier unique :

```
clients/acme-sa/2026/04/bank-statements/
├── 2026-04_BNP-courant.pdf
├── 2026-04_BNP-livret-pro.pdf
└── 2026-04_Qonto-usd.pdf
```

### Multi-pages relevé / cas hebdomadaires

Si un relevé couvre plusieurs périodes ou est fragmenté, suffixer avec la plage :

```
clients/acme-sa/2026/04/bank-statements/2026-04-01_2026-04-15_BNP-courant.pdf
```

### Contrats pluriannuels

Les contrats vivent à `<slug>/contrats/`, indexés sur la date de signature, pas la date de réception. Un avenant garde le même `<Type>` préfixé `Avenant-` :

```
clients/acme-sa/contrats/2024-01-15_MSA_TrendexTech.pdf
clients/acme-sa/contrats/2026-03-01_Avenant-MSA_TrendexTech.pdf
```

### Notes de frais — toujours rattachées à un client

Une note de frais appartient toujours à un client (celui dont l'employé doit être remboursé). L'émetteur du document est le commerçant (SNCF, restaurant…) ; le **bénéficiaire** — identifié dans le composant `<Collaborateur>` du nom — est l'employé d'un client.

```
clients/<client>/<AAAA>/<MM>/notes-de-frais/<AAAA-MM-JJ>_<Collaborateur>_<Émetteur>_<MontantTTC>.<ext>
```

Les frais perso de l'utilisateur lui-même **ne sont pas dans le scope** de ce skill. Si un document de ce type est détecté (typiquement : ticket envoyé depuis l'adresse perso de l'utilisateur, sans contexte client) → traité comme `non attribuable` → `.pending-attribution/`.

### Documents non datables

Si `dateEmission` est introuvable ET non déductible (carte de visite, capture d'écran, etc.) → date de réception en fallback, et alerte `extraction_incomplete`. Décision auto-rétrogradée à `needs_review`.

### Reclassement (correction d'un mauvais classement)

Quand le comptable corrige une attribution (mauvais client, mauvaise catégorie), le skill :

1. Déplace le fichier vers le nouveau chemin (sans renommage si le nouveau nom est identique).
2. Met à jour `index.json` des deux clients concernés (ancien + nouveau).
3. Logue l'opération dans `audit.log` du nouveau client avec `motif: reclassement-manuel`.
4. Ne touche jamais à `_archive-suppression/` (la suppression est un autre flux).

---

## `relances.md` — schéma

> Maintenu par le skill `relances`. Lu par `organisation-documents` en cas de classement d'une réponse à relance.

Un fichier par mois et par client : `clients/<slug>/<AAAA>/<MM>/relances.md`.

**Contenu** — un fichier = deux tableaux markdown :

```markdown
# Relances — <Raison sociale client> — <AAAA>/<MM>

> Maintenu par le skill `relances`. Toute modification manuelle est tolérée mais peut être écrasée à la prochaine exécution.

## À envoyer

| Date prévue | Facture       | Destinataire | Montant TTC | Échéance dépassée | Canal | Type   | Statut    |
| ----------- | ------------- | ------------ | ----------- | ----------------- | ----- | ------ | --------- |
| 2026-05-15  | F-2026-04-012 | TrendexTech  | 2 400,00 €  | 15 j              | email | R1     | planifiée |
| 2026-05-30  | F-2026-04-012 | TrendexTech  | 2 400,00 €  | 30 j              | email | R2     | planifiée |

## Effectuées

| Date envoi | Facture       | Destinataire | Montant TTC | Canal   | Type | Réponse client | Suite             |
| ---------- | ------------- | ------------ | ----------- | ------- | ---- | -------------- | ----------------- |
| 2026-04-15 | F-2026-03-008 | ACME Corp    | 1 248,00 €  | email   | R1   | promesse 15/05 | R2 planifié 30/05 |
| 2026-04-30 | F-2026-03-005 | Foo SAS      | 850,00 €    | courrier | R3   | aucune         | escalade huissier |
```

**Colonnes obligatoires** — toute autre colonne est facultative.

**Règles d'écriture** :

- Le skill `relances` est seul autorisé à modifier ces fichiers. `organisation-documents` les lit uniquement.
- L'ordre des lignes dans `À envoyer` : chronologique croissant (la prochaine en haut).
- L'ordre des lignes dans `Effectuées` : chronologique décroissant (la plus récente en haut).
- Quand une relance planifiée est envoyée, le skill `relances` la migre de `À envoyer` vers `Effectuées` dans le fichier du mois courant (pas du mois de la facture).

---

## `followup.md` — schéma

> Maintenu par le skill `facturation`. Lu par `organisation-documents` en cas de classement d'une facture entrante (paiement) ou sortante (émise).

Un fichier par mois et par client : `clients/<slug>/<AAAA>/<MM>/followup.md`.

```markdown
# Followup factures — <Raison sociale client> — <AAAA>/<MM>

> Maintenu par le skill `facturation`. Vue mois en cours.

## À émettre

| Date prévue | Destinataire | Description        | Montant TTC prévu | Statut         |
| ----------- | ------------ | ------------------ | ----------------- | -------------- |
| 2026-04-01  | TrendexTech  | Forfait avril      | 2 400,00 €        | à préparer     |
| 2026-04-15  | ACME Corp    | Prestation projet X | 5 800,00 €        | brouillon prêt |

## Émises ce mois

| Date émission | N° facture    | Destinataire | Montant TTC | Échéance   | Statut paiement | Prochaine relance |
| ------------- | ------------- | ------------ | ----------- | ---------- | --------------- | ----------------- |
| 2026-04-01    | F-2026-04-012 | TrendexTech  | 2 400,00 €  | 2026-04-30 | non payée       | R1 le 2026-05-15  |
| 2026-04-15    | F-2026-04-013 | ACME Corp    | 5 800,00 €  | 2026-05-15 | partielle (50%) | suivi 2026-05-15  |
| 2026-04-22    | F-2026-04-014 | Foo SAS      | 720,00 €    | 2026-05-22 | payée 2026-04-28 | —                |
```

**Statut paiement** — enum : `non payée` | `partielle` | `payée` | `litige` | `irrécouvrable`.

**Mise à jour automatique** — quand `organisation-documents` classe un relevé bancaire dans `bank-statements/`, il devrait théoriquement déclencher un rapprochement (`rapprochement-bancaire` skill) qui mettra à jour `followup.md` avec les paiements détectés. Hors scope de ce skill, mais le contrat de chemin est figé ici pour permettre l'intégration.

---

## Pourquoi cette structure

| Décision                                    | Alternative rejetée                          | Raison                                                                                  |
| ------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------- |
| `<AAAA>/<MM>/` (deux niveaux)               | `<AAAA-MM>/` (un niveau)                     | Conservation 10 ans = 120 dossiers à plat ingérables sur Drive/Finder                   |
| `invoices/in` + `invoices/out` séparés      | `invoices/` unique                           | TVA et PCG différents — un compta ne mélange jamais                                     |
| `contrats/` à la racine client              | `contrats/` au mois                          | Contrats pluriannuels — les enterrer dans un mois rend la lecture impossible            |
| `bank-statements/` toujours un dossier      | Fichier `bank-statement.pdf` direct          | Multi-comptes très courant, dossier dès le départ évite la migration                    |
| `relances.md` + `followup.md` au mois       | Fichier rolling à la racine client           | Le comptable raisonne par mois (clôtures) — état mensuel plus utile que log global      |
| Slug dérivé du domaine                      | Slug dérivé de l'email                       | 1 client = N employés = N emails — mais 1 dossier client                                |
| Aucun slug réservé dans `clients/`          | `_cabinet` + `_non-attribue` pseudo-clients   | Un slug = un client réel. Le reste vit dans `.pending-attribution/` hors `clients/`.   |
| Auto-création par défaut                    | Toujours demander à l'utilisateur            | Trop interruptif au quotidien. Confirmation opt-in via `clientCreation: "confirm"`.    |
| Enum `categorie` kebab-case singulier       | Capitalisé pluriel                           | Aligné sur les noms de dossiers (`invoices/in` etc.) — pas de mismatch machine/affiché |


path: scripts/extract.py
content: #!/usr/bin/env python3
"""
Extraction DÉTERMINISTE des champs d'un document comptable.

Usage :
  python3 extract.py <fichier.pdf>

Sortie : un objet JSON sur stdout. Champs introuvables → null.
L'appelant ne devine JAMAIS : si un champ obligatoire est null,
il classe en needs_review (et n'invente ni montant ni numéro).

  invoice :
    {"kind":"invoice","invoice_id","issue_date","due_date",
     "total_ht","tva_rate","tva_amount","total_ttc",
     "emitter","recipient","siren_found":[...]}

  bank-statement :
    {"kind":"bank-statement","holder","iban","bank",
     "period_start","period_end","opening_balance","closing_balance",
     "operations":[{"date","label","amount","invoice_ref"}]}

  autre : {"kind":"other"}
"""

import json
import re
import subprocess
import sys

BANKS = ["BNP PARIBAS", "CRÉDIT AGRICOLE", "CREDIT AGRICOLE", "SOCIÉTÉ GÉNÉRALE",
         "SOCIETE GENERALE", "LA BANQUE POSTALE", "CIC", "LCL", "HSBC",
         "CAISSE D'ÉPARGNE", "CAISSE D'EPARGNE", "BANQUE POPULAIRE"]
BANK_TOKENS = ("BNP", "AGRICOLE", "GÉNÉRALE", "GENERALE", "POSTALE", "CIC",
               "LCL", "HSBC", "ÉPARGNE", "EPARGNE", "POPULAIRE")

INVOICE_ID_RE = re.compile(
    r"\b([A-Z]{1,4}\d{0,3}-\d{2,4}-\d{2,8}|[A-Z]{2,4}\d{4,12}|\d{4}-\d{2,8})\b")


# ── Helpers ───────────────────────────────────────────────────────────────

def pdftext(path):
    try:
        r = subprocess.run(["pdftotext", "-layout", str(path), "-"],
                           capture_output=True, text=True, errors="ignore", timeout=30)
        return r.stdout
    except Exception:
        return ""


def parse_money(s):
    s = s.replace("\xa0", " ")
    m = re.search(r"\d[\d \.]*,\d{2}|\d[\d \.]+|\d+", s)
    if not m:
        return None
    v = m.group(0).replace(" ", "")
    if "," in v:
        v = v.replace(".", "").replace(",", ".")
    try:
        return round(float(v), 2)
    except ValueError:
        return None


def amount_after(label_re, text):
    """Premier montant trouvé sur une ligne contenant le label."""
    for line in text.splitlines():
        if re.search(label_re, line, re.IGNORECASE):
            chunks = re.findall(r"[\d\xa0 ]+,\d{2}", line.replace("\xa0", " "))
            if chunks:
                return parse_money(chunks[-1])
    return None


def iso_date(dd_mm_yyyy):
    m = re.match(r"(\d{2})/(\d{2})/(\d{4})", dd_mm_yyyy)
    return f"{m.group(3)}-{m.group(2)}-{m.group(1)}" if m else None


def date_after(label_re, text):
    for line in text.splitlines():
        if re.search(label_re, line, re.IGNORECASE):
            m = re.search(r"\d{2}/\d{2}/\d{4}", line)
            if m:
                return iso_date(m.group(0))
    return None


def first_date(text):
    m = re.search(r"\b\d{2}/\d{2}/\d{4}\b", text)
    return iso_date(m.group(0)) if m else None


def extract_invoice_ref(label):
    m = re.search(r"\b(?:REF|FACT|FACTURE)\s+([A-Za-z0-9][\w\-/]+)", label, re.IGNORECASE)
    return m.group(1) if m else None


def siren_list(text):
    return re.findall(r"\b\d{3}\s?\d{3}\s?\d{3}\b", text)


# ── Classification ────────────────────────────────────────────────────────

def looks_like_statement(t):
    up = t.upper()
    if "RELEVÉ DE COMPTE" in up or "RELEVE DE COMPTE" in up or "ACCOUNT STATEMENT" in up:
        return True
    if "RELEVÉ BANCAIRE" in up or "EXTRAIT DE COMPTE" in up:
        return True
    if "SOLDE D'OUVERTURE" in up or "SOLDE D'OUVERTURE" in up or "SOLDE DE CLÔTURE" in up or "SOLDE DE CLOTURE" in up:
        return True
    if any(b in up for b in BANKS) and ("RELEVÉ" in up or "RELEVE" in up):
        return True
    return False


def has_invoice_guard(t):
    up = t.upper()
    return ((("FACTURE" in up) or ("INVOICE" in up) or ("BON DE FACTURATION" in up))
            and (("SIRET" in up) or ("TVA" in up)))


def looks_like_invoice(t):
    up = t.upper()
    score = 0
    if "FACTURE" in up or "INVOICE" in up or "BON DE FACTURATION" in up or "BILL" in up:
        score += 1
    if "SIRET" in up or re.search(r"N°?\s*TVA", up) or "SIREN" in up:
        score += 1
    if "FACTURÉ À" in up or "FACTURE A" in up or "DESTINATAIRE" in up or "BILL TO" in up or "\nCLIENT" in up:
        score += 1
    if "TOTAL TTC" in up or "NET À PAYER" in up or "TOTAL HT" in up or "NET A PAYER" in up:
        score += 1
    return score >= 2


# ── Extraction facture ────────────────────────────────────────────────────

def company_before_siret(text):
    """Première ligne « réelle » = nom du vendeur (avant le 1er SIRET)."""
    seen_lines = text.splitlines()
    for l in seen_lines[:15]:
        chunk = re.split(r"\s{2,}", l.strip())[0].strip().strip(":")
        if not chunk or len(chunk) < 3:
            continue
        if re.match(r"(FACTURE|INVOICE|BON DE FACTURATION)\b", chunk, re.I):
            continue
        if re.search(r"^\d", chunk):
            continue
        if not re.search(r"[A-Za-zÀ-ÿ]{3,}", chunk):
            continue
        return chunk
    return None


def recipient_block(text):
    lines = text.splitlines()
    label_re = re.compile(r"FACTUR[ÉE]\s*[ÀA]\b|DESTINATAIRE\b|BILL\s*TO\b|^\s*CLIENT\b", re.I)
    for i, l in enumerate(lines):
        if not label_re.search(l):
            continue
        after = label_re.split(l)[-1].strip().lstrip(":").strip()
        candidates = ([after] if after else []) + [lines[j].strip() for j in range(i + 1, min(i + 5, len(lines)))]
        for c in candidates:
            parts = [p.strip() for p in re.split(r"\s{2,}", c) if p.strip()]
            for p in reversed(parts):  # le destinataire est dans la colonne de droite
                if len(p) < 3:
                    continue
                if re.match(r"(ÉCHÉANCE|ECHEANCE|SIRET|N°|TVA|RÈGLEMENT|REGLEMENT)\b", p, re.I):
                    continue
                if re.match(r"^[\d\s/.,€-]+$", p):
                    continue
                if re.search(r"[A-Za-zÀ-ÿ]{3,}", p):
                    return p
    return None


def extract_invoice(text):
    inv_id = None
    # Ordre des patterns : du plus strict (forme structurée) au plus large.
    # Critique : « FACTURE … N° X » avec N° optionnel matchait à tort le N de
    # « Numérix » (sur la ligne d'après FACTURE), d'où la capture « umérix »
    # observée en prod. → On exige `N°` strict et un id structuré en tête.
    for pat in (
        # 1) Forme structurée standard : "N° F1-2026-0001", "N° R3-2026-0003"
        r"\bN\s*[°O]\s*([A-Z]{1,4}\d{0,3}[\-/]\d{2,4}[\-/]\d{2,8})\b",
        # 2) "FACTURE … N° X" avec X structuré (filet pour layouts exotiques)
        r"\bFACTURE\b[\s\S]{0,200}?\bN\s*[°O]\s*([A-Z]{1,4}\d{0,3}[\-/]\d{2,4}[\-/]\d{2,8})\b",
        # 3) "Facture : X" / "Facture # X" — séparateur explicite
        r"\bFACTURE\s*[:#]\s*([A-Za-z0-9][\w\-/]{2,})",
        # 4) "Réf. facture : X"
        r"\bR[ÉE]F\.?\s*FACTURE\s*[:\-]?\s*([A-Za-z0-9][\w\-/]{2,})",
        # 5) Filet de sécurité : "N° X" forme compacte sans tirets
        r"\bN\s*[°O]\s*([A-Z]{2,4}\d{4,12})\b",
    ):
        m = re.search(pat, text, re.I)
        if m:
            inv_id = m.group(1).strip().strip(".:,")
            break
    if not inv_id:
        m = INVOICE_ID_RE.search(text)
        if m:
            inv_id = m.group(1)
    if inv_id and (inv_id.upper() in ("TVA", "SIRET", "HT", "TTC", "FR") or len(inv_id) < 3
                   or re.match(r"^\d{1,3}$", inv_id)):
        inv_id = None

    total_ttc = amount_after(r"NET\s*[ÀA]\s*PAYER|TOTAL\s*TTC|MONTANT\s*TTC", text)
    total_ht = amount_after(r"TOTAL\s*HT|SOUS-?TOTAL\s*HT|MONTANT\s*HT|TOTAL\s*H\.?T\.?", text)
    tva_amount = amount_after(r"(?<!N°\s)(?<!N° )\bTVA\b(?!\s*INTRA)", text)

    tva_rate = None
    mr = re.search(r"TVA\s*\(?\s*([\d,\.]+)\s*%", text, re.I)
    if mr:
        try:
            tva_rate = round(float(mr.group(1).replace(",", ".")) / 100, 4)
        except ValueError:
            pass
    up = text.upper()
    if tva_rate is None and ("TVA NON APPLICABLE" in up or "EXONÉR" in up or "EXONER" in up
                             or "ART. 261" in up or "ART 261" in up):
        tva_rate = 0.0
        if tva_amount is None:
            tva_amount = 0.0

    return {
        "kind": "invoice",
        "invoice_id": inv_id,
        "issue_date": date_after(r"\bDATE\b\s*:", text) or date_after(r"\bDATE\b", text) or first_date(text),
        "due_date": date_after(r"ÉCHÉANCE|ECHEANCE|[ÀA]\s*RÉGLER\s*AVANT|[ÀA]\s*REGLER\s*AVANT", text),
        "total_ht": total_ht,
        "tva_rate": tva_rate,
        "tva_amount": tva_amount,
        "total_ttc": total_ttc,
        "emitter": company_before_siret(text),
        "recipient": recipient_block(text),
        "siren_found": siren_list(text),
    }


# ── Extraction relevé bancaire ────────────────────────────────────────────

def extract_statement(text):
    up = text.upper()
    bank = next((b for b in BANKS if b in up), None)

    m = re.search(r"IBAN\s*:?\s*([A-Z]{2}[A-Z0-9 ]{10,32})", text)
    iban = m.group(1).strip() if m else None

    period_start = period_end = None
    mp = re.search(r"P[ÉE]RIODE\s*:?\s*(\d{2}/\d{2}/\d{4})\s*[—\-–à]+\s*(\d{2}/\d{2}/\d{4})", text, re.I)
    if mp:
        period_start, period_end = iso_date(mp.group(1)), iso_date(mp.group(2))

    opening = amount_after(r"SOLDE\s*D[''’]?\s*OUVERTURE|ANCIEN\s*SOLDE", text)
    closing = amount_after(r"SOLDE\s*DE\s*CL[ÔO]TURE|NOUVEAU\s*SOLDE|SOLDE\s*FINAL", text)

    HEADER_KW = re.compile(
        r"^(RELEV|EXTRAIT|COMPTE|IBAN|BIC|PÉRIODE|PERIODE|SOLDE|ANCIEN\s|NOUVEAU\s|"
        r"DATE|VALEUR|LIBELL|MONTANT|OP[ÉE]RATION|D[ÉE]BIT|CR[ÉE]DIT|TITULAIRE|ÉDIT|EDIT|PAGE)\b")
    holder = None
    fallback = None
    for l in text.splitlines()[:16]:
        for p in [x.strip() for x in re.split(r"\s{2,}", l) if x.strip()]:
            if len(p) < 4 or not re.search(r"[A-Za-zÀ-ÿ]{3,}", p):
                continue
            pu = p.upper()
            if HEADER_KW.match(pu):
                continue
            if pu in (b.upper() for b in BANKS) or any(tok in pu for tok in BANK_TOKENS):
                continue
            # un nom d'entreprise contient en général des minuscules ;
            # les en-têtes/banques sont en capitales
            if re.search(r"[a-zà-ÿ]", p):
                holder = p
                break
            if fallback is None:
                fallback = p
        if holder:
            break
    holder = holder or fallback

    operations = []
    for line in text.splitlines():
        m = re.match(
            r"\s*(\d{2}/\d{2}/\d{4})\s{2,}(.+?)\s{2,}([\d\xa0 ]+,\d{2})\s*€?\s*(CR|DB|CRÉDIT|DÉBIT)?\s*$",
            line.replace("\xa0", " "), re.I)
        if not m:
            continue
        amt = parse_money(m.group(3))
        if amt is None:
            continue
        flag = (m.group(4) or "").upper()
        if flag.startswith("DB") or flag.startswith("DÉB") or flag.startswith("DEB"):
            amt = -amt
        label = m.group(2).strip()
        operations.append({
            "date": iso_date(m.group(1)),
            "label": label,
            "amount": amt,
            "invoice_ref": extract_invoice_ref(label),
        })

    return {
        "kind": "bank-statement",
        "holder": holder,
        "iban": iban,
        "bank": bank,
        "period_start": period_start,
        "period_end": period_end,
        "opening_balance": opening,
        "closing_balance": closing,
        "operations": operations,
    }


# ── Main ──────────────────────────────────────────────────────────────────

def classify_and_extract(text):
    if not text.strip():
        return {"kind": "other", "error": "no text extracted"}
    if looks_like_statement(text) and not has_invoice_guard(text):
        return extract_statement(text)
    if looks_like_invoice(text):
        return extract_invoice(text)
    if looks_like_statement(text):
        return extract_statement(text)
    return {"kind": "other"}


def main():
    if len(sys.argv) < 2:
        print(json.dumps({"kind": "other", "error": "usage: extract.py <pdf>"}))
        return
    out = classify_and_extract(pdftext(sys.argv[1]))
    print(json.dumps(out, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/main.py
content: #!/usr/bin/env python3
"""
Moteur du skill `rapprochement-bancaire`.

Travaille sur l'arborescence `clients/<slug>/...` produite par `organisation-documents` (scripts/main.py).
Toute l'extraction de texte est déléguée à `extract.py` (source unique).

Pour chaque période active :
  - lit les factures (nom de fichier conventionnel, sinon contenu PDF via extract.py)
  - lit les relevés bancaires (transactions ligne par ligne via extract.py)
  - rapproche chaque facture avec son paiement (Pass 1 = réf facture, Pass 2 = fuzzy)
  - valide la TVA de chaque facture
  - détecte les anomalies (doublon, facture manquante, orphelin, TVA, retard)
  - écrit UN SEUL `rapprochement.json` par client (factures + lignes bancaires non
    rapprochées + anomalies + relances, groupées par période mensuelle)
  - produit un rapport consolidé inter-clients

Usage :
  python3 main.py [<racine_clients>]
  <racine_clients> par défaut : ./clients
"""

import json
import re
import sys
from datetime import datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import extract  # noqa: E402

ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("clients")
TODAY = datetime.utcnow().date()
ACTIVE_MONTHS = {
    TODAY.strftime("%Y-%m"),
    (TODAY.replace(day=1) - timedelta(days=1)).strftime("%Y-%m"),
}

# Fichiers écrits par les anciennes versions du script. On les supprime à chaque
# run pour qu'aucun consommateur ne lise un état périmé après la migration de format.
LEGACY_FILES = ("followup.json", "relances.json", "anomalies.json")


# ── IO ────────────────────────────────────────────────────────────────────

def load_json(path, default):
    try:
        return json.loads(Path(path).read_text(encoding="utf-8"))
    except Exception:
        return default


def save_json(path, data):
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def pdf_fields(pdf_path):
    return extract.classify_and_extract(extract.pdftext(pdf_path))


# ── Helpers ───────────────────────────────────────────────────────────────

def parse_invoice_filename(filename):
    """
    Format conventionnel (écrit par organisation-documents/scripts/main.py) :
      AAAA-MM-JJ_N°Facture_Contrepartie_MontantTTC.pdf
    Tolère aussi un ancien format 3 parties (sans n° de facture).
    """
    m = re.match(r"(\d{4}-\d{2}-\d{2})_([^_]+)_([^_]+)_(\d+(?:\.\d+)?)\.pdf$", filename)
    if m:
        issued = m.group(1)
        return {
            "invoice_id": m.group(2),
            "issued_date": issued,
            "due_date": (datetime.strptime(issued, "%Y-%m-%d") + timedelta(days=30)).strftime("%Y-%m-%d"),
            "counterparty": m.group(3),
            "amount": float(m.group(4)),
        }
    m = re.match(r"(\d{4}-\d{2}-\d{2})_([^_]+)_(\d+(?:[._]\d+)?)\.pdf$", filename)
    if m:
        issued = m.group(1)
        amount_raw = m.group(3).replace("_", ".")
        return {
            "invoice_id": f"{issued}_{m.group(2)}_{amount_raw}",
            "issued_date": issued,
            "due_date": (datetime.strptime(issued, "%Y-%m-%d") + timedelta(days=30)).strftime("%Y-%m-%d"),
            "counterparty": m.group(2),
            "amount": float(amount_raw),
        }
    return None


def similarity(a, b):
    a, b = (a or "").lower().strip(), (b or "").lower().strip()
    if not a or not b:
        return 0.0
    if a == b or a in b or b in a:
        return 1.0
    a_clean = re.sub(r"[^a-z0-9]+", " ", a).strip()
    b_clean = re.sub(r"[^a-z0-9]+", " ", b).strip()
    if a_clean and b_clean and (a_clean in b_clean or b_clean in a_clean):
        return 0.9
    base = SequenceMatcher(None, a, b).ratio()
    wa, wb = set(re.findall(r"[a-z0-9]+", a)), set(re.findall(r"[a-z0-9]+", b))
    jacc = len(wa & wb) / len(wa | wb) if (wa and wb) else 0.0
    return max(base, jacc)


def is_locked(month_dir):
    return (month_dir / "batch.lock.json").exists()


def should_process(month_dir):
    key = f"{month_dir.parent.name}-{month_dir.name}"
    return key in ACTIVE_MONTHS or not is_locked(month_dir)


def check_tva(fields):
    ht = fields.get("total_ht")
    rate = fields.get("tva_rate")
    declared = fields.get("tva_amount")
    if ht is None or rate is None or declared is None:
        return None
    if rate == 0:
        return None
    expected = round(ht * rate, 2)
    if expected == 0:
        return None
    disc = abs(declared - expected) / expected
    if disc > 0.05:
        return {
            "type": "tva_incorrecte",
            "total_ht": ht, "tva_declared": declared, "tva_expected": expected,
            "tva_rate_pct": round(rate * 100, 1), "discrepancy_pct": round(disc * 100, 1),
            "blocking": True,
        }
    return None


# ── Traitement d'un client ────────────────────────────────────────────────

def process_client(client_dir):
    """
    Renvoie (rapprochement_dict, totals_dict).

    rapprochement_dict = {
      "client": <slug>,
      "generated_at": "YYYY-MM-DD",
      "periods": [
        { "period": "YYYY-MM", "locked": bool,
          "invoices": [...],                # chaque facture porte son champ "anomalies"
          "unmatched_bank_lines": [...],    # facture_manquante / paiement_orphelin
          "period_anomalies": [...],        # doublon_paiement / releve_non_parseable / facture_illisible
          "relances": [...] },              # dérivé des factures overdue/partial
        ...
      ]
    }
    """
    periods = {}

    def ensure_period(key, locked):
        if key not in periods:
            periods[key] = {
                "period": key,
                "locked": locked,
                "bank_statements_count": 0,
                "bank_transactions_count": 0,
                "invoices": [],
                "unmatched_bank_lines": [],
                "period_anomalies": [],
                "relances": [],
                "_transactions": [],  # interne, retiré avant écriture
            }
        return periods[key]

    # ── Collecte par période ──────────────────────────────────────────────
    for year_dir in sorted(client_dir.iterdir()):
        if not (year_dir.is_dir() and re.match(r"\d{4}$", year_dir.name)):
            continue
        for month_dir in sorted(year_dir.iterdir()):
            if not month_dir.is_dir() or not should_process(month_dir):
                continue

            pkey = f"{year_dir.name}-{month_dir.name}"
            period = ensure_period(pkey, is_locked(month_dir))

            # Factures
            for f in month_dir.rglob("invoices/**/*.pdf"):
                direction = "out" if "/out/" in str(f).replace("\\", "/") else "in"
                parsed = parse_invoice_filename(f.name)
                fields = pdf_fields(f)

                if parsed:
                    inv_id = parsed["invoice_id"]
                    amount = parsed["amount"]
                    issued = parsed["issued_date"]
                    due = parsed["due_date"]
                    cp = parsed["counterparty"]
                elif fields.get("kind") == "invoice" and fields.get("invoice_id") and fields.get("total_ttc") is not None:
                    inv_id = fields["invoice_id"]
                    amount = fields["total_ttc"]
                    issued = fields.get("issue_date") or ""
                    due = ((datetime.strptime(issued, "%Y-%m-%d") + timedelta(days=30)).strftime("%Y-%m-%d")
                           if issued else (TODAY + timedelta(days=30)).strftime("%Y-%m-%d"))
                    cp = (fields.get("recipient") if direction == "out" else fields.get("emitter")) or client_dir.name
                else:
                    period["period_anomalies"].append({
                        "type": "facture_illisible", "source_file": str(f), "blocking": False,
                    })
                    continue

                cp_name = (fields.get("recipient") if direction == "out" else fields.get("emitter")) or cp
                # Si extract.py a su lire le PDF, on récupère le numéro réel — ça permet
                # de rattraper un nommage de fichier corrompu en amont (invoice_id incorrect).
                real_inv_id = fields.get("invoice_id") if fields.get("kind") == "invoice" else None
                if real_inv_id and real_inv_id != inv_id:
                    inv_id = real_inv_id

                inv = {
                    "invoice_id": inv_id, "type": direction, "amount": amount,
                    "status": "unpaid", "issued_date": issued, "due_date": due,
                    "counterparty": cp, "counterparty_name": cp_name,
                    "bank_matched": False, "source_file": str(f),
                    "anomalies": [],
                }
                period["invoices"].append(inv)

                if fields.get("kind") == "invoice":
                    tva_anom = check_tva(fields)
                    if tva_anom:
                        tva_anom["invoice_id"] = inv_id
                        inv["anomalies"].append(tva_anom)

            # Relevés bancaires
            for st in month_dir.rglob("bank-statements/*"):
                if not st.is_file():
                    continue
                period["bank_statements_count"] += 1
                f = pdf_fields(st)
                ops = f.get("operations", []) if f.get("kind") == "bank-statement" else []
                for op in ops:
                    period["_transactions"].append({
                        "date": op.get("date"),
                        "label": (op.get("label") or "").lower(),
                        "raw_label": op.get("label") or "",
                        "amount": op.get("amount"),
                        "invoice_ref": op.get("invoice_ref"),
                        "_period": pkey,
                    })
                period["bank_transactions_count"] += len(ops)
                if not ops:
                    period["period_anomalies"].append({
                        "type": "releve_non_parseable", "source_file": str(st), "blocking": False,
                    })

    # ── Vues globales pour le matching (paiement de mois M peut solder facture M-1) ──
    all_invoices = [inv for p in periods.values() for inv in p["invoices"]]
    all_transactions = [tx for p in periods.values() for tx in p["_transactions"]]

    # Pass 1 — réf facture présente dans le libellé bancaire (REF/FACT <id>)
    for inv in all_invoices:
        for tx in all_transactions:
            if tx.get("invoice_ref") and tx["invoice_ref"] == inv["invoice_id"]:
                paid = round(abs(tx["amount"]), 2)
                if abs(paid - inv["amount"]) <= 1.0:
                    inv["status"] = "paid"
                elif paid < inv["amount"]:
                    inv["status"] = "partial"
                    inv["amount_paid"] = paid
                    inv["amount_remaining"] = round(inv["amount"] - paid, 2)
                else:
                    inv["status"] = "paid"
                    inv["amount_paid"] = paid
                    inv["overpaid_by"] = round(paid - inv["amount"], 2)
                inv["bank_matched"] = True
                inv["matched_tx"] = tx["raw_label"]
                break

    # Pass 2 — fuzzy : |montant| ±1€ + similarité libellé / contrepartie ≥ 0.6
    for inv in all_invoices:
        if inv["bank_matched"]:
            continue
        for tx in all_transactions:
            if abs(abs(tx["amount"]) - inv["amount"]) > 1.0:
                continue
            if similarity(tx["label"], inv.get("counterparty_name", "")) >= 0.6:
                inv["status"] = "paid"
                inv["bank_matched"] = True
                inv["matched_tx"] = tx["raw_label"]
                break

    # ── Anomalies ─────────────────────────────────────────────────────────
    # Doublons de paiement (même date + montant + libellé) — rattachés à la
    # période où la transaction a été observée.
    seen = {}
    for tx in all_transactions:
        key = (tx["date"], round(abs(tx["amount"]), 2), tx["label"][:30])
        if key in seen:
            periods[tx["_period"]]["period_anomalies"].append({
                "type": "doublon_paiement", "label": tx["raw_label"],
                "amount": tx["amount"], "date": tx["date"], "blocking": True,
            })
        else:
            seen[key] = True

    # Lignes bancaires non rapprochées → unmatched_bank_lines de leur période
    known = {inv["invoice_id"] for inv in all_invoices}
    matched_label_set = {inv.get("matched_tx") for inv in all_invoices if inv.get("matched_tx")}

    for tx in all_transactions:
        if tx["raw_label"] in matched_label_set:
            continue
        ref = tx.get("invoice_ref")
        if ref and ref not in known:
            # Le relevé cite un n° de facture absent du dossier
            periods[tx["_period"]]["unmatched_bank_lines"].append({
                "type": "facture_manquante", "invoice_ref": ref, "label": tx["raw_label"],
                "amount": tx["amount"], "date": tx["date"],
                "blocking": abs(tx["amount"] or 0) > 1000,
            })
            continue
        if not ref and tx["amount"] is not None and tx["amount"] > 0:
            # Encaissement sans aucune référence ni facture
            periods[tx["_period"]]["unmatched_bank_lines"].append({
                "type": "paiement_orphelin", "label": tx["raw_label"],
                "amount": tx["amount"], "date": tx["date"],
                "blocking": tx["amount"] > 1000,
            })

    # ── Statuts overdue + relances (par période) ──────────────────────────
    for p in periods.values():
        for inv in p["invoices"]:
            try:
                due = datetime.strptime(inv["due_date"], "%Y-%m-%d").date()
            except (ValueError, KeyError):
                due = TODAY + timedelta(days=30)

            if inv["status"] == "unpaid" and due < TODAY:
                inv["status"] = "overdue"
                inv["anomalies"].append({
                    "type": "invoice_overdue", "invoice_id": inv["invoice_id"],
                    "days_late": (TODAY - due).days, "amount": inv["amount"], "blocking": False,
                })

            if inv["status"] in ("overdue", "partial"):
                days_late = max((TODAY - due).days, 0)
                step = 1 if days_late <= 30 else 2 if days_late <= 60 else 3 if days_late <= 90 else "escalation"
                r = {"invoice_id": inv["invoice_id"], "counterparty": inv.get("counterparty", ""),
                     "amount": inv["amount"], "due_date": inv.get("due_date"), "days_late": days_late,
                     "step": step, "status": "pending",
                     "next_action_date": (TODAY + timedelta(days=5)).strftime("%Y-%m-%d")}
                if inv["status"] == "partial":
                    r["amount_remaining"] = inv.get("amount_remaining", inv["amount"])
                    r["note"] = f"Solde restant dû : {r['amount_remaining']:.2f} €"
                p["relances"].append(r)

    # ── Sérialisation ─────────────────────────────────────────────────────
    periods_list = []
    for pkey in sorted(periods.keys(), reverse=True):  # plus récent en tête
        p = periods[pkey]
        p.pop("_transactions", None)
        periods_list.append(p)

    result = {
        "client": client_dir.name,
        "generated_at": TODAY.isoformat(),
        "periods": periods_list,
    }

    # Comptage agrégé
    total_inv = len(all_invoices)
    paid = sum(1 for i in all_invoices if i["status"] == "paid")
    matched = sum(1 for i in all_invoices if i.get("bank_matched"))
    total_relances = sum(len(p["relances"]) for p in periods_list)
    total_unmatched = sum(len(p["unmatched_bank_lines"]) for p in periods_list)
    invoice_anoms = sum(len(i["anomalies"]) for i in all_invoices)
    period_anoms = sum(len(p["period_anomalies"]) for p in periods_list)
    total_anomalies = invoice_anoms + period_anoms + total_unmatched
    blocking = 0
    for i in all_invoices:
        blocking += sum(1 for a in i["anomalies"] if a.get("blocking"))
    for p in periods_list:
        blocking += sum(1 for a in p["period_anomalies"] if a.get("blocking"))
        blocking += sum(1 for u in p["unmatched_bank_lines"] if u.get("blocking"))

    totals = {
        "invoices": total_inv, "paid": paid, "matched": matched,
        "relances": total_relances, "unmatched_bank_lines": total_unmatched,
        "anomalies": total_anomalies, "blocking_anomalies": blocking,
    }

    save_json(client_dir / "rapprochement.json", result)
    for legacy in LEGACY_FILES:
        legacy_path = client_dir / legacy
        if legacy_path.exists():
            legacy_path.unlink()

    return result, totals


# ── Main ──────────────────────────────────────────────────────────────────

def main():
    if not ROOT.exists():
        print(f"✗ Racine introuvable : {ROOT}")
        sys.exit(1)

    report = {"date": TODAY.isoformat(), "clients": []}
    tot_inv = tot_paid = tot_matched = tot_rel = tot_anom = tot_block = 0

    for client_dir in sorted(ROOT.iterdir()):
        if not client_dir.is_dir() or client_dir.name.startswith("_"):
            continue
        _result, t = process_client(client_dir)
        report["clients"].append({
            "client": client_dir.name, "invoices": t["invoices"], "paid": t["paid"],
            "matched": t["matched"], "relances": t["relances"],
            "anomalies": t["anomalies"], "blocking_anomalies": t["blocking_anomalies"],
        })
        tot_inv += t["invoices"]; tot_paid += t["paid"]; tot_matched += t["matched"]
        tot_rel += t["relances"]; tot_anom += t["anomalies"]; tot_block += t["blocking_anomalies"]

    report["totals"] = {
        "invoices": tot_inv, "rapprochements": tot_matched,
        "relances": tot_rel, "anomalies": tot_anom, "blocking_anomalies": tot_block,
    }
    save_json(Path(f"compta_batch_report_{TODAY}.json"), report)
    print(f"✓ {len(report['clients'])} clients — {tot_matched}/{tot_inv} rapprochés — "
          f"{tot_rel} relances — {tot_anom} anomalies ({tot_block} bloquantes)")
    for c in report["clients"]:
        print(f"  · {c['client']}: {c['paid']}/{c['invoices']} payées, {c['relances']} relances, {c['anomalies']} anomalies")
    print(f"  Rapport : compta_batch_report_{TODAY}.json")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (HIGH)