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

Merged
arcodange merged 1 commits from arcodange/ged-attach into main 2026-07-19 00:13:29 +02:00
11 changed files with 587 additions and 31 deletions
Showing only changes of commit fb13bdcc4f - Show all commits
+49 -6
View File
@@ -12,13 +12,17 @@ description: >-
(thirdparty by exact name, supplier invoice by socid+ref_supplier, customer (thirdparty by exact name, supplier invoice by socid+ref_supplier, customer
invoice by socid+date+total+line fingerprint, payment by invoice+amount+ invoice by socid+date+total+line fingerprint, payment by invoice+amount+
normalized transaction id; credit notes excepted, a follow-up), so replaying normalized transaction id; credit notes excepted, a follow-up), so replaying
a manifest — even one that failed mid-run — is a no-op, never a duplicate. Every write goes through dol-write.sh, a manifest — even one that failed mid-run — is a no-op, never a duplicate.
Also attaches source documents (the supplier's PDF) onto an invoice's GED
(erp#43): upload via the Documents API, idempotent by (object, filename,
sha256) — re-attach is a no-op, same name + different content aborts. Every write goes through dol-write.sh,
which REFUSES any host that is not the sandbox — the structural guarantee which REFUSES any host that is not the sandbox — the structural guarantee
(ADR-0003) that this skill can never mutate production. (ADR-0003) that this skill can never mutate production.
Use when the user asks to "create a thirdparty / supplier / client fiche", Use when the user asks to "create a thirdparty / supplier / client fiche",
"compléter / mettre à jour la fiche client", "add a contact to a thirdparty", "compléter / mettre à jour la fiche client", "add a contact to a thirdparty",
"saisir une facture", "record an invoice with lines", "enregistrer un règlement / "saisir une facture", "record an invoice with lines", "enregistrer un règlement /
paiement", or to rehearse a write before promoting it to prod. SKIP for production writes paiement", "attacher la pièce / le justificatif / le PDF à la facture",
or to rehearse a write before promoting it to prod. SKIP for production writes
(prod stays read-only via the `dolibarr` skill's `ai_agent` key; promotion is a (prod stays read-only via the `dolibarr` skill's `ai_agent` key; promotion is a
separate, human-gated replay). Depends on the write-scoped `ai_agent_sandbox` separate, human-gated replay). Depends on the write-scoped `ai_agent_sandbox`
Dolibarr user + its API key. Dolibarr user + its API key.
@@ -232,6 +236,31 @@ exits 0 without writing. Otherwise POST `/contacts` → `{"id": <new>,
`phone_perso`, `address`, `zip`, `town`, `country_id`, `note_public`, `phone_perso`, `address`, `zip`, `town`, `country_id`, `note_public`,
`note_private`. Unknown fields are refused, never dropped. `note_private`. Unknown fields are refused, never dropped.
### 7 · Attach a source document (GED) — `scripts/document-attach.sh`
```sh
echo '{"modulepart":"facture_fournisseur","ref":"FAF2026013",
"file":"pdfs/F1045_ARCODANGE_2026-06-30.pdf"}' | scripts/document-attach.sh
echo '{"modulepart":"facture","object_id":19,"file":"/abs/path/piece.pdf",
"filename":"stored-name.pdf"}' | scripts/document-attach.sh
```
Uploads the **source piece** (the supplier's own PDF, a contract…) onto an
invoice's GED directory via `POST /documents/upload` (base64 content,
`overwriteifexists` always 0). `modulepart`: `facture`/`invoice` (customer) or
`facture_fournisseur`/`invoice_supplier`/`supplier_invoice` (supplier). Address
the invoice by `object_id` or by Dolibarr `ref` (both = cross-checked; a
ref-only lookup matching 0 or 2+ aborts). `filename` defaults to the file's
basename. Emits `{object_id, ref, modulepart, filename, sha256, size, deduped}`.
**Idempotent by (object, filename, sha256) — our own check, never Dolibarr's
overwrite flag.** Before any POST the object's GED is listed
(`GET /documents?modulepart=…&id=…`, where 404 on a proven-existing object means
"no documents yet") and a same-named entry is **downloaded back and
sha256-compared**: identical → `{"deduped": true}`, no upload; **different
content ABORTS** — silently replacing a stored piece would rewrite evidence
(refuse-never-repair). After an upload the file is re-listed, downloaded back
and sha256-verified (read-back proof the GED holds exactly the bytes sent).
## Promote to prod (rehearse → review → replay) ## Promote to prod (rehearse → review → replay)
The ADR-0003 capstone: take a change rehearsed in the sandbox and apply the **same The ADR-0003 capstone: take a change rehearsed in the sandbox and apply the **same
@@ -240,7 +269,11 @@ array of write ops using **symbolic refs** (`@name`) instead of ids, so it is
portable from sandbox to prod (an invoice references `@tp1`, the thirdparty created portable from sandbox to prod (an invoice references `@tp1`, the thirdparty created
earlier in the run). See `examples/promote-manifest.json`. Op kinds: `thirdparty`, earlier in the run). See `examples/promote-manifest.json`. Op kinds: `thirdparty`,
`thirdparty_update` (input: `socid` + `fields`), `contact`, `invoice`, `creditnote`, `thirdparty_update` (input: `socid` + `fields`), `contact`, `invoice`, `creditnote`,
`payment` — each mapping to its workflow script above. `payment`, `attach` — each mapping to its workflow script above. An `attach` op's
relative `file` path resolves against the **manifest's directory** (the manifest
is the portable unit: a replay pack carries its `pdfs/` beside it), and
`promote-plan` prints the file's sha256 — or a loud MISSING warning — at review
time so the content is pinned before any apply.
```sh ```sh
scripts/promote-plan.sh change.json # 1. human-readable review scripts/promote-plan.sh change.json # 1. human-readable review
@@ -266,7 +299,8 @@ ops answer `deduped=true`, the rest execute. `promote-apply` marks each op
(`OK — promote complete (1 created, 2 deduped)`), so an all-`deduped` second run (`OK — promote complete (1 created, 2 deduped)`), so an all-`deduped` second run
is visible proof of a no-op. Live acceptance: `tests/replay-idempotency.sh` is visible proof of a no-op. Live acceptance: `tests/replay-idempotency.sh`
double-applies a self-contained manifest (thirdparty + supplier invoice + double-applies a self-contained manifest (thirdparty + supplier invoice +
payment) on the sandbox and asserts run 2 dedupes all ops with zero new rows. payment + GED attach) on the sandbox and asserts run 2 dedupes all ops with
zero new rows and zero new GED files.
A manifest value can reference another entity two ways, both resolved against the A manifest value can reference another entity two ways, both resolved against the
**target** so the same file is portable sandbox↔prod: **target** so the same file is portable sandbox↔prod:
@@ -313,13 +347,22 @@ sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`.
(ambiguous or role-mismatch aborts), supplier invoice by (socid, ref_supplier) (ambiguous or role-mismatch aborts), supplier invoice by (socid, ref_supplier)
(total mismatch aborts), customer invoice by (socid, date, total, line (total mismatch aborts), customer invoice by (socid, date, total, line
fingerprint), payment by (invoice, amount, normalized tx id), contact by fingerprint), payment by (invoice, amount, normalized tx id), contact by
(socid, email) then (socid, lastname+firstname) — each answering (socid, email) then (socid, lastname+firstname), GED attach by (object,
filename, sha256) — each answering
`{"deduped": true}` instead of minting a duplicate, so replaying a manifest is `{"deduped": true}` instead of minting a duplicate, so replaying a manifest is
always safe. Two holes: a **payment without a `transaction_id`** has no always safe. Two holes: a **payment without a `transaction_id`** has no
dedupe key and WILL double-pay on a replay, and **`creditnote-create.sh` does dedupe key and WILL double-pay on a replay, and **`creditnote-create.sh` does
not dedupe yet** (supplier-avoir parity follow-up) — do not replay a manifest not dedupe yet** (supplier-avoir parity follow-up) — do not replay a manifest
containing a creditnote op past a mid-run failure. Offline proof: containing a creditnote op past a mid-run failure. Offline proof:
`tests/run-tests.sh`; live double-apply proof: `tests/replay-idempotency.sh`. `tests/run-tests.sh`; live double-apply proof: `tests/replay-idempotency.sh`.
- **GED attach paths carry a get_exdir prefix for supplier invoices.** A
supplier-invoice document lives at `fournisseur/facture/<x>/<y>/<REF>/<file>`
(the `<x>/<y>` split derives from the object id), so `/documents/download`
needs `0/3/FAF2026014/file.pdf`, not `FAF2026014/file.pdf`. `document-attach.sh`
derives the module-relative path from the listing's `fullname` (substring
after the last `/facture/`) — never reconstructs it. Customer invoices have
no prefix (`facture/<REF>/<file>`). Upload answers the bare filename as a
JSON string; the script verifies it and then read-back-verifies the sha256.
- **A dedupe lookup that fails (non-404) aborts the op** — the scripts refuse to - **A dedupe lookup that fails (non-404) aborts the op** — the scripts refuse to
POST blind, because assuming "no match" on a 403/timeout is precisely how POST blind, because assuming "no match" on a 403/timeout is precisely how
duplicates get minted (cf. the `voir_tous` ACL trap in the `dolibarr` skill: duplicates get minted (cf. the `voir_tous` ACL trap in the `dolibarr` skill:
@@ -328,5 +371,5 @@ sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`.
`soc2` (seen in WIP operator payloads) is not a Dolibarr field and the API `soc2` (seen in WIP operator payloads) is not a Dolibarr field and the API
would drop it silently — `contact-create.sh` refuses it with a pointer to would drop it silently — `contact-create.sh` refuses it with a pointer to
`poste`. `poste`.
- **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}` - **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|attach|write}`
(JSON on stdin) — `arcodange sandbox help` for the list. (JSON on stdin) — `arcodange sandbox help` for the list.
@@ -16,6 +16,7 @@ Remaining: bucket C non-invoice UI entries (erp#57).
| `manifest-A-km-payments.json` | 2 KissMetrics customer payments (Wise wires 2 147,00 € + 2 195,97 €, tx ids from the activity feed) | | `manifest-A-km-payments.json` | 2 KissMetrics customer payments (Wise wires 2 147,00 € + 2 195,97 €, tx ids from the activity feed) |
| `manifest-B-suppliers.json` | DARNIS F1045 supplier invoice (214,70 HT / 257,64 TTC) + its payment + Anthropic/Mistral payment ops | | `manifest-B-suppliers.json` | DARNIS F1045 supplier invoice (214,70 HT / 257,64 TTC) + its payment + Anthropic/Mistral payment ops |
| `manifest-B2-sandbox-payments.json` | Sandbox-side payment re-run after the varchar(50) fix (erp#37) | | `manifest-B2-sandbox-payments.json` | Sandbox-side payment re-run after the varchar(50) fix (erp#37) |
| `manifest-C-ged-attach.json` | erp#43 — attach the four source PDFs onto the FAF supplier invoices (GED). `#supplierinvoice:ref_supplier=` lookups + `pdfs/`-relative paths, so the same file replays sandbox↔prod once `pdfs/` is populated (see below). Rehearsed green on the sandbox 2026-07-19: run 1 = 4 created, run 2 = 4 deduped (sha256 no-op). |
| `prod-replay-prelude.sh` | Pre-replay guards: fresh checkpoint assumptions, target checks, env pinning | | `prod-replay-prelude.sh` | Pre-replay guards: fresh checkpoint assumptions, target checks, env pinning |
| `rehearsal-runbook.md` | Step-by-step of the rehearsal: what ran, in what order, with which gates | | `rehearsal-runbook.md` | Step-by-step of the rehearsal: what ran, in what order, with which gates |
| `verify-provenance.py` | **The anti-hallucination PoC** — 36 field-level checks: every critical value re-verified against source-PDF text (pdftotext) + FRESH Qonto/Wise pulls; locale-normalized (`219,50``219.50`, Wise `2,147` thousands format). 36/36 green at rehearsal time. | | `verify-provenance.py` | **The anti-hallucination PoC** — 36 field-level checks: every critical value re-verified against source-PDF text (pdftotext) + FRESH Qonto/Wise pulls; locale-normalized (`219,50``219.50`, Wise `2,147` thousands format). 36/36 green at rehearsal time. |
@@ -25,15 +26,22 @@ Remaining: bucket C non-invoice UI entries (erp#57).
`verify-provenance.py` expects `./pdfs/` containing: `verify-provenance.py` expects `./pdfs/` containing:
`F1045_ARCODANGE_2026-06-30.pdf`, `F1046_ARCODANGE_2026-06-29.pdf`, `F1045_ARCODANGE_2026-06-30.pdf`, `F1046_ARCODANGE_2026-06-29.pdf`,
`Invoice-9BF0758D-695749.pdf` (Anthropic), `invoice-MSTRL-API-814045-001.pdf` (Mistral). `Invoice-9BF0758D-695749.pdf` (Anthropic), `invoice-MSTRL-API-814045-001.pdf` (Mistral).
Re-fetch via the `arcodange-email-ingest` skill (they live in `books@`workflow 2 downloads Re-fetch via the `arcodange-email-ingest` skill (workflow 2 downloads attachments by
attachments by message id) or from the GED once erp#43 attaches them. message id) or from the GED once erp#43's attach replays on prod. Message ids (Zoho,
verified 2026-07-19 — sha256 matched the golden-set sidecars): F1045 →
`1780782481239014300` (`/Notification`), F1046 → `1782725019202004300` (`/Notification`),
Anthropic Invoice-9BF0758D-695749 → `1776017238960014300` (`/Inbox/books`), Mistral →
`1775141901205014300` (`/Inbox/books`). `pdfs/` is gitignored — populate it, then
`promote plan` prints each file's sha256 before any apply.
## Who consumes this pack ## Who consumes this pack
- **erp#41** (provenance checker as a promote-plan stage) — industrializes `verify-provenance.py`. - **erp#41** (provenance checker as a promote-plan stage) — industrializes `verify-provenance.py`.
- **erp#42** (compliance linter) — these manifests are the *legitimate* fixtures that must PASS. - **erp#42** (compliance linter) — these manifests are the *legitimate* fixtures that must PASS.
- **erp#44** (idempotency keys) — replay these manifests twice on a checkpoint; run 2 must be all-deduped. - **erp#44** (idempotency keys) — replay these manifests twice on a checkpoint; run 2 must be all-deduped.
- **erp#43** (GED attach) — attaches the four PDFs to FAF2026010013 on the sandbox. - **erp#43** (GED attach) — `manifest-C-ged-attach.json` attaches the four PDFs to
FAF2026010013. Sandbox rehearsal done (attach + re-attach no-op proven); the prod
replay of manifest C is the remaining human-gated step.
## Rule this pack proves ## Rule this pack proves
@@ -0,0 +1,34 @@
[
{
"op": "attach",
"input": {
"modulepart": "facture_fournisseur",
"object_id": "#supplierinvoice:ref_supplier=F1045",
"file": "pdfs/F1045_ARCODANGE_2026-06-30.pdf"
}
},
{
"op": "attach",
"input": {
"modulepart": "facture_fournisseur",
"object_id": "#supplierinvoice:ref_supplier=F1046",
"file": "pdfs/F1046_ARCODANGE_2026-06-29.pdf"
}
},
{
"op": "attach",
"input": {
"modulepart": "facture_fournisseur",
"object_id": "#supplierinvoice:ref_supplier=9BF0758D-695749",
"file": "pdfs/Invoice-9BF0758D-695749.pdf"
}
},
{
"op": "attach",
"input": {
"modulepart": "facture_fournisseur",
"object_id": "#supplierinvoice:ref_supplier=MSTRL-API-814045-001",
"file": "pdfs/invoice-MSTRL-API-814045-001.pdf"
}
}
]
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
# Attach a source document onto a Dolibarr invoice in the SANDBOX (GED upload)
# — IDEMPOTENT by (object, filename, sha256): re-attaching the same file is a
# no-op, and the same filename with DIFFERENT content ABORTS (a conflict for a
# human, never an overwrite). erp#43.
#
# Input: a JSON object on stdin (or a file path / inline JSON in $1):
# modulepart (required) "facture"|"invoice" → customer invoice GED
# "facture_fournisseur"|"invoice_supplier"|
# "supplier_invoice" → supplier invoice GED
# object_id invoice id on the target (one of object_id / ref
# ref Dolibarr's own ref, e.g. FAF2026013 required; both given =
# cross-checked)
# file (required) path to the file to attach. Resolved against the CWD;
# promote-apply resolves manifest-relative paths BEFORE invoking.
# filename stored name in the GED (default: basename of file)
#
# Idempotency (erp#43, the erp#44 pattern): BEFORE any POST the object's GED
# directory is listed (GET /documents) and a same-named entry — if any — is
# DOWNLOADED BACK and sha256-compared against the local file:
# - same sha256 → {"deduped": true}, no upload (re-attach = no-op);
# - different sha → ABORT. The idempotency check is OUR OWN sha256 compare,
# never Dolibarr's overwriteifexists flag (whose semantics vary by version):
# silently replacing a stored piece would rewrite evidence — refuse, never
# repair. overwriteifexists is always sent as 0.
# - a listing/download failure other than "no documents yet" (HTTP 404 on an
# object we just proved exists) ABORTS — uploading blind could duplicate.
# After an upload the GED is re-listed and the file downloaded back and
# sha256-verified: read-back proof the GED holds exactly the bytes we sent.
#
# Path gotcha: supplier-invoice documents live under an id-derived get_exdir
# prefix (e.g. fournisseur/facture/0/3/FAF2026014/…), so /documents/download
# needs "0/3/REF/file.pdf", not "REF/file.pdf". The module-relative path is
# derived from the listing's fullname (substring after the last "/facture/").
#
# Emits {"object_id", "ref", "modulepart", "filename", "sha256", "size",
# "deduped"} on stdout. All requests go through dol-write.sh (or $DOL_WRITE),
# which refuses any host that is not the sandbox.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)"
elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")"
else INPUT="${SRC}"; fi # inline JSON
TMPD="$(mktemp -d -t docatt.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
# --- 1. Validate + normalize the input (before ANY request) --------------------
cat > "${TMPD}/validate.py" <<'PY'
import json, os, sys
ALLOWED = ["modulepart", "object_id", "ref", "file", "filename"]
MP = {"facture": "facture", "invoice": "facture",
"facture_fournisseur": "facture_fournisseur",
"invoice_supplier": "facture_fournisseur",
"supplier_invoice": "facture_fournisseur"}
try:
d = json.loads(sys.stdin.read() or "null")
except Exception as e:
sys.exit("document-attach.sh: input is not valid JSON: %s" % e)
if not isinstance(d, dict):
sys.exit("document-attach.sh: input must be a JSON object")
bad = sorted(k for k in d if k not in ALLOWED)
if bad:
sys.exit("document-attach.sh: REFUSED — unknown field(s): %s\n allowed: %s"
% (", ".join(bad), ", ".join(ALLOWED)))
mp_raw = str(d.get("modulepart", "")).strip().lower()
if mp_raw not in MP:
sys.exit("document-attach.sh: 'modulepart' must be one of %s (got %r) — this "
"op attaches onto customer or supplier invoices only"
% ("/".join(sorted(set(MP))), mp_raw))
mp = MP[mp_raw]
oid = d.get("object_id")
ref = str(d.get("ref") or "").strip()
if oid in (None, "") and not ref:
sys.exit("document-attach.sh: one of 'object_id' / 'ref' is required")
if oid not in (None, "") and not str(oid).isdigit():
sys.exit("document-attach.sh: 'object_id' must be numeric, got %r" % oid)
path = str(d.get("file") or "")
if not path:
sys.exit("document-attach.sh: 'file' is required")
if not os.path.isfile(path):
sys.exit("document-attach.sh: file not found: %s\n (paths resolve against "
"the CWD; promote-apply resolves manifest-relative paths first)" % path)
filename = str(d.get("filename") or "").strip() or os.path.basename(path)
if "/" in filename or filename in (".", ".."):
sys.exit("document-attach.sh: 'filename' must be a bare file name, got %r" % filename)
print(mp)
print("/invoices" if mp == "facture" else "/supplierinvoices")
print(str(oid) if oid not in (None, "") else "")
print(ref)
print(path)
print(filename)
PY
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py")"
MP="$(sed -n 1p <<<"${MAPPED}")"
ENDPOINT="$(sed -n 2p <<<"${MAPPED}")"
OID="$(sed -n 3p <<<"${MAPPED}")"
REF_IN="$(sed -n 4p <<<"${MAPPED}")"
FILE="$(sed -n 5p <<<"${MAPPED}")"
FILENAME="$(sed -n 6p <<<"${MAPPED}")"
# Local sha256 + base64 (binary-safe, portable — no macOS/Linux base64 flag
# drift; the base64 goes to a file, never through argv, so size doesn't matter).
LOCAL_SHA="$(python3 - "${FILE}" "${TMPD}/b64.txt" <<'PY'
import base64, hashlib, sys
data = open(sys.argv[1], "rb").read()
open(sys.argv[2], "w").write(base64.b64encode(data).decode())
print(hashlib.sha256(data).hexdigest())
PY
)"
urlenc() { python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"; }
# --- 2. Resolve the object on the target (id ⇄ ref) ----------------------------
if [[ -n "${OID}" ]]; then
if ! "${W}" GET "${ENDPOINT}/${OID}" > "${TMPD}/obj.json" 2> "${TMPD}/obj.err"; then
cat "${TMPD}/obj.err" >&2
echo "document-attach.sh: object ${ENDPOINT}/${OID} not readable on the target — refusing to upload blind" >&2
exit 1
fi
REF="$(python3 -c "import json,sys; print(json.load(sys.stdin).get('ref') or '')" < "${TMPD}/obj.json")"
if [[ -z "${REF}" ]]; then
echo "document-attach.sh: object ${ENDPOINT}/${OID} has no ref — cannot address its GED directory" >&2
exit 1
fi
if [[ -n "${REF_IN}" && "${REF_IN}" != "${REF}" ]]; then
echo "document-attach.sh: ABORT — object_id ${OID} is '${REF}' on the target, not the requested ref '${REF_IN}'" >&2
exit 1
fi
else
# ref-only: look the object up by its Dolibarr ref — 0 or 2+ matches abort.
FLT="$(urlenc "(t.ref:=:'${REF_IN}')")"
set +e
"${W}" GET "${ENDPOINT}?limit=2&sqlfilters=${FLT}" > "${TMPD}/lookup.json" 2> "${TMPD}/lookup.err"
rc=$?
set -e
if [[ ${rc} -ne 0 ]]; then
if grep -q "HTTP 404" "${TMPD}/lookup.err"; then printf '[]' > "${TMPD}/lookup.json"
else
cat "${TMPD}/lookup.err" >&2
echo "document-attach.sh: could not look up ref '${REF_IN}' on ${ENDPOINT} — refusing to upload blind" >&2
exit 1
fi
fi
OID="$(python3 - "${TMPD}/lookup.json" "${REF_IN}" <<'PY'
import json, sys
try:
rows = json.load(open(sys.argv[1]))
except Exception:
rows = []
rows = rows if isinstance(rows, list) else []
if len(rows) == 0:
sys.exit("document-attach.sh: ref %r matched nothing on the target" % sys.argv[2])
if len(rows) > 1:
sys.exit("document-attach.sh: ref %r is ambiguous (%d matches) — pass object_id" % (sys.argv[2], len(rows)))
print(int(rows[0]["id"]))
PY
)"
REF="${REF_IN}"
fi
# --- 3. List the object's GED directory (the dedupe source of truth) -----------
list_documents() { # $1 = output file; 404 on a proven-existing object = "no docs yet"
set +e
"${W}" GET "/documents?modulepart=${MP}&id=${OID}" > "$1" 2> "${TMPD}/list.err"
local rc=$?
set -e
if [[ ${rc} -ne 0 ]]; then
if grep -q "HTTP 404" "${TMPD}/list.err"; then
printf '[]' > "$1"
else
cat "${TMPD}/list.err" >&2
echo "document-attach.sh: could not list documents for ${MP} id ${OID} — refusing to upload blind (dedupe impossible)" >&2
exit 1
fi
fi
}
list_documents "${TMPD}/list.json"
# Find a same-named entry and derive its module-relative download path from
# fullname (everything after the last "/facture/" — covers both moduleparts,
# including the supplier-invoice get_exdir prefix like 0/3/REF/…).
cat > "${TMPD}/match.py" <<'PY'
import json, os, sys
try:
rows = json.load(open(sys.argv[1]))
except Exception:
rows = []
rows = rows if isinstance(rows, list) else []
fn = sys.argv[2]
hits = []
for r in rows:
if str(r.get("type") or "") == "dir":
continue
name = str(r.get("relativename") or "").strip() \
or os.path.basename(str(r.get("fullname") or ""))
if name == fn:
hits.append(r)
if len(hits) > 1:
sys.exit("document-attach.sh: ABORT — %d GED entries named %r on this object; "
"refusing to guess" % (len(hits), fn))
if hits:
full = str(hits[0].get("fullname") or "")
i = full.rfind("/facture/")
if i < 0:
sys.exit("document-attach.sh: ABORT — cannot derive the module-relative "
"path from %r" % full)
print(json.dumps({"relpath": full[i + len("/facture/"):],
"size": hits[0].get("size")}))
PY
remote_sha() { # $1 = module-relative path → prints sha256 of the stored bytes
local enc dl
enc="$(urlenc "$1")"
if ! dl="$("${W}" GET "/documents/download?modulepart=${MP}&original_file=${enc}" 2> "${TMPD}/dl.err")"; then
cat "${TMPD}/dl.err" >&2
echo "document-attach.sh: could not download back '$1' to verify its sha256 — refusing to act blind" >&2
exit 1
fi
python3 -c "
import base64, hashlib, json, sys
d = json.load(sys.stdin)
print(hashlib.sha256(base64.b64decode(d['content'])).hexdigest())" <<<"${dl}"
}
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/list.json" "${FILENAME}")"
if [[ -n "${MATCH}" ]]; then
RELPATH="$(python3 -c "import json,sys; print(json.load(sys.stdin)['relpath'])" <<<"${MATCH}")"
SIZE="$(python3 -c "import json,sys; print(json.load(sys.stdin)['size'])" <<<"${MATCH}")"
STORED_SHA="$(remote_sha "${RELPATH}")"
if [[ "${STORED_SHA}" == "${LOCAL_SHA}" ]]; then
echo "document-attach.sh: '${FILENAME}' already attached to ${REF} with identical sha256 — deduped, no upload" >&2
python3 -c "
import json, sys
print(json.dumps({'object_id': int(sys.argv[1]), 'ref': sys.argv[2],
'modulepart': sys.argv[3], 'filename': sys.argv[4],
'sha256': sys.argv[5], 'size': int(sys.argv[6]),
'deduped': True}))" "${OID}" "${REF}" "${MP}" "${FILENAME}" "${LOCAL_SHA}" "${SIZE}"
exit 0
fi
echo "document-attach.sh: ABORT — '${FILENAME}' already exists on ${REF} with DIFFERENT content" >&2
echo " stored sha256: ${STORED_SHA}" >&2
echo " local sha256: ${LOCAL_SHA}" >&2
echo " Same name + different bytes is a conflict for a human to resolve — never an overwrite." >&2
exit 1
fi
# --- 4. Upload (no same-named entry) -------------------------------------------
python3 - "${FILENAME}" "${MP}" "${REF}" "${TMPD}/b64.txt" > "${TMPD}/upload.json" <<'PY'
import json, sys
print(json.dumps({"filename": sys.argv[1], "modulepart": sys.argv[2],
"ref": sys.argv[3], "filecontent": open(sys.argv[4]).read().strip(),
"fileencoding": "base64", "overwriteifexists": "0"}))
PY
RESP="$("${W}" POST /documents/upload @"${TMPD}/upload.json")"
python3 - "${RESP}" "${FILENAME}" <<'PY'
import json, sys
try:
got = json.loads(sys.argv[1])
except Exception:
got = None
if got != sys.argv[2]:
sys.exit("document-attach.sh: upload did not confirm the filename "
"(expected %r, got %s)" % (sys.argv[2], sys.argv[1]))
PY
# --- 5. Read-back: re-list + download + sha256 compare -------------------------
list_documents "${TMPD}/list2.json"
MATCH2="$(python3 "${TMPD}/match.py" "${TMPD}/list2.json" "${FILENAME}")"
if [[ -z "${MATCH2}" ]]; then
echo "document-attach.sh: read-back FAILED — '${FILENAME}' not listed on ${REF} after upload" >&2
exit 1
fi
RELPATH2="$(python3 -c "import json,sys; print(json.load(sys.stdin)['relpath'])" <<<"${MATCH2}")"
SIZE2="$(python3 -c "import json,sys; print(json.load(sys.stdin)['size'])" <<<"${MATCH2}")"
STORED_SHA2="$(remote_sha "${RELPATH2}")"
if [[ "${STORED_SHA2}" != "${LOCAL_SHA}" ]]; then
echo "document-attach.sh: read-back FAILED — stored sha256 ${STORED_SHA2} != local ${LOCAL_SHA}" >&2
exit 1
fi
python3 -c "
import json, sys
print(json.dumps({'object_id': int(sys.argv[1]), 'ref': sys.argv[2],
'modulepart': sys.argv[3], 'filename': sys.argv[4],
'sha256': sys.argv[5], 'size': int(sys.argv[6]),
'deduped': False}))" "${OID}" "${REF}" "${MP}" "${FILENAME}" "${LOCAL_SHA}" "${SIZE2}"
@@ -38,7 +38,8 @@ manifest_path, script_dir = sys.argv[1], sys.argv[2]
ops = json.load(open(manifest_path)) ops = json.load(open(manifest_path))
OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh", OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh",
"creditnote": "creditnote-create.sh", "payment": "payment-record.sh", "creditnote": "creditnote-create.sh", "payment": "payment-record.sh",
"thirdparty_update": "thirdparty-update.sh", "contact": "contact-create.sh"} "thirdparty_update": "thirdparty-update.sh", "contact": "contact-create.sh",
"attach": "document-attach.sh"}
refmap = {} refmap = {}
n_created = n_deduped = 0 n_created = n_deduped = 0
@@ -100,6 +101,11 @@ for i, op in enumerate(ops, 1):
if not script: if not script:
sys.exit("promote-apply: unknown op '%s'" % t) sys.exit("promote-apply: unknown op '%s'" % t)
inp = resolve(op.get("input", {})) inp = resolve(op.get("input", {}))
# attach: a relative "file" is relative to the MANIFEST, not the CWD — the
# manifest is the portable unit (replay packs carry their pdfs/ beside it).
if t == "attach" and isinstance(inp.get("file"), str) and not os.path.isabs(inp["file"]):
inp["file"] = os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(manifest_path)), inp["file"]))
r = subprocess.run([os.path.join(script_dir, script)], input=json.dumps(inp), r = subprocess.run([os.path.join(script_dir, script)], input=json.dumps(inp),
capture_output=True, text=True, env=os.environ) capture_output=True, text=True, env=os.environ)
if r.returncode != 0: if r.returncode != 0:
@@ -10,7 +10,7 @@
set -euo pipefail set -euo pipefail
MANIFEST="${1:?usage: promote-plan.sh <manifest.json>}" MANIFEST="${1:?usage: promote-plan.sh <manifest.json>}"
python3 - "$MANIFEST" <<'PY' python3 - "$MANIFEST" <<'PY'
import json, sys import hashlib, json, os, sys
ops = json.load(open(sys.argv[1])) ops = json.load(open(sys.argv[1]))
print("Promote plan — %d operation(s) (symbolic refs resolve at apply time):\n" % len(ops)) print("Promote plan — %d operation(s) (symbolic refs resolve at apply time):\n" % len(ops))
for i, op in enumerate(ops, 1): for i, op in enumerate(ops, 1):
@@ -46,6 +46,23 @@ for i, op in enumerate(ops, 1):
flds = inp.get("fields") or {} flds = inp.get("fields") or {}
print(" socid=%s update %d dossier field(s): %s" % (inp.get("socid"), len(flds), print(" socid=%s update %d dossier field(s): %s" % (inp.get("socid"), len(flds),
", ".join(sorted(flds)) if flds else "NONE (will be refused)")) ", ".join(sorted(flds)) if flds else "NONE (will be refused)"))
elif t == "attach":
obj = inp.get("object_id") or inp.get("ref")
f = inp.get("file")
# A relative file resolves against the MANIFEST (same rule as apply).
resolved = None
if isinstance(f, str):
resolved = f if os.path.isabs(f) else os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(sys.argv[1])), f))
print(" object=%s modulepart=%s file=%s" % (obj, inp.get("modulepart"), f))
if resolved and os.path.isfile(resolved):
data = open(resolved, "rb").read()
print(" sha256=%s (%d bytes) (idempotent: dedupe by object+filename+sha256; "
"same name + different content aborts)"
% (hashlib.sha256(data).hexdigest(), len(data)))
else:
print(" !! FILE MISSING at plan time: %s — apply WILL fail; "
"re-fetch the source first" % resolved)
elif t == "contact": elif t == "contact":
name = " ".join(x for x in (inp.get("firstname"), inp.get("lastname")) if x) or "?" name = " ".join(x for x in (inp.get("firstname"), inp.get("lastname")) if x) or "?"
print(" socid=%s contact %s%s%s (idempotent: dedupe by email, then lastname+firstname)" print(" socid=%s contact %s%s%s (idempotent: dedupe by email, then lastname+firstname)"
@@ -8,10 +8,12 @@
# What it does (writes go ONLY through the host-guarded dol-write.sh): # 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: # 1. builds a small self-contained manifest with a unique-per-run fixture:
# one supplier thirdparty + one validated supplier invoice (Qonto-style # one supplier thirdparty + one validated supplier invoice (Qonto-style
# transaction id, so the erp#37 normalization is exercised too) + one payment # transaction id, so the erp#37 normalization is exercised too) + one
# 2. applies it → expects 3 created, no dedupe # payment + one GED attach (erp#43: object_id via @ref, file path relative
# 3. applies it AGAIN → expects 3 deduped, zero new rows (verified by # to the manifest, idempotent by sha256)
# row-counting thirdparties / invoices / payments via the API) # 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; # Sandbox etiquette: the fixture rows stay behind (the sandbox is disposable;
# a checkpoint refresh reclaims them). Run from anywhere: # 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", "input": { "invoice_id": "@inv", "kind": "supplier", "mode": "VIR",
"account_id": 1, "date": "2026-07-02", "amount": 120.00, "account_id": 1, "date": "2026-07-02", "amount": 120.00,
"transaction_id": "${TX}", "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 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) count_rows() { # $1 = path, counts a JSON array (Dolibarr 404-on-empty => 0)
local out 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}"; } tp_count() { count_rows "/thirdparties?limit=100&sqlfilters=${FLT}"; }
inv_count() { count_rows "/supplierinvoices?thirdparty_ids=$1&limit=500"; } inv_count() { count_rows "/supplierinvoices?thirdparty_ids=$1&limit=500"; }
pay_count() { count_rows "/supplierinvoices/$1/payments"; } 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}) ==" echo "== erp#44 replay-idempotency — fixture ${STAMP} (evidence: ${EV}) =="
[[ "$(tp_count)" == "0" ]] || fail "fixture name already exists on the sandbox (clock collision?)" [[ "$(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 \ "${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
| tee "${EV}/run1.out" | tee "${EV}/run1.out"
grep -q 'deduped' "${EV}/run1.out" && fail "run 1: nothing may dedupe on a fresh fixture" 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 -c ' created' "${EV}/run1.out")" == "4" ]] || fail "run 1: expected 4 created ops"
grep -q '(3 created)' "${EV}/run1.out" || fail "run 1: summary must say (3 created)" grep -q '(4 created)' "${EV}/run1.out" || fail "run 1: summary must say (4 created)"
TPID="$(python3 -c "import json,sys,re 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")" 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 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")" 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}")" 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}" | tee "${EV}/counts-run1.txt" 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" ]] || fail "run 1 must have created exactly 1 of each" [[ "${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 \ "${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
| tee "${EV}/run2.out" | 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 ' 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}")" 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}" | tee "${EV}/counts-run2.txt" 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}" ]] \ [[ "${TP2}" == "${TP1}" && "${INV2}" == "${INV1}" && "${PAY2}" == "${PAY1}" && "${DOC2}" == "${DOC1}" ]] \
|| fail "run 2 must add ZERO rows (run1: ${TP1}/${INV1}/${PAY1}, run2: ${TP2}/${INV2}/${PAY2})" || 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. # The payment's stored num must be the erp#37 canonical short form.
"${W}" GET "/supplierinvoices/${INVID}/payments" > "${EV}/payments.json" "${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}" || fail "stored num must be the normalized short form ${TX_SHORT}"
echo 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 "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 run1.out run2.out counts-run*.txt payments.json" 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. # an existing fiche missing the requested role ABORTS; a miss creates.
# 11. a deduped DRAFT with validate:true is validated on replay (converges an # 11. a deduped DRAFT with validate:true is validated on replay (converges an
# op that died between create and validate). # 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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PR="${SCRIPT_DIR}/../scripts/payment-record.sh" 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" || fail "draft-converge: the matched draft must be validated"
echo "OK: draft convergence — replay validates the half-done invoice instead of duplicating it" 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" echo "OK: all offline tests passed"
@@ -28,6 +28,9 @@
set -euo pipefail set -euo pipefail
STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}" STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}"
METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}" 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 case "${METHOD} ${ENDPOINT}" in
GET\ *"/payments"*) GET\ *"/payments"*)
if [[ -f "${STATE}/payments.json" ]]; then if [[ -f "${STATE}/payments.json" ]]; then
@@ -120,6 +123,55 @@ json.dump(rows, open(p, "w"), ensure_ascii=False)
PY PY
echo "88" 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") POST\ *"/validate")
printf '%s\n' "${ENDPOINT}" > "${STATE}/validated_endpoint" printf '%s\n' "${ENDPOINT}" > "${STATE}/validated_endpoint"
echo '{"success":1}' echo '{"success":1}'
+4
View File
@@ -8,4 +8,8 @@
secrets/ secrets/
*.key *.key
.claude/skills/**/.env .claude/skills/**/.env
# Replay-pack source PDFs — evidence, re-fetchable from books@ (email-ingest),
# never committed (see each pack's README for the re-fetch recipe)
.claude/skills/dolibarr-sandbox-write/replay-packs/**/pdfs/
.claude/skills/**/examples/document_*.bin .claude/skills/**/examples/document_*.bin
+4
View File
@@ -87,6 +87,7 @@ COMMANDS
invoice Customer/supplier invoice + product/service lines invoice Customer/supplier invoice + product/service lines
payment Record a règlement (transaction_id=bank tx) → bank_transaction_id payment Record a règlement (transaction_id=bank tx) → bank_transaction_id
creditnote Create an avoir — customer or supplier (kind) creditnote Create an avoir — customer or supplier (kind)
attach Upload a source document onto an invoice (GED; sha256-idempotent)
accounts List bank accounts (id/label) to pick account_id accounts List bank accounts (id/label) to pick account_id
write <METHOD> <path> [body] Raw host-guarded write write <METHOD> <path> [body] Raw host-guarded write
checkpoint status|refresh|provision|relink-env Manage the iso-prod checkpoint checkpoint status|refresh|provision|relink-env Manage the iso-prod checkpoint
@@ -287,6 +288,7 @@ EOF
invoice) exec "${SKILLS}/dolibarr-sandbox-write/scripts/invoice-create.sh" "$@" ;; invoice) exec "${SKILLS}/dolibarr-sandbox-write/scripts/invoice-create.sh" "$@" ;;
payment) exec "${SKILLS}/dolibarr-sandbox-write/scripts/payment-record.sh" "$@" ;; payment) exec "${SKILLS}/dolibarr-sandbox-write/scripts/payment-record.sh" "$@" ;;
creditnote) exec "${SKILLS}/dolibarr-sandbox-write/scripts/creditnote-create.sh" "$@" ;; creditnote) exec "${SKILLS}/dolibarr-sandbox-write/scripts/creditnote-create.sh" "$@" ;;
attach) exec "${SKILLS}/dolibarr-sandbox-write/scripts/document-attach.sh" "$@" ;;
accounts) exec "${SKILLS}/dolibarr-sandbox-write/scripts/bank-accounts.sh" "$@" ;; accounts) exec "${SKILLS}/dolibarr-sandbox-write/scripts/bank-accounts.sh" "$@" ;;
write) exec "${SKILLS}/dolibarr-sandbox-write/scripts/dol-write.sh" "$@" ;; write) exec "${SKILLS}/dolibarr-sandbox-write/scripts/dol-write.sh" "$@" ;;
checkpoint) checkpoint)
@@ -327,6 +329,8 @@ Each subcommand reads a JSON object on stdin (or a file path arg).
echo '{"invoice_id":19,"mode":"VIR","account_id":1}' | arcodange sandbox payment echo '{"invoice_id":19,"mode":"VIR","account_id":1}' | arcodange sandbox payment
creditnote avoir (credit note) referencing a source invoice creditnote avoir (credit note) referencing a source invoice
echo '{"socid":42,"source_invoice":19,"validate":true,"lines":[...]}' | arcodange sandbox creditnote echo '{"socid":42,"source_invoice":19,"validate":true,"lines":[...]}' | arcodange sandbox creditnote
attach upload a source document onto an invoice's GED (idempotent by sha256)
echo '{"modulepart":"facture_fournisseur","ref":"FAF2026013","file":"F1045.pdf"}' | arcodange sandbox attach
write raw host-guarded write arcodange sandbox write POST /thirdparties '{"name":".."}' write raw host-guarded write arcodange sandbox write POST /thirdparties '{"name":".."}'
checkpoint manage the iso-prod checkpoint (status|refresh|provision|relink-env) checkpoint manage the iso-prod checkpoint (status|refresh|provision|relink-env)
arcodange sandbox checkpoint status arcodange sandbox checkpoint status