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:
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user