Part of erp#65 (phase 1). Ledger grammar "thirdparty complete" gets its op: allowlisted non-ledger fields, per-field diff read-back. Contacts are born idempotent (dedupe by email then name). Promote ops wired both targets, offline stub tests, SKILL.md workflows, KM dossier manifest (unsigned-contract truth fix + EIN-to-collect note). Co-Authored-By: Claude Fable 5 <[email protected]>
109 lines
4.9 KiB
Bash
Executable File
109 lines
4.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Update an EXISTING thirdparty's DOSSIER fields in the SANDBOX — allowlisted.
|
|
#
|
|
# The ledger grammar's "thirdparty complete": a fiche is completed with identity /
|
|
# address / typology / notes / national ids — never with ledger-side state. This
|
|
# script therefore REFUSES any field outside the allowlist below (code_client,
|
|
# client/fournisseur roles, remises, payment conditions, bank details… are
|
|
# creation- or ledger-side concerns, not dossier updates). Refusal happens BEFORE
|
|
# any request is sent, exits non-zero, and names the offending key(s).
|
|
#
|
|
# Usage:
|
|
# thirdparty-update.sh <socid> <json-file-or-inline-json>
|
|
# thirdparty-update.sh <socid> - # fields JSON on stdin
|
|
# thirdparty-update.sh # promote form: {"socid":N,"fields":{…}} on stdin
|
|
#
|
|
# Read-back contract (anti-silent-partial-apply):
|
|
# GET before → PUT → GET after. stdout is a JSON diff of the REQUESTED fields
|
|
# that actually changed: {"id":N,"changed":{field:{"before":…,"after":…}}}.
|
|
# A requested field whose read-back differs from the requested value makes the
|
|
# script exit non-zero — a write that "didn't take" is an error, never a silent
|
|
# partial apply. Re-running the same update yields "changed": {} (idempotent).
|
|
#
|
|
# 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}"
|
|
|
|
SOCID_ARG="${1:-}"
|
|
SRC="${2:-}"
|
|
if [[ -n "${SOCID_ARG}" ]]; then
|
|
if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)"
|
|
elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")"
|
|
else INPUT="${SRC}"; fi # inline JSON
|
|
else
|
|
INPUT="$(cat)" # promote form: {"socid":N,"fields":{…}}
|
|
fi
|
|
|
|
TMPD="$(mktemp -d -t tpupd.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
|
|
|
|
# --- 1. Validate against the dossier allowlist (before ANY request) ------------
|
|
cat > "${TMPD}/validate.py" <<'PY'
|
|
import json, sys
|
|
# The dossier allowlist — identity / address / typology / notes / national ids.
|
|
# Everything else is refused: ledger-side or creation-side fields have their own
|
|
# ops, and silently forwarding them would let a typo mutate the ledger.
|
|
ALLOWED = ["name", "name_alias", "address", "zip", "town", "state_id",
|
|
"region_id", "country_id", "country_code", "email", "url", "phone",
|
|
"typent_id", "effectif_id", "note_public", "note_private",
|
|
"idprof1", "idprof2", "idprof3", "idprof4", "idprof5", "idprof6",
|
|
"tva_intra"]
|
|
socid_arg = sys.argv[1]
|
|
try:
|
|
d = json.loads(sys.stdin.read() or "null")
|
|
except Exception as e:
|
|
sys.exit("thirdparty-update.sh: input is not valid JSON: %s" % e)
|
|
if socid_arg:
|
|
socid, fields = socid_arg, d
|
|
else:
|
|
if not isinstance(d, dict) or "socid" not in d or "fields" not in d:
|
|
sys.exit("thirdparty-update.sh: stdin form must be {\"socid\":N,\"fields\":{...}}")
|
|
socid, fields = d["socid"], d["fields"]
|
|
socid = str(socid)
|
|
if not socid.isdigit():
|
|
sys.exit("thirdparty-update.sh: socid must be numeric, got %r" % socid)
|
|
if not isinstance(fields, dict) or not fields:
|
|
sys.exit("thirdparty-update.sh: no fields to update (fields must be a non-empty JSON object)")
|
|
bad = sorted(k for k in fields if k not in ALLOWED)
|
|
if bad:
|
|
sys.exit("thirdparty-update.sh: REFUSED — field(s) outside the dossier allowlist: %s\n"
|
|
" allowed: %s" % (", ".join(bad), ", ".join(ALLOWED)))
|
|
print(socid)
|
|
print(json.dumps(fields, ensure_ascii=False))
|
|
PY
|
|
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py" "${SOCID_ARG}")"
|
|
SOCID="$(sed -n 1p <<<"${MAPPED}")"
|
|
BODY="$(sed -n 2p <<<"${MAPPED}")"
|
|
printf '%s' "${BODY}" > "${TMPD}/requested.json"
|
|
|
|
# --- 2. GET before → PUT → GET after -------------------------------------------
|
|
"${W}" GET "/thirdparties/${SOCID}" > "${TMPD}/before.json"
|
|
"${W}" PUT "/thirdparties/${SOCID}" "${BODY}" > /dev/null
|
|
"${W}" GET "/thirdparties/${SOCID}" > "${TMPD}/after.json"
|
|
|
|
# --- 3. Per-field diff of the requested fields; fail if a field didn't take ----
|
|
cat > "${TMPD}/diff.py" <<'PY'
|
|
import json, sys
|
|
before = json.load(open(sys.argv[1]))
|
|
after = json.load(open(sys.argv[2]))
|
|
req = json.load(open(sys.argv[3]))
|
|
socid = int(sys.argv[4])
|
|
def s(v): # Dolibarr returns most scalars as strings; None ≡ ""
|
|
return "" if v is None else str(v)
|
|
changed, failed = {}, []
|
|
for k, want in req.items():
|
|
b, a = before.get(k), after.get(k)
|
|
if s(a) != s(want):
|
|
failed.append((k, want, a))
|
|
if s(b) != s(a):
|
|
changed[k] = {"before": b, "after": a}
|
|
print(json.dumps({"id": socid, "changed": changed}, ensure_ascii=False))
|
|
if failed:
|
|
for k, want, got in failed:
|
|
sys.stderr.write("thirdparty-update.sh: field %r did NOT take — requested %r, read back %r\n"
|
|
% (k, want, got))
|
|
sys.exit(1)
|
|
PY
|
|
python3 "${TMPD}/diff.py" "${TMPD}/before.json" "${TMPD}/after.json" "${TMPD}/requested.json" "${SOCID}"
|