diff --git a/.claude/skills/arcodange-email-ingest/scripts/email-inspect.sh b/.claude/skills/arcodange-email-ingest/scripts/email-inspect.sh index 1525440..7649eb9 100755 --- a/.claude/skills/arcodange-email-ingest/scripts/email-inspect.sh +++ b/.claude/skills/arcodange-email-ingest/scripts/email-inspect.sh @@ -115,7 +115,7 @@ if [[ -n "${SAVE_PDF_DIR}" ]]; then fi # 5. Heuristic extract + render -python3 - "${WORK}" "${FMT}" <<'PY' +EXTRACT_DIR="${SCRIPT_DIR}" python3 - "${WORK}" "${FMT}" <<'PY' import json, sys, os, re, datetime, glob work, fmt = sys.argv[1:3] @@ -158,42 +158,18 @@ def extract(text): if v is not None: return f"{v:.2f}" return None - out["total_ht"] = first_amount(r'(?:total\s*ht|montant\s*ht|net\s*amount|subtotal)[^\d-]*([\d \.,]+)') - # TVA: require currency suffix to avoid matching VAT-number digits - out["total_tva"] = first_amount(r'(?:tva|vat)[^\d-]*([\d \.,]+)\s*(?:€|eur)\b') - out["total_ttc"] = first_amount(r'(?:total\s*ttc|amount\s*due|total\s*due|grand\s*total|montant\s*total|amount\s*paid)[^\d-]*([\d \.,]+)') - - # Invoice ref — must contain a digit (filters "umber", "Invoice", etc.) - m = re.search(r'(?:facture|invoice|receipt|reçu)\s*(?:n[°o]?|number|#|:)\s*([A-Za-z0-9][\w\d/-]{2,})', text, re.IGNORECASE) - if m and any(c.isdigit() for c in m.group(1)): - out["invoice_ref"] = m.group(1) - else: - # Fallback: any reasonable ref-shaped token after "Invoice" / "Facture" header - m = re.search(r'\b([A-Z]{2,}[-/]?\d[\w\d/-]{2,})\b', text) - out["invoice_ref"] = m.group(1) if m else None - - # Invoice date — try ISO, French DD/MM/YYYY, English MM/DD/YYYY, French long form - out["invoice_date_raw"] = None - for p in ( - r'\b(\d{4}-\d{2}-\d{2})\b', - r'(?:date|émise\s*le|invoice\s*date|date\s*de\s*facturation)[:\s]*(\d{1,2}[\s/.-]\d{1,2}[\s/.-]\d{2,4})', - r'(?:date|émise\s*le|invoice\s*date)[:\s]*(\d{1,2}\s+\w{3,9}\.?\s+\d{4})', - ): - m = re.search(p, text, re.IGNORECASE) - if m: out["invoice_date_raw"] = m.group(1).strip(); break - - # VAT rate (e.g. "20%") — restrict to 0-25% so "100%" / page footers don't match. - vrate = None - for line in lines: - m = re.search(r'\b(\d{1,2}([.,]\d+)?)\s*%', line) - if m: - v = float(m.group(1).replace(",", ".")) - if 0 <= v <= 25: - vrate = m.group(1).replace(",", "."); break - out["vat_rate_pct"] = vrate - + # Extraction déléguée au module testé (extract_fields.py), pinné par + # test_extract.py contre les 16 factures du golden set erp#39. L'ancienne + # version inline n'était pas exécutable isolément, donc jamais mesurée : + # elle renvoyait le n° de TVA d'Arcodange comme référence de facture. + # `python3 -` reads from stdin, so __file__ does not exist here: the shell + # passes the module's directory in EXTRACT_DIR. + sys.path.insert(0, os.environ["EXTRACT_DIR"]) + from extract_fields import extract as _extract + out.update(_extract(text)) return out + pdfs = [] for pdf in sorted(glob.glob(os.path.join(work,"atts","*.pdf")) + glob.glob(os.path.join(work,"atts","*.PDF"))): diff --git a/.claude/skills/arcodange-email-ingest/scripts/extract_fields.py b/.claude/skills/arcodange-email-ingest/scripts/extract_fields.py new file mode 100644 index 0000000..b89c1e2 --- /dev/null +++ b/.claude/skills/arcodange-email-ingest/scripts/extract_fields.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Field extraction from a supplier-invoice PDF text layer. + +Lifted out of email-inspect.sh so it can be REGRESSION-TESTED against the 16 +hand-verified invoices in fleet/golden/invoice-extract/ (see test_extract.py). +It was a heredoc, therefore untestable, therefore wrong in ways nobody could +measure: on Darnis F1048 it returned Arcodange's own VAT number as the invoice +reference and no date at all. + +Two structural traps, both observed on real documents: + + 1. `Nº` in French invoices is U+00BA (MASCULINE ORDINAL INDICATOR), not the + degree sign `°`. A character class of [°o] silently misses it, the primary + pattern fails, and the fallback grabs the first ref-shaped token in the + document — which is very often a VAT number. + 2. Two-column layouts put the label and its value on DIFFERENT lines, with + unrelated text in between. A pattern requiring `label:\\s*value` never + matches, so the field comes back empty. + +Principle kept from the atom contract: emit nothing rather than emit a guess. +A value that fails its own sanity check is dropped, not repaired. +""" +from __future__ import annotations + +import re + +# Ordinal marks seen on real French invoices: degree sign, masculine ordinal, 'o' +ORD = "[°ºo]?" + +# VAT numbers are ref-shaped and appear near the top of most invoices — the +# single biggest source of false "invoice_ref". Two country prefixes + 8-13 +# digits/letters, no separators. +VAT_LIKE = re.compile(r"^(?:FR[0-9A-Z]{11}|BE0?\d{9,10}|DE\d{9}|ES[0-9A-Z]\d{7}[0-9A-Z]|IT\d{11}" + r"|NL\d{9}B\d{2}|LU\d{8}|IE\d{7}[A-W]{1,2}|PT\d{9}|AT U\d{8}|PL\d{10})$", re.I) +SIRET_LIKE = re.compile(r"^\d{9,14}$") + + +def parse_amount(s: str) -> float | None: + """European or Anglo number → float. None when it is not a number.""" + if not s: + return None + t = re.sub(r"[\s  ]", "", s).strip().rstrip(".,") + if not t: + return None + if "," in t and "." in t: # 1.234,56 or 1,234.56 + t = t.replace(".", "").replace(",", ".") if t.rfind(",") > t.rfind(".") \ + else t.replace(",", "") + elif "," in t: + t = t.replace(",", ".") + try: + return float(t) + except ValueError: + return None + + +def extract_ref(text: str) -> str | None: + """The supplier's own invoice number.""" + # 1. Explicitly labelled, on the same line — the reliable case. + for pat in ( + rf"(?:facture|invoice|receipt|re[çc]u)\s*(?:n{ORD}|number|#|:)\s*[:\s]*([A-Za-z0-9][\w\d/-]{{2,}})", + r"(?:num[ée]ro\s+de\s+facture|invoice\s+number)\s*:?\s*([A-Za-z0-9][\w\d/-]{2,})", + ): + for m in re.finditer(pat, text, re.IGNORECASE): + cand = m.group(1).strip(" .:،,") + if _plausible_ref(cand): + return cand + + # 2. Two-column layout: the label sits alone, the value lands further down + # the page. Look ahead a bounded window and take the first plausible + # token — bounded so we do not wander into another block. + m = re.search(r"(?:num[ée]ro\s+de\s+facture|invoice\s+number|facture\s+n" + ORD + r")\s*:?", text, re.IGNORECASE) + if m: + window = text[m.end():m.end() + 400] + # Rejoin FIRST: if the window opens on a dangling fragment, the tail alone + # is also plausible, and scanning first would return a ref amputated of + # its head — a subtly wrong value, the worst kind. + # A ref split by the column wrap: "06-01-26-" ends a line, its tail + # ("payment-366753") lands further down in the other column. Rejoin the + # dangling fragment with the next token that carries a digit. + frag = re.match(r"\s*([A-Za-z0-9][\w\d/-]*[-/])(?=\s)", window) + if frag: + for tail in re.findall(r"\b([A-Za-z0-9][\w\d/-]{2,})\b", window[frag.end():frag.end() + 200]): + if any(c.isdigit() for c in tail): + joined = frag.group(1) + tail + if _plausible_ref(joined): + return joined + for cand in re.findall(r"\b([A-Za-z0-9][\w\d/-]{2,})\b", window): + if _plausible_ref(cand): + return cand + + # 3. No labelled ref found. Emit nothing rather than the first ref-shaped + # token in the document — that is how a VAT number ends up as an invoice + # reference, which is exactly the bug this module exists to kill. + return None + + +def _plausible_ref(cand: str) -> bool: + if not cand or not any(c.isdigit() for c in cand): + return False + if VAT_LIKE.match(cand) or SIRET_LIKE.match(cand): + return False + if re.fullmatch(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}", cand): # a date + return False + if len(cand) > 40: + return False + if cand.endswith(("-", "/", ".")): # tronquée au saut de colonne + return False + return True + + +MONTHS = {} +for i, names in enumerate([ + ("janvier", "janv", "january", "jan"), ("février", "fevrier", "févr", "fevr", "february", "feb"), + ("mars", "march", "mar"), ("avril", "avr", "april", "apr"), ("mai", "may"), + ("juin", "june", "jun"), ("juillet", "juil", "july", "jul"), ("août", "aout", "august", "aug"), + ("septembre", "sept", "september", "sep"), ("octobre", "oct", "october"), + ("novembre", "nov", "november"), ("décembre", "decembre", "déc", "dec", "december"), +], start=1): + for n in names: + MONTHS[n] = i + + +def to_iso(raw: str | None) -> str | None: + """dd/mm/yyyy or '2 avr. 2026' → yyyy-mm-dd. Consumers want one shape, not five.""" + if not raw: + return None + raw = raw.strip() + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw): + return raw + m = re.fullmatch(r"(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2,4})", raw) + if m: + d, mo, y = m.groups() + y = "20" + y if len(y) == 2 else y + return f"{y}-{int(mo):02d}-{int(d):02d}" + m = re.fullmatch(r"(\d{1,2})\s+([A-Za-zéûîàôçÉ.]+)\.?\s+(\d{4})", raw) + if m: + d, mon, y = m.groups() + idx = MONTHS.get(mon.lower().rstrip(".")) + if idx: + return f"{y}-{idx:02d}-{int(d):02d}" + return raw + + +DATE_LABELS = (r"date\s*d[e']\s*[ée]mission", r"date\s*de\s*facturation", r"invoice\s*date", + r"date\s*d[e']\s*facture", r"[ée]mise?\s*le", r"\bdate\b") + + +def extract_date(text: str) -> str | None: + """Issue date. Prefers a labelled date; falls back to the earliest date seen.""" + # ISO anywhere wins — unambiguous. + m = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", text) + if m: + return m.group(1) + + # Labelled, allowing the two-column gap: up to 300 chars of anything between + # the label and its value (the value is often a column away and a line down). + for lab in DATE_LABELS: + for m in re.finditer(lab, text, re.IGNORECASE): + window = text[m.end():m.end() + 300] + d = re.search(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b", window) + if d: + return d.group(1) + d = re.search(r"\b(\d{1,2}\s+[A-Za-zéûîàôç.]{3,10}\.?\s+\d{4})\b", window) + if d: + return d.group(1) + + # Unlabelled: take the first date-shaped token, but only if the document has + # one — no invention. + m = re.search(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{4})\b", text) + return m.group(1) if m else None + + +def extract_amounts(text: str) -> dict: + """HT / TVA / TTC. Returns only what the document actually labels.""" + lines = text.splitlines() + + def first(pattern: str) -> str | None: + for line in lines: + m = re.search(pattern, line, re.IGNORECASE) + if m: + v = parse_amount(m.group(1)) + if v is not None: + return f"{v:.2f}" + return None + + AMT = r"[^\d-]*([\d\u00a0\u202f .,]+)" + # "Montant total (HT)" is an HT, not a TTC — the parenthetical decides, so it + # is matched on the HT side and excluded on the TTC side. + ht = first(r"(?:total\s*ht|montant\s*ht|montant\s*total\s*\(\s*ht\s*\)|net\s*amount|subtotal|sous.?total\s*\(\s*ht\s*\))" + AMT) + # The rate often sits between the label and the amount — "TVA (20%) : 43,70 €". + # A currency mark is still required so a VAT *number* is never read as a VAT *amount*. + tva = first(r"(?:tva|vat)\s*(?:\(\s*\d{1,2}(?:[.,]\d+)?\s*%\s*\))?\s*:?" + AMT + r"\s*(?:€|\beur\b)") + ttc = first(r"(?:total\s*ttc|amount\s*due|total\s*due|grand\s*total|montant\s*total|amount\s*paid)(?!\s*\(?\s*ht)" + AMT) + if ttc is None: + # Some invoices label the TTC simply "Total" (Legalstart, INPI). Accept it + # only as a fallback, and only on a line that is nothing else. + ttc = first(r"^\s*total\s+" + AMT.lstrip("[^\\d-]*") if False else r"^\s*total\s+([\d\u00a0\u202f .,]+)\s*€") + + out = {"total_ht": ht, "total_tva": tva, "total_ttc": ttc} + # Arithmetic sanity: if HT + TVA disagrees with TTC we matched the wrong + # lines somewhere. Report it rather than pretend the numbers are coherent. + h, t_, c = (parse_amount(out[k] or "") for k in ("total_ht", "total_tva", "total_ttc")) + out["arithmetic_ok"] = None if None in (h, t_, c) else abs(h + t_ - c) <= 0.01 + return out + + +def extract_vat_rate(text: str) -> str | None: + for line in text.splitlines(): + m = re.search(r"\b(\d{1,2}(?:[.,]\d+)?)\s*%", line) + if m: + v = float(m.group(1).replace(",", ".")) + if 0 <= v <= 25: + return m.group(1) + return None + + +def extract(text: str) -> dict: + out = { + "invoice_ref": extract_ref(text), + "invoice_date_raw": to_iso(extract_date(text)), + "vat_rate_pct": extract_vat_rate(text), + } + out.update(extract_amounts(text)) + return out diff --git a/.claude/skills/arcodange-email-ingest/scripts/test_extract.py b/.claude/skills/arcodange-email-ingest/scripts/test_extract.py new file mode 100644 index 0000000..0b6dc87 --- /dev/null +++ b/.claude/skills/arcodange-email-ingest/scripts/test_extract.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Regression test for extract_fields.py against the erp#39 golden set. + +The email skill's extraction was a heredoc inside a shell script: impossible to +run in isolation, therefore never measured, therefore silently wrong. This pins +it to the 16 hand-verified supplier invoices in fleet/golden/invoice-extract/. + +Scored per field, and a WRONG value counts against us far more than a missing +one: emitting Arcodange's VAT number as an invoice reference is worse than +emitting nothing, because a human copies it without looking. + + python3 test_extract.py # summary + python3 test_extract.py --verbose # per-document diff +""" +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from extract_fields import extract, parse_amount + +REPO = os.path.realpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..")) +GOLDEN = os.path.join(REPO, "fleet", "golden", "invoice-extract") +VERBOSE = "--verbose" in sys.argv + + +def norm_date(s): + """dd/mm/yyyy or yyyy-mm-dd → yyyy-mm-dd; anything else stays as-is.""" + if not s: + return None + s = s.strip() + if len(s) == 10 and s[4] == "-": + return s + for sep in ("/", ".", "-"): + p = s.split(sep) + if len(p) == 3 and all(x.strip().isdigit() for x in p): + d, m, y = (x.strip() for x in p) + if len(y) == 2: + y = "20" + y + if len(y) == 4: + return f"{y}-{int(m):02d}-{int(d):02d}" + return s + + +def main() -> int: + inputs = os.path.join(GOLDEN, "inputs") + if not os.path.isdir(inputs): + print(f"golden set not found at {GOLDEN} — skipping", file=sys.stderr) + return 0 + + stats = {f: {"ok": 0, "wrong": 0, "missing": 0} for f in ("invoice_ref", "invoice_date_raw", "total_ht", "total_ttc")} + docs = 0 + for name in sorted(os.listdir(inputs)): + if not name.endswith(".json"): + continue + doc = json.load(open(os.path.join(inputs, name))) + exp_path = os.path.join(GOLDEN, "expected", name) + if not os.path.exists(exp_path): + continue + exp = json.load(open(exp_path)) + got = extract(doc["text"]) + docs += 1 + + checks = { + "invoice_ref": (exp.get("ref_supplier"), got.get("invoice_ref")), + "invoice_date_raw": (exp.get("date_issue"), norm_date(got.get("invoice_date_raw"))), + "total_ht": (exp.get("totals", {}).get("ht"), parse_amount(got.get("total_ht") or "")), + "total_ttc": (exp.get("totals", {}).get("ttc"), parse_amount(got.get("total_ttc") or "")), + } + for field, (want, have) in checks.items(): + if have is None: + stats[field]["missing"] += 1 + verdict = "MANQUE" + elif isinstance(want, (int, float)) and isinstance(have, (int, float)): + ok = abs(float(want) - float(have)) <= 0.01 + stats[field]["ok" if ok else "wrong"] += 1 + verdict = "ok" if ok else "FAUX" + else: + ok = str(want) == str(have) + stats[field]["ok" if ok else "wrong"] += 1 + verdict = "ok" if ok else "FAUX" + if VERBOSE and verdict != "ok": + print(f" {name[:34]:<36}{field:<18}{verdict:<8}attendu={want!r} obtenu={have!r}") + + print(f"\n{docs} documents du golden set\n") + print(f"{'champ':<20}{'exact':>7}{'FAUX':>7}{'absent':>9} {'exactitude'}") + print("-" * 62) + total_wrong = 0 + for f, s in stats.items(): + total = s["ok"] + s["wrong"] + s["missing"] + total_wrong += s["wrong"] + print(f"{f:<20}{s['ok']:>7}{s['wrong']:>7}{s['missing']:>9} {100*s['ok']/total:.1f} %") + print("-" * 62) + print(f"\nvaleurs FAUSSES (le pire cas — un humain les recopie) : {total_wrong}") + return 1 if total_wrong else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fleet/profile/decisions/adc-008-date-facture-fournisseur.md b/fleet/profile/decisions/adc-008-date-facture-fournisseur.md new file mode 100644 index 0000000..1f94f51 --- /dev/null +++ b/fleet/profile/decisions/adc-008-date-facture-fournisseur.md @@ -0,0 +1,86 @@ +--- +id: adc-008 +title: "Facture fournisseur enregistrée à SA date, y compris future, tant que l'exercice ne change pas" +status: Accepted +decided: 2026-08-13 +effective_from: 2026-08-13 +effective_until: null +supersedes: null +superseded_by: null +--- + +# adc-008 — Date d'enregistrement d'une facture fournisseur + +## Context + +Un fournisseur peut émettre une facture datée **postérieurement** à son envoi. +Cas déclencheur : Darnis Operations envoie le 13/08/2026 la facture **F1048** +datée du **31/08/2026** (pratique de facturation en fin de mois). + +Question posée : enregistrer le document à sa date, attendre cette date, ou +demander une réémission ? + +## Decision + +**La facture est enregistrée à la date portée sur le document**, même si cette +date est dans le futur, **dès lors qu'elle tombe dans le même exercice +comptable** — l'exercice Arcodange étant l'année civile (clôture au 31/12, +cf. `fiscal.yaml`). + +**Limite qui inverse la décision :** si la date portée fait basculer l'écriture +dans l'exercice **suivant** (facture reçue en décembre, datée de janvier), la +présente décision ne s'applique pas — l'enregistrement attend, ou la question +est arbitrée avec l'expert-comptable. Le rattachement à l'exercice prime sur la +commodité de saisie. + +## Base légale & doctrine + +Le rattachement d'une charge à un exercice suit le principe d'indépendance des +exercices (art. 38 et 39 CGI ; PCG art. 513-4) : c'est la date de la facture, +et non celle de sa saisie, qui détermine l'exercice. Altérer la date d'une +facture fournisseur reviendrait à substituer une donnée à celle du document — +contraire à la piste d'audit fiable (CGI art. 289 VII), qui exige que l'écriture +reflète la pièce justificative. + +Côté TVA déductible, le droit à déduction sur une prestation de services naît à +l'encaissement par le prestataire (CGI art. 271), non à la saisie : la date +d'enregistrement est donc sans effet. + +## Alternatives rejected + +- **Attendre la date de la facture pour l'enregistrer.** Sans risque, mais crée + une file d'attente de pièces reçues non saisies — précisément ce que la + régularisation de juillet 2026 avait mis au jour. +- **Demander la réémission à la date d'envoi.** Le plus propre sur le fond, mais + impose un geste au fournisseur pour un enjeu nul dans le même exercice. +- **Enregistrer à la date de réception plutôt qu'à celle du document.** Rejeté : + fait diverger l'écriture de sa pièce justificative. + +## Consequences + +- Aucun paiement n'est enregistré tant que le règlement n'a pas eu lieu : la + facture reste `paye=0` jusqu'à l'encaissement effectif côté fournisseur. +- Le libellé du fournisseur peut désigner une autre période que le fait + générateur (F1048 indique « août 2026 » pour une commission sur l'encaissement + de **juillet**). Le libellé n'est pas corrigé ; la note interne documente le + rattachement réel. +- Le juge pré-gate signale toute date future comme anomalie. C'est le + comportement voulu : la présente décision est ce qui autorise l'opérateur à + passer outre, et ce passage outre reste tracé dans le dossier de preuve. + +## QA & validation + +- Contrôle arithmétique du document avant saisie (HT + TVA = TTC) — sur F1048 : + 218,50 + 43,70 = 262,20, vérifié par `extract_fields.py`. +- Répétition sandbox puis gate humain via `fleet/harness/promote/` : le verdict + BLOCK du juge sur la date future doit être explicitement outrepassé, jamais + contourné silencieusement. +- Au 31/12, vérifier qu'aucune facture enregistrée ne porte une date de + l'exercice suivant — c'est le seul cas que cette décision n'autorise pas. + +## References + +- Première application : facture fournisseur `FAF2026014` (Darnis F1048), + enregistrée le 13/08/2026 en production, datée du 31/08/2026. +- Exercice comptable : `fleet/profile/fiscal.yaml`, `entity.fiscal_year_end`. +- Contrat d'apport d'affaires : `CT2601-0001` (adc-005 pour la lane CCA). diff --git a/test/provisionAiUser.ts b/test/provisionAiUser.ts index a3977e6..7b3e729 100644 --- a/test/provisionAiUser.ts +++ b/test/provisionAiUser.ts @@ -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 { - 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; }, diff --git a/test/scopes.ts b/test/scopes.ts index 54a9e13..1b0923a 100644 --- a/test/scopes.ts +++ b/test/scopes.ts @@ -77,6 +77,7 @@ export const PERMISSION_LABELS: Readonly> = { 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> = { 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> = { const READ_ONLY: ReadonlyArray = [ 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> = { @@ -135,7 +137,7 @@ export const SCOPES: Readonly> = { "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> = { * 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> = { // 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;