#!/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