feat(write-skill): GED attach op — upload the source document onto its invoice (erp#43)

document-attach.sh uploads a source piece (the supplier's own PDF) onto an
invoice's GED via POST /documents/upload — idempotent by (object, filename,
sha256): before any POST the object's GED is listed and a same-named entry is
downloaded back and sha256-compared. Identical → deduped no-op; different
content → ABORT (refuse-never-repair, overwriteifexists always 0, never
Dolibarr's overwrite flag). Read-back after upload: re-list + download +
sha256-verify. Module-relative download paths are derived from the listing's
fullname (supplier invoices carry an id-derived get_exdir prefix like
9/2/FAF2026013/…, so reconstruction would be wrong).

Promote integration: new `attach` op in promote-plan/promote-apply (OP_SCRIPT),
object_id resolvable via @ref and #supplierinvoice lookups; a relative `file`
resolves against the manifest's directory (replay packs carry pdfs/ beside the
manifest, gitignored — README documents the books@ re-fetch message ids).
promote-plan prints each file's sha256 (or a loud MISSING) at review time.
CLI: `arcodange sandbox attach`.

Proof: offline case 12 in tests/run-tests.sh (upload body, dedupe, conflict
abort, field refusal, manifest-relative resolution via stubbed /documents);
live: manifest-C-ged-attach.json applied twice on the sandbox — run 1 four
created, run 2 four deduped, one GED file per FAF2026010-013, stored sha256s
equal to the re-fetched sources; tests/replay-idempotency.sh extended with an
attach op (4 created → 4 deduped, ged_files count unchanged) and a live
same-name/different-bytes abort verified.

Closes erp#43

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
2026-07-19 00:11:59 +02:00
co-authored by Claude Fable 5
parent e54b2f0f5d
commit fb13bdcc4f
11 changed files with 587 additions and 31 deletions
@@ -8,10 +8,12 @@
# What it does (writes go ONLY through the host-guarded dol-write.sh):
# 1. builds a small self-contained manifest with a unique-per-run fixture:
# one supplier thirdparty + one validated supplier invoice (Qonto-style
# transaction id, so the erp#37 normalization is exercised too) + one payment
# 2. applies it → expects 3 created, no dedupe
# 3. applies it AGAIN → expects 3 deduped, zero new rows (verified by
# row-counting thirdparties / invoices / payments via the API)
# transaction id, so the erp#37 normalization is exercised too) + one
# payment + one GED attach (erp#43: object_id via @ref, file path relative
# to the manifest, idempotent by sha256)
# 2. applies it → expects 4 created, no dedupe
# 3. applies it AGAIN → expects 4 deduped, zero new rows (verified by
# row-counting thirdparties / invoices / payments / GED files via the API)
#
# Sandbox etiquette: the fixture rows stay behind (the sandbox is disposable;
# a checkpoint refresh reclaims them). Run from anywhere:
@@ -45,9 +47,13 @@ cat > "${EV}/manifest.json" <<JSON
"input": { "invoice_id": "@inv", "kind": "supplier", "mode": "VIR",
"account_id": 1, "date": "2026-07-02", "amount": 120.00,
"transaction_id": "${TX}",
"comment": "idem44 replay-idempotency test" } }
"comment": "idem44 replay-idempotency test" } },
{ "op": "attach",
"input": { "modulepart": "facture_fournisseur", "object_id": "@inv",
"file": "attach-fixture.pdf" } }
]
JSON
printf '%%PDF-1.4 idem-replay attach fixture %s\n' "${STAMP}" > "${EV}/attach-fixture.pdf"
count_rows() { # $1 = path, counts a JSON array (Dolibarr 404-on-empty => 0)
local out
@@ -61,37 +67,45 @@ FLT="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(\"(t.nom:=:
tp_count() { count_rows "/thirdparties?limit=100&sqlfilters=${FLT}"; }
inv_count() { count_rows "/supplierinvoices?thirdparty_ids=$1&limit=500"; }
pay_count() { count_rows "/supplierinvoices/$1/payments"; }
doc_count() { # GED files on a supplier invoice (404 = none yet => 0)
local out
if out="$("${W}" GET "/documents?modulepart=facture_fournisseur&id=$1" 2>/dev/null)"; then
python3 -c "import json,sys; r=json.load(sys.stdin); print(len([x for x in r if x.get('type')=='file']) if isinstance(r,list) else 0)" <<<"${out}"
else
echo 0
fi
}
echo "== erp#44 replay-idempotency — fixture ${STAMP} (evidence: ${EV}) =="
[[ "$(tp_count)" == "0" ]] || fail "fixture name already exists on the sandbox (clock collision?)"
echo; echo "-- RUN 1: expect 3 created ------------------------------------------------"
echo; echo "-- RUN 1: expect 4 created ------------------------------------------------"
"${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
| tee "${EV}/run1.out"
grep -q 'deduped' "${EV}/run1.out" && fail "run 1: nothing may dedupe on a fresh fixture"
[[ "$(grep -c ' created' "${EV}/run1.out")" == "3" ]] || fail "run 1: expected 3 created ops"
grep -q '(3 created)' "${EV}/run1.out" || fail "run 1: summary must say (3 created)"
[[ "$(grep -c ' created' "${EV}/run1.out")" == "4" ]] || fail "run 1: expected 4 created ops"
grep -q '(4 created)' "${EV}/run1.out" || fail "run 1: summary must say (4 created)"
TPID="$(python3 -c "import json,sys,re
m=re.search(r'ref -> id: (\{.*\})', open(sys.argv[1]).read()); print(json.loads(m.group(1))['tp'])" "${EV}/run1.out")"
INVID="$(python3 -c "import json,sys,re
m=re.search(r'ref -> id: (\{.*\})', open(sys.argv[1]).read()); print(json.loads(m.group(1))['inv'])" "${EV}/run1.out")"
TP1="$(tp_count)"; INV1="$(inv_count "${TPID}")"; PAY1="$(pay_count "${INVID}")"
echo "row counts after run 1: thirdparties=${TP1} invoices=${INV1} payments=${PAY1}" | tee "${EV}/counts-run1.txt"
[[ "${TP1}" == "1" && "${INV1}" == "1" && "${PAY1}" == "1" ]] || fail "run 1 must have created exactly 1 of each"
TP1="$(tp_count)"; INV1="$(inv_count "${TPID}")"; PAY1="$(pay_count "${INVID}")"; DOC1="$(doc_count "${INVID}")"
echo "row counts after run 1: thirdparties=${TP1} invoices=${INV1} payments=${PAY1} ged_files=${DOC1}" | tee "${EV}/counts-run1.txt"
[[ "${TP1}" == "1" && "${INV1}" == "1" && "${PAY1}" == "1" && "${DOC1}" == "1" ]] || fail "run 1 must have created exactly 1 of each"
echo; echo "-- RUN 2 (same manifest): expect 3 deduped, zero new rows -----------------"
echo; echo "-- RUN 2 (same manifest): expect 4 deduped, zero new rows -----------------"
"${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
| tee "${EV}/run2.out"
[[ "$(grep -c 'deduped=true' "${EV}/run2.out")" == "3" ]] || fail "run 2: all 3 ops must dedupe"
[[ "$(grep -c 'deduped=true' "${EV}/run2.out")" == "4" ]] || fail "run 2: all 4 ops must dedupe"
grep -q ' created' "${EV}/run2.out" && fail "run 2: nothing may be created on a replay"
grep -q '(3 deduped)' "${EV}/run2.out" || fail "run 2: summary must say (3 deduped)"
grep -q '(4 deduped)' "${EV}/run2.out" || fail "run 2: summary must say (4 deduped)"
TP2="$(tp_count)"; INV2="$(inv_count "${TPID}")"; PAY2="$(pay_count "${INVID}")"
echo "row counts after run 2: thirdparties=${TP2} invoices=${INV2} payments=${PAY2}" | tee "${EV}/counts-run2.txt"
[[ "${TP2}" == "${TP1}" && "${INV2}" == "${INV1}" && "${PAY2}" == "${PAY1}" ]] \
|| fail "run 2 must add ZERO rows (run1: ${TP1}/${INV1}/${PAY1}, run2: ${TP2}/${INV2}/${PAY2})"
TP2="$(tp_count)"; INV2="$(inv_count "${TPID}")"; PAY2="$(pay_count "${INVID}")"; DOC2="$(doc_count "${INVID}")"
echo "row counts after run 2: thirdparties=${TP2} invoices=${INV2} payments=${PAY2} ged_files=${DOC2}" | tee "${EV}/counts-run2.txt"
[[ "${TP2}" == "${TP1}" && "${INV2}" == "${INV1}" && "${PAY2}" == "${PAY1}" && "${DOC2}" == "${DOC1}" ]] \
|| fail "run 2 must add ZERO rows (run1: ${TP1}/${INV1}/${PAY1}/${DOC1}, run2: ${TP2}/${INV2}/${PAY2}/${DOC2})"
# The payment's stored num must be the erp#37 canonical short form.
"${W}" GET "/supplierinvoices/${INVID}/payments" > "${EV}/payments.json"
@@ -99,5 +113,5 @@ grep -q "\"${TX_SHORT}\"" "${EV}/payments.json" \
|| fail "stored num must be the normalized short form ${TX_SHORT}"
echo
echo "PASS: replay is a no-op — run 1 created 3 rows (tp=${TPID}, inv=${INVID}), run 2 deduped all 3, row counts unchanged (${TP2}/${INV2}/${PAY2})."
echo "Evidence in ${EV}: manifest.json run1.out run2.out counts-run*.txt payments.json"
echo "PASS: replay is a no-op — run 1 created 4 rows (tp=${TPID}, inv=${INVID}), run 2 deduped all 4, row counts unchanged (${TP2}/${INV2}/${PAY2}/${DOC2})."
echo "Evidence in ${EV}: manifest.json attach-fixture.pdf run1.out run2.out counts-run*.txt payments.json"