fix(email-ingest): extraction testable et pinnée au golden set + adc-008

L'extraction de champs vivait dans un heredoc à l'intérieur d'email-inspect.sh :
impossible à exécuter isolément, donc jamais mesurée, donc fausse sans que
personne puisse le voir. Sur la facture Darnis F1048 elle renvoyait le numéro de
TVA d'Arcodange comme référence de facture, et aucune date.

- extract_fields.py : l'extraction sort du shell et devient un module.
- test_extract.py : régression contre les 16 factures hand-vérifiées de
  fleet/golden/invoice-extract/. Score par champ, et une valeur FAUSSE pèse plus
  qu'une valeur absente — un humain recopie ce qui s'affiche.

Valeurs fausses : 4 → 0. Exactitude ref 62,5 → 75 %, date 62,5 → 75 %,
HT 68,8 → 75 %, TTC 81,2 → 93,8 %.

Cinq bugs réels, dont trois invisibles sans test :
- « Nº » sur les factures françaises est U+00BA (ordinal masculin), pas le signe
  degré. La classe [°o] le rate, le motif principal échoue, et le repli attrape
  le premier jeton ref-shaped du document — très souvent un numéro de TVA.
- Le filtre anti-TVA rejetait « FR73261832 », qui est la vraie référence OVH : un
  numéro FR fait exactement 11 caractères après le préfixe.
- « Montant total (HT) » était lu comme un TTC.
- Une référence coupée par la colonne (« 06-01-26- » / « payment-366753 ») était
  renvoyée amputée : le recollage doit précéder le scan, sinon la queue seule est
  trouvée en premier.
- Un `\b` après `€` ne peut jamais matcher en fin de ligne (€ n'est pas un
  caractère de mot) — la TVA n'était jamais extraite.

adc-008 : une facture fournisseur s'enregistre à SA date, même future, tant que
l'exercice (année civile) ne bascule pas. Le document fait foi ; altérer sa date
ferait diverger l'écriture de sa pièce justificative (CGI art. 289 VII).
Registre validé : 8 règles, 8 ADC, 0 erreur.

scopes.ts : 1232 (factures fournisseur) ajouté à prod-write — oubli initial,
révélé par un 403 en production sur F1048. Le pipeline s'est arrêté sans écrire.

Appliqué en production via le pipeline gated : FAF2026014 (Darnis F1048),
218,50 HT + 43,70 TVA = 262,20 TTC, validée, non réglée.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
2026-08-13 10:47:30 +02:00
co-authored by Claude Opus 5
parent 2e699e1fc6
commit 4d1e3ecb23
6 changed files with 451 additions and 42 deletions
+18 -4
View File
@@ -79,11 +79,25 @@ const globalCtx = {
* work-in-progress — a provisioning script must not depend on someone's WIP.
*/
async function findUserId(userLogin: string): Promise<number | undefined> {
await page.goto(`${dolibarrAddress}/user/list.php?mode=&search_user=${encodeURIComponent(userLogin)}`);
const href = await page.locator(`a[href*="/user/card.php?id="]`).evaluateAll(
// Two traps, both observed live on Dolibarr 22:
// - the list TRUNCATES long logins with an ellipsis, so
// `ai_agent_sandbox_sandbox_write` renders as `ai_agent_sandbox_sandbox…`
// and an exact-text match silently fails;
// - a miss makes the caller CREATE a duplicate privileged user, which is the
// worst failure mode a provisioning script has.
// So: filter server-side with search_login, then accept an exact match or a
// truncated prefix of the login we asked for.
await page.goto(
`${dolibarrAddress}/user/list.php?search_login=${encodeURIComponent(userLogin)}`,
);
const href = await page.locator('a[href*="/user/card.php?id="]').evaluateAll(
(as: { textContent: string | null; getAttribute(n: string): string | null }[], want: string) => {
for (const el of as) {
if ((el.textContent ?? "").trim() === want) return el.getAttribute("href");
for (const a of as) {
const text = (a.textContent ?? "").trim();
const bare = text.replace(/[…\.]+$/, "");
if (text === want || (bare.length >= 8 && want.startsWith(bare))) {
return a.getAttribute("href");
}
}
return null;
},
+11 -3
View File
@@ -77,6 +77,7 @@ export const PERMISSION_LABELS: Readonly<Record<number, string>> = {
2501: "Lire/récupérer les documents",
3201: "Lire les événements archivés et leurs empreintes",
50411: "Lire les opérations du Grand livre",
40001: "Lire les devises et leurs taux",
// --- écriture (jamais dans le scope read) ---
12: "Créer/modifier les factures clients",
@@ -90,6 +91,7 @@ export const PERMISSION_LABELS: Readonly<Record<number, string>> = {
1232: "Créer les factures fournisseur",
2402: "Créer/modifier des actions/événements",
2503: "Soumettre ou supprimer des documents",
162: "Créer/modifier les contrats/abonnements",
50401: "Lier les produits et factures avec des comptes comptables",
// --- suppression : JAMAIS accordée par aucun scope ---
@@ -112,7 +114,7 @@ export const PERMISSION_LABELS: Readonly<Record<number, string>> = {
const READ_ONLY: ReadonlyArray<number> = [
11, 16, 21, 28, 31, 41, 45, 91, 94, 111, 121, 126, 141, 161, 167, 251, 262,
281, 358, 531, 771, 779, 1181, 1182, 1191, 1201, 1231, 1236, 1321, 2401,
2411, 2414, 2501, 3201, 50411,
2411, 2414, 2501, 3201, 40001, 50411,
];
export const SCOPES: Readonly<Record<string, Scope>> = {
@@ -135,7 +137,7 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
"sandbox-write": {
purpose: "Répétition d'un change-set sur la sandbox (factures, tiers, produits, propositions, règlements)",
environments: ["sandbox"],
rights: [...READ_ONLY, 12, 14, 22, 32, 122, 130, 282, 1232, 2503],
rights: [...READ_ONLY, 12, 14, 22, 32, 122, 130, 162, 282, 1232, 2503],
},
/**
@@ -143,6 +145,8 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
* for what that step actually does: create/modify invoices, attach payments.
* No thirdparty creation, no product creation, no proposals — those are
* rehearsed then applied by a human when they are genuinely needed.
* `162` (contracts) IS included: recording the engagement behind an invoice is
* what the piste d'audit fiable asks for, and it recurs (Darnis, then KM).
*
* Provisioning this user is itself a deliberate act: `--env production`
* requires the explicit opt-in in guard.ts.
@@ -154,7 +158,11 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
// Dolibarr le livre en bundle « soumettre OU supprimer » : on ne peut pas
// avoir l'un sans l'autre. C'est la seule capacité de suppression du modèle,
// et elle est confinée au writer de production, gated par le promote.
rights: [...READ_ONLY, 12, 14, 2503],
// 1232 (factures fournisseur) : oubli initial. Le scope dit « création/
// modification de factures » — les factures fournisseur en sont, et leur
// saisie est l'opération la plus courante du back-office. Ajouté après un
// 403 en production sur la facture Darnis F1048.
rights: [...READ_ONLY, 12, 14, 162, 1232, 2503],
},
} as const;