Merge pull request 'fix(email-ingest): extraction testable et pinnée au golden set + adc-008' (#83) from arcodange/prod-apply into main

This commit was merged in pull request #83.
This commit is contained in:
2026-08-13 10:47:58 +02:00
6 changed files with 451 additions and 42 deletions
@@ -115,7 +115,7 @@ if [[ -n "${SAVE_PDF_DIR}" ]]; then
fi fi
# 5. Heuristic extract + render # 5. Heuristic extract + render
python3 - "${WORK}" "${FMT}" <<'PY' EXTRACT_DIR="${SCRIPT_DIR}" python3 - "${WORK}" "${FMT}" <<'PY'
import json, sys, os, re, datetime, glob import json, sys, os, re, datetime, glob
work, fmt = sys.argv[1:3] work, fmt = sys.argv[1:3]
@@ -158,42 +158,18 @@ def extract(text):
if v is not None: return f"{v:.2f}" if v is not None: return f"{v:.2f}"
return None return None
out["total_ht"] = first_amount(r'(?:total\s*ht|montant\s*ht|net\s*amount|subtotal)[^\d-]*([\d \.,]+)') # Extraction déléguée au module testé (extract_fields.py), pinné par
# TVA: require currency suffix to avoid matching VAT-number digits # test_extract.py contre les 16 factures du golden set erp#39. L'ancienne
out["total_tva"] = first_amount(r'(?:tva|vat)[^\d-]*([\d \.,]+)\s*(?:€|eur)\b') # version inline n'était pas exécutable isolément, donc jamais mesurée :
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 \.,]+)') # 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
# Invoice ref — must contain a digit (filters "umber", "Invoice", etc.) # passes the module's directory in EXTRACT_DIR.
m = re.search(r'(?:facture|invoice|receipt|reçu)\s*(?:n[°o]?|number|#|:)\s*([A-Za-z0-9][\w\d/-]{2,})', text, re.IGNORECASE) sys.path.insert(0, os.environ["EXTRACT_DIR"])
if m and any(c.isdigit() for c in m.group(1)): from extract_fields import extract as _extract
out["invoice_ref"] = m.group(1) out.update(_extract(text))
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
return out return out
pdfs = [] pdfs = []
for pdf in sorted(glob.glob(os.path.join(work,"atts","*.pdf")) + for pdf in sorted(glob.glob(os.path.join(work,"atts","*.pdf")) +
glob.glob(os.path.join(work,"atts","*.PDF"))): glob.glob(os.path.join(work,"atts","*.PDF"))):
@@ -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
@@ -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())
@@ -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).
+18 -4
View File
@@ -79,11 +79,25 @@ const globalCtx = {
* work-in-progress — a provisioning script must not depend on someone's WIP. * work-in-progress — a provisioning script must not depend on someone's WIP.
*/ */
async function findUserId(userLogin: string): Promise<number | undefined> { async function findUserId(userLogin: string): Promise<number | undefined> {
await page.goto(`${dolibarrAddress}/user/list.php?mode=&search_user=${encodeURIComponent(userLogin)}`); // Two traps, both observed live on Dolibarr 22:
const href = await page.locator(`a[href*="/user/card.php?id="]`).evaluateAll( // - 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) => { (as: { textContent: string | null; getAttribute(n: string): string | null }[], want: string) => {
for (const el of as) { for (const a of as) {
if ((el.textContent ?? "").trim() === want) return el.getAttribute("href"); const text = (a.textContent ?? "").trim();
const bare = text.replace(/[…\.]+$/, "");
if (text === want || (bare.length >= 8 && want.startsWith(bare))) {
return a.getAttribute("href");
}
} }
return null; 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", 2501: "Lire/récupérer les documents",
3201: "Lire les événements archivés et leurs empreintes", 3201: "Lire les événements archivés et leurs empreintes",
50411: "Lire les opérations du Grand livre", 50411: "Lire les opérations du Grand livre",
40001: "Lire les devises et leurs taux",
// --- écriture (jamais dans le scope read) --- // --- écriture (jamais dans le scope read) ---
12: "Créer/modifier les factures clients", 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", 1232: "Créer les factures fournisseur",
2402: "Créer/modifier des actions/événements", 2402: "Créer/modifier des actions/événements",
2503: "Soumettre ou supprimer des documents", 2503: "Soumettre ou supprimer des documents",
162: "Créer/modifier les contrats/abonnements",
50401: "Lier les produits et factures avec des comptes comptables", 50401: "Lier les produits et factures avec des comptes comptables",
// --- suppression : JAMAIS accordée par aucun scope --- // --- suppression : JAMAIS accordée par aucun scope ---
@@ -112,7 +114,7 @@ export const PERMISSION_LABELS: Readonly<Record<number, string>> = {
const READ_ONLY: ReadonlyArray<number> = [ const READ_ONLY: ReadonlyArray<number> = [
11, 16, 21, 28, 31, 41, 45, 91, 94, 111, 121, 126, 141, 161, 167, 251, 262, 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, 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>> = { export const SCOPES: Readonly<Record<string, Scope>> = {
@@ -135,7 +137,7 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
"sandbox-write": { "sandbox-write": {
purpose: "Répétition d'un change-set sur la sandbox (factures, tiers, produits, propositions, règlements)", purpose: "Répétition d'un change-set sur la sandbox (factures, tiers, produits, propositions, règlements)",
environments: ["sandbox"], 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. * for what that step actually does: create/modify invoices, attach payments.
* No thirdparty creation, no product creation, no proposals those are * No thirdparty creation, no product creation, no proposals those are
* rehearsed then applied by a human when they are genuinely needed. * 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` * Provisioning this user is itself a deliberate act: `--env production`
* requires the explicit opt-in in guard.ts. * 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 // 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, // 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. // 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; } as const;