Files
erp/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh
T

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