feat(write-skill): client-dossier ops — thirdparty update (allowlisted) + idempotent contacts
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]>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
#!/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}"
|
||||
Reference in New Issue
Block a user