L'article 289 impose une numérotation chronologique et continue aux factures que la société ÉMET. Il ne dit rien de la référence de classement que Dolibarr attribue à celles qu'elle REÇOIT : le numéro qui fait foi pour ces dernières est celui du fournisseur, porté par ref_supplier, et la séquence FAF suit l'ordre d'ENREGISTREMENT — c'est sa construction normale, pas un défaut. Appliquée aux deux registres, la garde produisait un faux positif systématique. La production porte DÉJÀ cinq ruptures dans la séquence FAF — FAF2026003 daté du 4 janvier suit FAF2026002 daté du 9 — et ZÉRO dans la séquence FAC. Or adc-008 prescrit d'enregistrer une facture fournisseur à la date du document : le cas ordinaire se heurtait donc au refus et exigeait ARCO_ALLOW_BACKDATE. Une garde qu'on outrepasse par routine ne garde plus rien. Elle avait déjà été outrepassée deux fois en une journée sur les factures Anthropic, et le même faux positif avait été produit par le juge pré-gate sur le change-set d'indemnité — signe que l'erreur était dans la règle, pas dans son application. Vérifié sur les deux registres : facture FOURNISSEUR antidatée au 01/03 -> créée facture CLIENT antidatée au 01/03 -> REFUSÉE (FAC008 au 23/07) La garde protège désormais là où la loi s'applique, et cesse d'obstruer là où elle ne s'applique pas. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
247 lines
12 KiB
Bash
Executable File
247 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Create a customer or supplier invoice (facture) with product/service lines in
|
|
# the SANDBOX, optionally validating it — IDEMPOTENT (erp#44): replaying the
|
|
# same create returns the existing invoice instead of minting a duplicate.
|
|
#
|
|
# Input: a JSON object on stdin (or a file path in $1):
|
|
# socid (required) thirdparty id
|
|
# kind "customer" | "supplier" (default "customer")
|
|
# date "YYYY-MM-DD" (default today)
|
|
# ref_supplier supplier's own invoice ref (supplier invoices)
|
|
# validate true|false (default false = leave draft)
|
|
# lines: [ { desc, qty, price_ht, tva, type: "product"|"service", product_id? } ]
|
|
#
|
|
# Idempotency (erp#44 — Learning #4 of the 2026-07-11 rehearsal, where a manifest
|
|
# that failed mid-run could not be replayed because op 1 would have re-created
|
|
# the DARNIS invoice). BEFORE any POST, list the thirdparty's invoices of the
|
|
# same kind and dedupe:
|
|
# - supplier kind WITH ref_supplier : by (socid, ref_supplier) — the supplier's
|
|
# own ref is the natural key. A match whose total_ttc DIFFERS from the
|
|
# requested lines ABORTS (same key, different content = a data conflict to
|
|
# resolve by a human, not a dedupe).
|
|
# - customer kind (or supplier without ref_supplier) : by (socid, date,
|
|
# total_ttc ±0.02, line fingerprint) where the fingerprint is the multiset of
|
|
# (desc, qty, subprice, tva_tx) per line — desc HTML-unescaped and
|
|
# whitespace-collapsed, since Dolibarr returns stored descriptions
|
|
# HTML-encoded.
|
|
# - 2+ matches ABORT (ambiguous — the target already has duplicates; never
|
|
# guess). Credit notes (type=2) are never dedupe candidates.
|
|
# On a match: if `validate:true` was requested and the match is still a DRAFT
|
|
# (statut=0 — e.g. run 1 died between create and validate), it is validated now,
|
|
# so a replayed manifest CONVERGES instead of leaving a half-done op behind.
|
|
# A listing failure other than 404 ABORTS — POSTing blind would mint duplicates.
|
|
#
|
|
# Emits {id, ref, ref_supplier, total_ht, total_ttc, statut, deduped} on stdout
|
|
# — deduped:true means no invoice was created (the id is the pre-existing one).
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
|
|
|
|
SRC="${1:-}"
|
|
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi
|
|
|
|
TMPD="$(mktemp -d -t invcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
|
|
cat > "${TMPD}/map.py" <<'PY'
|
|
import json, sys, datetime
|
|
d = json.loads(sys.stdin.read())
|
|
if not d.get("socid"):
|
|
sys.exit("invoice-create.sh: 'socid' is required")
|
|
supplier = d.get("kind", "customer").lower() in ("supplier", "fournisseur")
|
|
endpoint = "/supplierinvoices" if supplier else "/invoices"
|
|
ds = d.get("date")
|
|
dt = datetime.datetime.strptime(ds, "%Y-%m-%d") if ds else datetime.datetime.now()
|
|
epoch = int(dt.timestamp())
|
|
lines = []
|
|
for ln in d.get("lines", []):
|
|
is_product = ln.get("type", "service").lower() in ("product", "produit")
|
|
L = {
|
|
"desc": ln.get("desc", ""),
|
|
"subprice": str(ln.get("price_ht", ln.get("subprice", 0))),
|
|
"qty": str(ln.get("qty", 1)),
|
|
"tva_tx": str(ln.get("tva", ln.get("tva_tx", 20))),
|
|
"product_type": "0" if is_product else "1",
|
|
}
|
|
if ln.get("product_id"):
|
|
L["fk_product"] = str(ln["product_id"])
|
|
lines.append(L)
|
|
body = {"socid": d["socid"], "date": epoch, "type": 0, "lines": lines}
|
|
if supplier and d.get("ref_supplier"):
|
|
body["ref_supplier"] = d["ref_supplier"]
|
|
# Expected total_ttc, Dolibarr-style (per-line rounding), for the dedupe key.
|
|
total_ttc = round(sum(round(float(L["qty"]) * float(L["subprice"]), 2)
|
|
* (1 + float(L["tva_tx"]) / 100.0) for L in lines), 2)
|
|
dedupe = {"supplier": supplier, "socid": str(d["socid"]),
|
|
"ref_supplier": str(d.get("ref_supplier") or "") if supplier else "",
|
|
"date": dt.strftime("%Y-%m-%d"), "total_ttc": total_ttc,
|
|
"lines": [[L["desc"], L["qty"], L["subprice"], L["tva_tx"]] for L in lines]}
|
|
print(endpoint)
|
|
print(json.dumps(body))
|
|
print("1" if d.get("validate") else "0")
|
|
print(json.dumps(dedupe))
|
|
PY
|
|
|
|
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/map.py")"
|
|
ENDPOINT="$(sed -n 1p <<<"${MAPPED}")"
|
|
BODY="$(sed -n 2p <<<"${MAPPED}")"
|
|
VALIDATE="$(sed -n 3p <<<"${MAPPED}")"
|
|
DEDUPE="$(sed -n 4p <<<"${MAPPED}")"
|
|
printf '%s' "${DEDUPE}" > "${TMPD}/dedupe.json"
|
|
|
|
# Final read-back: shared by the create and dedupe paths.
|
|
emit_summary() { # $1 = invoice id, $2 = deduped true|false
|
|
"${W}" GET "${ENDPOINT}/$1" | DEDUPED="$2" python3 -c "import json,sys,os
|
|
d=json.load(sys.stdin)
|
|
o={k:d.get(k) for k in ('id','ref','ref_supplier','total_ht','total_ttc','statut')}
|
|
o['deduped']=os.environ['DEDUPED']=='true'
|
|
print(json.dumps(o))"
|
|
}
|
|
|
|
# --- Dedupe lookup (erp#44): list this thirdparty's invoices of the same kind ---
|
|
SOCID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['socid'])" < "${TMPD}/dedupe.json")"
|
|
set +e
|
|
"${W}" GET "${ENDPOINT}?thirdparty_ids=${SOCID}&limit=500" \
|
|
> "${TMPD}/list.json" 2> "${TMPD}/list.err"
|
|
rc=$?
|
|
set -e
|
|
if [[ ${rc} -ne 0 ]]; then
|
|
if grep -q "HTTP 404" "${TMPD}/list.err"; then
|
|
printf '[]' > "${TMPD}/list.json" # empty lists can answer 404 (dolibarr skill gotcha)
|
|
else
|
|
cat "${TMPD}/list.err" >&2
|
|
echo "invoice-create.sh: could not list ${ENDPOINT} for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
cat > "${TMPD}/match.py" <<'PY'
|
|
import json, sys, html, datetime
|
|
ded = json.load(open(sys.argv[1]))
|
|
try:
|
|
rows = json.load(open(sys.argv[2]))
|
|
except Exception:
|
|
rows = []
|
|
rows = [r for r in (rows if isinstance(rows, list) else [])
|
|
if str(r.get("type", "0")) != "2"] # credit notes never dedupe a create
|
|
|
|
def norm_desc(s):
|
|
return " ".join(html.unescape(str(s or "")).split())
|
|
def fnum(v, nd):
|
|
try: return round(float(v), nd)
|
|
except Exception: return None
|
|
def fingerprint(lines):
|
|
return sorted((norm_desc(l[0]), fnum(l[1], 3), fnum(l[2], 2), fnum(l[3], 3))
|
|
for l in lines)
|
|
|
|
TOL = 0.02
|
|
want_fp = fingerprint(ded["lines"])
|
|
hits = []
|
|
if ded["supplier"] and ded["ref_supplier"]:
|
|
key = ded["ref_supplier"].strip()
|
|
hits = [r for r in rows if str(r.get("ref_supplier") or "").strip() == key]
|
|
if len(hits) == 1 and ded["lines"]:
|
|
got = fnum(hits[0].get("total_ttc"), 2)
|
|
if got is not None and abs(got - ded["total_ttc"]) > TOL:
|
|
sys.exit("invoice-create.sh: ABORT — supplier invoice with ref_supplier "
|
|
"%r already exists on socid %s (id %s) but its total_ttc %.2f "
|
|
"differs from the requested %.2f. Same key, different content "
|
|
"is a conflict to resolve, not a dedupe." %
|
|
(key, ded["socid"], hits[0].get("id"), got, ded["total_ttc"]))
|
|
else:
|
|
for r in rows:
|
|
try:
|
|
rdate = datetime.datetime.fromtimestamp(int(r.get("date"))).strftime("%Y-%m-%d")
|
|
except Exception:
|
|
continue
|
|
if rdate != ded["date"]:
|
|
continue
|
|
got = fnum(r.get("total_ttc"), 2)
|
|
if got is None or abs(got - ded["total_ttc"]) > TOL:
|
|
continue
|
|
rl = r.get("lines")
|
|
if not isinstance(rl, list):
|
|
sys.exit("invoice-create.sh: ABORT — candidate invoice id %s matches "
|
|
"(date, total) but carries no inline lines to fingerprint; "
|
|
"refusing to guess" % r.get("id"))
|
|
if fingerprint([[l.get("desc"), l.get("qty"), l.get("subprice"), l.get("tva_tx")]
|
|
for l in rl]) == want_fp:
|
|
hits.append(r)
|
|
if len(hits) > 1:
|
|
sys.exit("invoice-create.sh: ABORT — dedupe key matches %d invoices on socid %s "
|
|
"(ids %s): the target already holds duplicates; refusing to guess."
|
|
% (len(hits), ded["socid"], ", ".join(str(h.get("id")) for h in hits)))
|
|
if hits:
|
|
print(json.dumps({"id": int(hits[0]["id"]), "statut": str(hits[0].get("statut"))}))
|
|
PY
|
|
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/dedupe.json" "${TMPD}/list.json")"
|
|
|
|
if [[ -n "${MATCH}" ]]; then
|
|
ID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"${MATCH}")"
|
|
STATUT="$(python3 -c "import json,sys; print(json.load(sys.stdin)['statut'])" <<<"${MATCH}")"
|
|
echo "invoice-create.sh: invoice already exists on socid ${SOCID} (id ${ID}) — deduped, no POST" >&2
|
|
# Converge an interrupted run: validate the matched draft if validation was asked.
|
|
if [[ "${VALIDATE}" == "1" && "${STATUT}" == "0" ]]; then
|
|
echo "invoice-create.sh: matched invoice ${ID} is still a draft — validating it now (completes the interrupted op)" >&2
|
|
"${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
|
|
fi
|
|
emit_summary "${ID}" true
|
|
exit 0
|
|
fi
|
|
|
|
# --- Create (no match) ----------------------------------------------------------
|
|
# --- Chronology guard (CGI art. 289: numbering must be chronological) --------
|
|
# Dolibarr assigns the next number at validation, in creation order — so issuing
|
|
# a document dated BEFORE the last one already issued yields a higher number on
|
|
# an earlier date, which is a numbering break. This bites whenever two documents
|
|
# of the same cycle are issued on different days (a deferred part issued after
|
|
# the next fixed part, for instance). Refuse rather than create the break.
|
|
#
|
|
# CUSTOMER INVOICES ONLY. L'article 289 impose une numérotation chronologique et
|
|
# continue aux factures que la société ÉMET. Il ne dit rien de la référence de
|
|
# classement que Dolibarr attribue aux factures qu'elle REÇOIT : le numéro qui
|
|
# fait foi pour celles-là est celui du fournisseur, porté par ref_supplier, et la
|
|
# séquence FAF suit l'ordre d'ENREGISTREMENT — c'est sa construction normale.
|
|
#
|
|
# Appliquer la garde aux deux registres produisait un faux positif systématique :
|
|
# la production porte déjà cinq ruptures dans la séquence FAF (FAF2026003 daté du
|
|
# 4 janvier suit FAF2026002 daté du 9), et zéro dans la séquence FAC. Toute
|
|
# facture fournisseur enregistrée après coup — le cas ordinaire, cf. adc-008 qui
|
|
# prescrit d'enregistrer à la date du document — se heurtait au refus et exigeait
|
|
# ARCO_ALLOW_BACKDATE. Une garde qu'on outrepasse par routine ne garde plus rien.
|
|
if [[ "${ENDPOINT}" != "/invoices" ]]; then
|
|
CHRONO_SKIP=1
|
|
fi
|
|
NEW_DATE="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['date'])" "${BODY}")"
|
|
LAST="$("${W}" GET "${ENDPOINT}?sortfield=t.rowid&sortorder=DESC&limit=1" 2>/dev/null \
|
|
| python3 -c "
|
|
import json,sys
|
|
try:
|
|
d=json.load(sys.stdin)
|
|
if isinstance(d,list) and d: print(f\"{d[0].get('date','0')}|{d[0].get('ref','?')}\")
|
|
else: print('0|-')
|
|
except Exception: print('0|-')" 2>/dev/null || echo "0|-")"
|
|
LAST_DATE="${LAST%%|*}"; LAST_REF="${LAST##*|}"
|
|
if [[ -z "${CHRONO_SKIP:-}" ]] && [[ "${LAST_DATE}" =~ ^[0-9]+$ ]] && (( LAST_DATE > 0 )) && (( NEW_DATE < LAST_DATE )); then
|
|
if [[ "${ARCO_ALLOW_BACKDATE:-}" != "I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY" ]]; then
|
|
printf 'invoice-create.sh: REFUSED — chronology break.\n' >&2
|
|
printf ' new document dated %s, but %s is already issued at %s.\n' \
|
|
"$(date -r "${NEW_DATE}" +%d/%m/%Y 2>/dev/null || echo "${NEW_DATE}")" \
|
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null || echo "${LAST_DATE}")" >&2
|
|
printf ' Numbering follows creation order, so this would give a higher number to an\n' >&2
|
|
printf ' earlier date (CGI art. 289). Issue in chronological order, or set\n' >&2
|
|
printf ' ARCO_ALLOW_BACKDATE=I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY to override.\n' >&2
|
|
exit 1
|
|
fi
|
|
printf 'invoice-create.sh: WARNING — backdating past %s (%s), override accepted.\n' \
|
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null)" >&2
|
|
fi
|
|
|
|
ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
|
|
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
|
|
echo "invoice-create.sh: create did not return an id: ${ID}" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "${VALIDATE}" == "1" ]]; then
|
|
"${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
|
|
fi
|
|
emit_summary "${ID}" false
|