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
@@ -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}"