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:
@@ -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"
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
# an existing fiche missing the requested role ABORTS; a miss creates.
|
||||
# 11. a deduped DRAFT with validate:true is validated on replay (converges an
|
||||
# op that died between create and validate).
|
||||
# GED attach (erp#43):
|
||||
# 12. document-attach.sh uploads with overwriteifexists=0 + read-back sha256;
|
||||
# an identical stored file dedupes (no POST); the same filename with
|
||||
# DIFFERENT content ABORTS (never an overwrite); unknown fields refused;
|
||||
# promote-apply resolves a manifest-relative "file" and promote-plan
|
||||
# prints its sha256.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PR="${SCRIPT_DIR}/../scripts/payment-record.sh"
|
||||
@@ -326,4 +332,83 @@ grep -q '/supplierinvoices/5/validate' "${S11}/validated_endpoint" \
|
||||
|| fail "draft-converge: the matched draft must be validated"
|
||||
echo "OK: draft convergence — replay validates the half-done invoice instead of duplicating it"
|
||||
|
||||
# --- Case 12: document-attach (erp#43) — upload, sha256 dedupe, conflict abort ---
|
||||
DA="${SCRIPT_DIR}/../scripts/document-attach.sh"
|
||||
bash -n "${DA}" || fail "bash -n document-attach.sh"
|
||||
S12="$(mktemp -d -t datest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}"' EXIT
|
||||
printf 'ged43 offline fixture' > "${S12}/src.pdf"
|
||||
SRC_SHA="$(python3 -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "${S12}/src.pdf")"
|
||||
SRC_B64="$(python3 -c "import base64,sys; print(base64.b64encode(open(sys.argv[1],'rb').read()).decode())" "${S12}/src.pdf")"
|
||||
printf '%s' '{"id":"29","ref":"FAF2026013","ref_supplier":"F1045","statut":"1"}' \
|
||||
> "${S12}/invoice_detail_29.json"
|
||||
# 12a — fresh attach: 404 listing → upload (overwriteifexists=0) → read-back sha
|
||||
OUT="$(printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12}/src.pdf" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S12}" bash "${DA}" 2>/dev/null)" \
|
||||
|| fail "attach-fresh: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['deduped'] is False and o['sha256'] == '${SRC_SHA}' and o['ref'] == 'FAF2026013' \
|
||||
and o['filename'] == 'src.pdf' and o['object_id'] == 29, o
|
||||
" || fail "attach-fresh: bad output: ${OUT}"
|
||||
python3 -c "
|
||||
import json
|
||||
b = json.load(open('${S12}/upload_body.json'))
|
||||
assert b['overwriteifexists'] == '0' and b['fileencoding'] == 'base64' \
|
||||
and b['ref'] == 'FAF2026013' and b['filecontent'] == '${SRC_B64}', b
|
||||
" || fail "attach-fresh: upload body must carry base64 content + overwriteifexists=0"
|
||||
# 12b — re-attach identical content (staged listing + download, NO upload state) → dedupe
|
||||
S12B="$(mktemp -d -t datest12b.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}" "${S12B}"' EXIT
|
||||
cp "${S12}/src.pdf" "${S12B}/src.pdf"; cp "${S12}/invoice_detail_29.json" "${S12B}/"
|
||||
printf '%s' '[{"name":null,"relativename":"src.pdf","type":"file","level1name":"FAF2026013",
|
||||
"fullname":"/var/www/documents/fournisseur/facture/0/3/FAF2026013/src.pdf","size":21}]' \
|
||||
> "${S12B}/documents.json"
|
||||
printf '{"filename":"src.pdf","content-type":"application/pdf","filesize":21,"content":"%s"}' "${SRC_B64}" \
|
||||
> "${S12B}/document_download.json"
|
||||
OUT="$(printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12B}/src.pdf" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S12B}" bash "${DA}" 2>/dev/null)" \
|
||||
|| fail "attach-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['deduped'] is True and o['sha256'] == '${SRC_SHA}', o
|
||||
" || fail "attach-dedupe: identical content must dedupe, got: ${OUT}"
|
||||
[[ ! -f "${S12B}/upload_body.json" ]] || fail "attach-dedupe: must NOT upload on a sha256 match"
|
||||
# 12c — same filename, DIFFERENT content → abort, no upload
|
||||
OTHER_B64="$(printf 'ged43 DIFFERENT bytes' | python3 -c "import base64,sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())")"
|
||||
printf '{"filename":"src.pdf","content-type":"application/pdf","filesize":21,"content":"%s"}' "${OTHER_B64}" \
|
||||
> "${S12B}/document_download.json"
|
||||
rc=0
|
||||
printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12B}/src.pdf" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S12B}" bash "${DA}" >/dev/null 2>"${S12B}/stderr12c" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "attach-conflict: same name + different content must abort"
|
||||
grep -q 'DIFFERENT content' "${S12B}/stderr12c" || fail "attach-conflict: error must say DIFFERENT content"
|
||||
[[ ! -f "${S12B}/upload_body.json" ]] || fail "attach-conflict: must NOT upload on a conflict"
|
||||
# 12d — unknown field refused before any request
|
||||
rc=0
|
||||
printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s","overwrite":true}' "${S12}/src.pdf" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S12}" bash "${DA}" >/dev/null 2>"${S12}/stderr12d" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "attach-unknown-field: must refuse unknown fields"
|
||||
grep -q 'overwrite' "${S12}/stderr12d" || fail "attach-unknown-field: error must name the offender"
|
||||
# 12e — promote-apply resolves a manifest-relative file; promote-plan prints its sha
|
||||
S12E="$(mktemp -d -t datest12e.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}" "${S12B}" "${S12E}"' EXIT
|
||||
mkdir -p "${S12E}/pack/pdfs"
|
||||
cp "${S12}/src.pdf" "${S12E}/pack/pdfs/src.pdf"
|
||||
printf '%s' '{"id":"29","ref":"FAF2026013","ref_supplier":"F1045","statut":"1"}' \
|
||||
> "${S12E}/invoice_detail_29.json"
|
||||
cat > "${S12E}/pack/manifest.json" <<'JSON'
|
||||
[ { "op": "attach",
|
||||
"input": { "modulepart": "facture_fournisseur", "object_id": 29,
|
||||
"file": "pdfs/src.pdf" } } ]
|
||||
JSON
|
||||
( cd "${S12E}" \
|
||||
&& bash "${PP}" pack/manifest.json > plan.out 2>&1 \
|
||||
&& DOL_WRITE="${STUB}" STUB_STATE="${S12E}" bash "${PA}" pack/manifest.json --target sandbox > apply.out 2>&1 ) \
|
||||
|| fail "attach-promote: plan/apply failed: $(cat "${S12E}/plan.out" "${S12E}/apply.out" 2>/dev/null)"
|
||||
grep -q "sha256=${SRC_SHA}" "${S12E}/plan.out" || fail "attach-promote: plan must print the file sha256"
|
||||
grep -q 'attach' "${S12E}/apply.out" || fail "attach-promote: apply must run the attach op"
|
||||
grep -q '(1 created)' "${S12E}/apply.out" || fail "attach-promote: summary must say (1 created)"
|
||||
[[ -f "${S12E}/upload_body.json" ]] || fail "attach-promote: the manifest-relative file must reach the upload"
|
||||
echo "OK: document-attach — upload + read-back, sha256 dedupe, conflict abort, field refusal, manifest-relative file"
|
||||
|
||||
echo "OK: all offline tests passed"
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
set -euo pipefail
|
||||
STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}"
|
||||
METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}"
|
||||
# dol-write.sh accepts @file bodies (document-attach.sh uses one for the base64
|
||||
# payload) — dereference it here so the recorded body is the JSON, not "@/path".
|
||||
[[ "${BODY}" == @* ]] && BODY="$(cat "${BODY:1}")"
|
||||
case "${METHOD} ${ENDPOINT}" in
|
||||
GET\ *"/payments"*)
|
||||
if [[ -f "${STATE}/payments.json" ]]; then
|
||||
@@ -120,6 +123,55 @@ json.dump(rows, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
echo "88"
|
||||
;;
|
||||
"GET /documents/download?"*)
|
||||
# Serve document_download.json when staged; else (post-upload read-back)
|
||||
# synthesize from upload_body.json; else the live 404.
|
||||
if [[ -f "${STATE}/document_download.json" ]]; then
|
||||
cat "${STATE}/document_download.json"
|
||||
elif [[ -f "${STATE}/upload_body.json" ]]; then
|
||||
python3 - "${STATE}/upload_body.json" <<'PY'
|
||||
import base64, json, sys
|
||||
b = json.load(open(sys.argv[1]))
|
||||
print(json.dumps({"filename": b.get("filename"), "content-type": "application/pdf",
|
||||
"filesize": len(base64.b64decode(b.get("filecontent", ""))),
|
||||
"content": b.get("filecontent", "")}))
|
||||
PY
|
||||
else
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"GET /documents?"*)
|
||||
# Serve documents.json when staged; else (post-upload read-back) a listing
|
||||
# built from upload_body.json — with the supplier-invoice get_exdir prefix
|
||||
# (0/3/REF/…) so the relative-path derivation is exercised; else the live
|
||||
# Dolibarr behavior: an object with no documents answers HTTP 404, not [].
|
||||
if [[ -f "${STATE}/documents.json" ]]; then
|
||||
cat "${STATE}/documents.json"
|
||||
elif [[ -f "${STATE}/upload_body.json" ]]; then
|
||||
python3 - "${STATE}/upload_body.json" <<'PY'
|
||||
import base64, json, sys
|
||||
b = json.load(open(sys.argv[1]))
|
||||
ref, fn = b.get("ref", "REF"), b.get("filename", "file.pdf")
|
||||
print(json.dumps([{"name": None, "relativename": fn, "type": "file",
|
||||
"level1name": ref,
|
||||
"fullname": "/var/www/documents/fournisseur/facture/0/3/%s/%s" % (ref, fn),
|
||||
"size": len(base64.b64decode(b.get("filecontent", "")))}]))
|
||||
PY
|
||||
else
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found: no document"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"POST /documents/upload")
|
||||
printf '%s' "${BODY}" > "${STATE}/upload_body.json"
|
||||
python3 - "${STATE}/upload_body.json" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps(json.load(open(sys.argv[1])).get("filename")))
|
||||
PY
|
||||
;;
|
||||
POST\ *"/validate")
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/validated_endpoint"
|
||||
echo '{"success":1}'
|
||||
|
||||
Reference in New Issue
Block a user