feat(write-skill): client-dossier ops — thirdparty update (allowlisted) + idempotent contacts #66
@@ -4,16 +4,19 @@ description: >-
|
||||
WRITE operations against the Arcodange Dolibarr SANDBOX (erp-sandbox.arcodange.lab)
|
||||
— the rehearsal environment where an AI agent records thirdparties, invoices and
|
||||
payments before any change is promoted to prod. Create client/supplier fiches
|
||||
(auto-coded), customer + supplier invoices with product/service lines and the
|
||||
(auto-coded), update an existing fiche's dossier (allowlisted non-ledger fields
|
||||
with a before/after read-back diff), add contacts idempotently (dedupe by email
|
||||
then name), customer + supplier invoices with product/service lines and the
|
||||
supplier's own reference, validate them, and record règlements (payments). Every
|
||||
write goes through dol-write.sh, which REFUSES any host that is not the sandbox —
|
||||
the structural guarantee (ADR-0003) that this skill can never mutate production.
|
||||
Use when the user asks to "create a thirdparty / supplier / client fiche", "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
|
||||
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",
|
||||
"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
|
||||
(prod stays read-only via the `dolibarr` skill's `ai_agent` key; promotion is a
|
||||
separate, human-gated replay), and for credit notes/avoirs (a planned follow-up).
|
||||
Depends on the write-scoped `ai_agent_sandbox` Dolibarr user + its API key.
|
||||
separate, human-gated replay). Depends on the write-scoped `ai_agent_sandbox`
|
||||
Dolibarr user + its API key.
|
||||
requires:
|
||||
bins: [bash, curl, python3]
|
||||
auth: ".env with DOLIBARR_SANDBOX_URL + DOLIBARR_SANDBOX_API_KEY (mode 600, gitignored)"
|
||||
@@ -62,7 +65,8 @@ GET /status` should return HTTP 200 with `"environment":"non-production"`.
|
||||
|
||||
## Workflows
|
||||
|
||||
All three read a JSON object on **stdin** (or a file path as `$1`) and emit ids.
|
||||
Each script reads a JSON object on **stdin** (or a file path — or inline JSON —
|
||||
as `$1`; `thirdparty-update.sh` also takes `<socid>` first) and emits ids as JSON.
|
||||
|
||||
### 1 · Thirdparty (fiche client/fournisseur) — `scripts/thirdparty-create.sh`
|
||||
|
||||
@@ -148,13 +152,49 @@ come out negative. `kind:"supplier"` targets `/supplierinvoices` (carry
|
||||
(`AVC…` for customer, `AVF…` for supplier). Emits `{id, ref, ref_supplier, total_ht,
|
||||
total_ttc, fk_facture_source, statut}`.
|
||||
|
||||
### 5 · Thirdparty dossier update — `scripts/thirdparty-update.sh`
|
||||
|
||||
```sh
|
||||
scripts/thirdparty-update.sh 1 '{"email":"[email protected]","note_public":"…"}'
|
||||
scripts/thirdparty-update.sh 1 fiche.json # or a file; or "-" for stdin
|
||||
echo '{"socid":1,"fields":{"zip":"33713"}}' | scripts/thirdparty-update.sh # promote form
|
||||
```
|
||||
Updates an **existing** fiche's dossier — **allowlisted fields only**: `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`…`idprof6`, `tva_intra`. That is the ledger grammar's
|
||||
"thirdparty complete" — identity / address / typology / notes / national ids.
|
||||
Anything else (`code_client`, `client`/`fournisseur` roles, remises, payment
|
||||
conditions…) is **refused before any request**, exit non-zero, offender named.
|
||||
Read-back contract: GET before → PUT → GET after; emits
|
||||
`{"id":N,"changed":{field:{"before":…,"after":…}}}` restricted to the requested
|
||||
fields that actually changed, and exits non-zero if a requested field did not
|
||||
take. Re-applying the same update yields `"changed": {}` — idempotency you can see.
|
||||
|
||||
### 6 · Contact — `scripts/contact-create.sh`
|
||||
|
||||
```sh
|
||||
echo '{"socid":1,"lastname":"Rootering","firstname":"Hendrik","poste":"COO",
|
||||
"email":"[email protected]"}' | scripts/contact-create.sh
|
||||
```
|
||||
**Idempotent from day one** (the erp#44 pattern): before any POST it lists the
|
||||
thirdparty's contacts and dedupes **by case-insensitive email, then by
|
||||
(lastname, firstname)**; a match emits `{"id": <existing>, "deduped": true}` and
|
||||
exits 0 without writing. Otherwise POST `/contacts` → `{"id": <new>,
|
||||
"deduped": false}`. Required: `socid`, `lastname`. Optional: `firstname`, `poste`
|
||||
(job title), `email`, `phone` (stored as `phone_pro`), `phone_mobile`,
|
||||
`phone_perso`, `address`, `zip`, `town`, `country_id`, `note_public`,
|
||||
`note_private`. Unknown fields are refused, never dropped.
|
||||
|
||||
## Promote to prod (rehearse → review → replay)
|
||||
|
||||
The ADR-0003 capstone: take a change rehearsed in the sandbox and apply the **same
|
||||
operations** to prod, with a human in the loop. The unit is a **manifest** — a JSON
|
||||
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
|
||||
earlier in the run). See `examples/promote-manifest.json`.
|
||||
earlier in the run). See `examples/promote-manifest.json`. Op kinds: `thirdparty`,
|
||||
`thirdparty_update` (input: `socid` + `fields`), `contact`, `invoice`, `creditnote`,
|
||||
`payment` — each mapping to its workflow script above.
|
||||
|
||||
```sh
|
||||
scripts/promote-plan.sh change.json # 1. human-readable review
|
||||
@@ -207,5 +247,18 @@ sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`.
|
||||
- **Avoirs (credit notes)** → `creditnote-create.sh` (customer invoice `type=2`
|
||||
referencing `source_invoice`; amounts negative, ref `AVC…`). Supplier avoirs
|
||||
are a follow-up.
|
||||
- **Dossier updates are allowlisted by design.** The ledger grammar's "thirdparty
|
||||
complete" completes a fiche with identity/address/typology/notes/idprof1-6/
|
||||
tva_intra — never with ledger-side state. `thirdparty-update.sh` refuses
|
||||
anything else (`code_client`, roles, payment conditions…) *before* any request:
|
||||
a typo'd field must fail loudly, not silently mutate the ledger. Offline proof
|
||||
in `tests/run-tests.sh`.
|
||||
- **Contacts dedupe by (socid, email) then (socid, lastname+firstname)**, both
|
||||
case-insensitive — `contact-create.sh` answers `{"id": …, "deduped": true}`
|
||||
instead of minting a duplicate, so replaying a manifest is always safe.
|
||||
- **`poste`, not `soc2`.** The job-title field on a Dolibarr contact is `poste`;
|
||||
`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
|
||||
`poste`.
|
||||
- **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}`
|
||||
(JSON on stdin) — `arcodange sandbox help` for the list.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Replay pack — KM client dossier 2026-07-15
|
||||
|
||||
Completes the KissMetrics fiche (socid 1) with the full contractual dossier and creates
|
||||
the principal contact — erp#65 phase 1, triggered by operator direction 2026-07-15
|
||||
(« il faut ces informations relatives au client KM dans Dolibarr »). Two ops:
|
||||
|
||||
1. **`thirdparty_update` socid 1** — identity/address/typology + the contract dossier in
|
||||
`note_public` (allowlisted dossier fields only; the op refuses anything ledger-side).
|
||||
2. **`contact`** — Hendrik Rootering, COO, `[email protected]` (idempotent: dedupes
|
||||
by email, then lastname+firstname, so a replay can never mint a duplicate).
|
||||
|
||||
## Provenance — and the two truth fixes
|
||||
|
||||
Source: the operator's prepared payloads in erp trunk `test/kissmetrics_update.json` +
|
||||
`test/hendrik_contact_fix.json`, cross-checked against the 2026-07-15 contract-facts
|
||||
extraction (erp#53 comment). Two deliberate deviations from the WIP payloads:
|
||||
|
||||
- **The contract is NOT signed.** The WIP note said « contrat cadre signé 2026-04-23 » —
|
||||
the repo proves otherwise: the contract is *effect-dated* 2026-04-23 (rétro-daté), the
|
||||
eIDAS signature is **in progress** (signable set SAFE TO SIGN 2026-06-28; PR
|
||||
kissmetrics_contract_proposal#1 merged 2026-07-15, branch gone → repo state = `main`).
|
||||
The note also now carries: invoicing in **USD** (EUR lock 1.1650 removed 2026-06-28),
|
||||
the 4 % window = 6 months **post-launch** (not post-signature), CCIP-CA + CMAP dispute
|
||||
chain, Kissmetrics Holdings Inc wires, and **« US EIN : à collecter (→ idprof1) »** —
|
||||
the EIN is a [HUMAN] item (W-8BEN-E exchange is the natural moment); until then the
|
||||
`dolibarr-thirdparty-completeness` audit keeps showing exactly that one gap.
|
||||
- **`poste`, not `soc2`.** The WIP contact payload carried the job title in `soc2`, which
|
||||
is not a Dolibarr field (the API silently drops it). The manifest uses `poste`;
|
||||
`contact-create.sh` refuses `soc2` outright.
|
||||
|
||||
## How to promote
|
||||
|
||||
```sh
|
||||
cd .claude/skills/dolibarr-sandbox-write
|
||||
scripts/promote-plan.sh replay-packs/2026-07-15-km-dossier/manifest.json # review
|
||||
scripts/promote-apply.sh replay-packs/2026-07-15-km-dossier/manifest.json --target sandbox # rehearse
|
||||
# idempotency proof: apply twice — run 2 must print changed=0 (no-op) + deduped=true
|
||||
```
|
||||
|
||||
**Prod step = the orchestrator's, human-gated — not this pack's job.** The prod replay
|
||||
(`--target prod`) needs `DOLIBARR_PROD_WRITE_KEY` in the environment (never stored) and
|
||||
`ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD`, per ADR-0003. Pair with
|
||||
`dolibarr-data-snapshot` before/after, then re-run `dolibarr-thirdparty-completeness` on
|
||||
socid 1 — expected result: only the EIN gap remains.
|
||||
|
||||
## Rehearsal record (sandbox, 2026-07-15)
|
||||
|
||||
Run 1: fiche diff = `changed=1 [note_public]` (the sandbox already carried the WIP
|
||||
payload — the diff IS the truth fix) + contact Hendrik created (id 3). Run 2:
|
||||
`changed=0 (no-op)` + `deduped=true` (same id 3). Read-back: all 13 dossier fields
|
||||
correct; note carries « SIGNATURE eIDAS EN COURS », « US EIN : à collecter »,
|
||||
« FACTURÉ EN USD ». Full outputs in the phase-1 PR on erp#65.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{ "op": "thirdparty_update", "ref": "km",
|
||||
"input": {
|
||||
"socid": 1,
|
||||
"fields": {
|
||||
"name": "KissMetrics",
|
||||
"name_alias": "KissMetrics Inc.",
|
||||
"address": "2850 34th Street North, 307",
|
||||
"zip": "33713",
|
||||
"town": "St. Petersburg",
|
||||
"state_id": "1167",
|
||||
"region_id": "297",
|
||||
"country_id": "11",
|
||||
"country_code": "US",
|
||||
"email": "[email protected]",
|
||||
"typent_id": "3",
|
||||
"effectif_id": "1",
|
||||
"note_public": "Client Arcodange. Delaware corp, CEO Evan Sforzo, COO Hendrik Rootering (contact principal, Slack-first). Contrat cadre à effet du 2026-04-23 (rétro-daté) — SIGNATURE eIDAS EN COURS (set signable SAFE TO SIGN 2026-06-28 ; PR kissmetrics_contract_proposal#1 mergée 2026-07-15). 6 mois actifs (→2026-10-23), différé jusqu'à 2027-01-23, base engagée $33,000. Rémunération : $5 500/mois ($2 500 fixe net 30 + $3 000 différé +3 mois), FACTURÉ EN USD, réglé EUR au taux BCE du jour du paiement (lock 1.1650 supprimé le 2026-06-28). 4 % cash collected sur clients net-new signés dans les 6 mois post-launch (launch ~juil. 2026, date à confirmer), sur les 12 premiers mois de chaque client, payé au fil des encaissements net 30, sans plafond. Capacité ~10 j-h/mois au référentiel 500 €/j, Arcodange organise à sa discrétion. Droit français, T. com. Paris (CCIP-CA), médiation CMAP. Wires possibles via l'affilié Kissmetrics Holdings Inc (Art. 1). W-8BEN-E à déposer chez KM ; US EIN : à collecter (→ idprof1). Repo : arcodange-org/kissmetrics_contract_proposal (main). Darnis Operations = apport d'affaires (10 % an 1, 5 % perpétuel, sur fee standard différé inclus, 4 % exclu)."
|
||||
} } },
|
||||
|
||||
{ "op": "contact", "ref": "hendrik",
|
||||
"input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik",
|
||||
"poste": "COO", "email": "[email protected]" } }
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a contact (socpeople) on a thirdparty in the SANDBOX — IDEMPOTENT from
|
||||
# day one (the erp#44 pattern, applied at birth).
|
||||
#
|
||||
# Input: a JSON object on stdin (or a file path / inline JSON in $1):
|
||||
# socid (required) the thirdparty the contact belongs to
|
||||
# lastname (required)
|
||||
# firstname, poste (job title), email, phone (stored as phone_pro),
|
||||
# phone_pro, phone_perso, phone_mobile, address, zip, town, country_id,
|
||||
# note_public, note_private (all optional)
|
||||
# Unknown fields are REFUSED, never silently dropped — notably the WIP operator
|
||||
# payloads' `soc2` is NOT a Dolibarr field: the job title is `poste`.
|
||||
#
|
||||
# Idempotency: BEFORE any POST, list the target's existing contacts
|
||||
# (GET /contacts?thirdparty_ids=<socid>) and dedupe
|
||||
# 1. by case-insensitive email, then
|
||||
# 2. by case-insensitive (lastname, firstname).
|
||||
# On a match: emit {"id": <existing>, "deduped": true} and exit 0 without
|
||||
# POSTing. Otherwise POST /contacts and emit {"id": <new>, "deduped": false}.
|
||||
#
|
||||
# The Dolibarr API answers HTTP 404 (not []) when a thirdparty has no contacts —
|
||||
# that failure, and only that one, is treated as "no contacts yet". Any other
|
||||
# listing failure ABORTS: assuming "empty" on e.g. a 403 would mint duplicates.
|
||||
#
|
||||
# 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 ctcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
|
||||
|
||||
# --- 1. Validate + map the POST body (before ANY request) ----------------------
|
||||
cat > "${TMPD}/validate.py" <<'PY'
|
||||
import json, sys
|
||||
ALLOWED = ["socid", "lastname", "firstname", "poste", "email", "phone",
|
||||
"phone_pro", "phone_perso", "phone_mobile", "address", "zip", "town",
|
||||
"country_id", "note_public", "note_private"]
|
||||
try:
|
||||
d = json.loads(sys.stdin.read() or "null")
|
||||
except Exception as e:
|
||||
sys.exit("contact-create.sh: input is not valid JSON: %s" % e)
|
||||
if not isinstance(d, dict):
|
||||
sys.exit("contact-create.sh: input must be a JSON object")
|
||||
if "soc2" in d:
|
||||
sys.exit("contact-create.sh: REFUSED — 'soc2' is not a Dolibarr contact field "
|
||||
"(the API would silently drop it); the job title field is 'poste'")
|
||||
bad = sorted(k for k in d if k not in ALLOWED)
|
||||
if bad:
|
||||
sys.exit("contact-create.sh: REFUSED — unknown field(s): %s\n allowed: %s"
|
||||
% (", ".join(bad), ", ".join(ALLOWED)))
|
||||
socid = str(d.get("socid", ""))
|
||||
if not socid.isdigit():
|
||||
sys.exit("contact-create.sh: 'socid' is required (numeric), got %r" % socid)
|
||||
if not str(d.get("lastname", "")).strip():
|
||||
sys.exit("contact-create.sh: 'lastname' is required")
|
||||
body = {"socid": socid, "lastname": str(d["lastname"]).strip()}
|
||||
for k in ALLOWED:
|
||||
if k in ("socid", "lastname", "phone"):
|
||||
continue
|
||||
v = d.get(k)
|
||||
if v not in (None, ""):
|
||||
body[k] = v
|
||||
# `phone` is the office line — Dolibarr contacts store it as phone_pro.
|
||||
if d.get("phone") not in (None, "") and "phone_pro" not in body:
|
||||
body["phone_pro"] = d["phone"]
|
||||
print(socid)
|
||||
print(json.dumps(body, ensure_ascii=False))
|
||||
PY
|
||||
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py")"
|
||||
SOCID="$(sed -n 1p <<<"${MAPPED}")"
|
||||
BODY="$(sed -n 2p <<<"${MAPPED}")"
|
||||
printf '%s' "${BODY}" > "${TMPD}/body.json"
|
||||
|
||||
# --- 2. Dedupe against the target's existing contacts --------------------------
|
||||
set +e
|
||||
"${W}" GET "/contacts?thirdparty_ids=${SOCID}&limit=500" \
|
||||
> "${TMPD}/list.json" 2> "${TMPD}/list.err"
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ ${rc} -ne 0 ]]; then
|
||||
if grep -q "HTTP 404" "${TMPD}/list.err"; then
|
||||
printf '[]' > "${TMPD}/list.json" # empty contact list answers 404, not []
|
||||
else
|
||||
cat "${TMPD}/list.err" >&2
|
||||
echo "contact-create.sh: could not list contacts for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > "${TMPD}/match.py" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
try:
|
||||
rows = json.load(open(sys.argv[2]))
|
||||
except Exception:
|
||||
rows = []
|
||||
rows = rows if isinstance(rows, list) else []
|
||||
def norm(v): return str(v or "").strip().lower()
|
||||
email, ln, fn = norm(body.get("email")), norm(body.get("lastname")), norm(body.get("firstname"))
|
||||
hit = None
|
||||
if email:
|
||||
for r in rows:
|
||||
if norm(r.get("email")) == email:
|
||||
hit = r; break
|
||||
if hit is None:
|
||||
for r in rows:
|
||||
if norm(r.get("lastname")) == ln and norm(r.get("firstname")) == fn:
|
||||
hit = r; break
|
||||
if hit is not None:
|
||||
print(json.dumps({"id": int(hit["id"]), "deduped": True}))
|
||||
PY
|
||||
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/body.json" "${TMPD}/list.json")"
|
||||
if [[ -n "${MATCH}" ]]; then
|
||||
echo "contact-create.sh: contact already exists on socid ${SOCID} — deduped, no POST" >&2
|
||||
printf '%s\n' "${MATCH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 3. POST the new contact ----------------------------------------------------
|
||||
NEWID="$("${W}" POST /contacts "${BODY}")"
|
||||
if [[ ! "${NEWID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "contact-create.sh: contact POST did not return an id: ${NEWID}" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '{"id": %s, "deduped": false}\n' "${NEWID}"
|
||||
@@ -21,7 +21,12 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
case "${TARGET}" in
|
||||
sandbox) export DOL_WRITE="${SCRIPT_DIR}/dol-write.sh" ;;
|
||||
# sandbox honors a pre-set DOL_WRITE so the offline tests can inject
|
||||
# tests/stub-dol-write.sh (the erp#37 hook the op scripts already honor);
|
||||
# the default is the host-guarded dol-write.sh.
|
||||
sandbox) export DOL_WRITE="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" ;;
|
||||
# prod NEVER inherits — always the gated dol-prod-write.sh (env-only key
|
||||
# + ARCO_PROMOTE_CONFIRM), so no environment trick can reroute a prod apply.
|
||||
prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;;
|
||||
*) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -32,7 +37,8 @@ import json, sys, subprocess, os
|
||||
manifest_path, script_dir = sys.argv[1], sys.argv[2]
|
||||
ops = json.load(open(manifest_path))
|
||||
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"}
|
||||
refmap = {}
|
||||
|
||||
import urllib.parse
|
||||
@@ -99,13 +105,28 @@ for i, op in enumerate(ops, 1):
|
||||
sys.stderr.write(r.stdout + r.stderr + "\n")
|
||||
sys.exit("promote-apply: op %d (%s) FAILED" % (i, t))
|
||||
out = r.stdout.strip()
|
||||
parsed = None
|
||||
try:
|
||||
rid = json.loads(out).get("id")
|
||||
parsed = json.loads(out)
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(parsed, dict):
|
||||
rid = parsed.get("id")
|
||||
else:
|
||||
rid = out if out.isdigit() else None
|
||||
ref = op.get("ref")
|
||||
if ref and rid is not None:
|
||||
refmap[ref] = int(rid) if str(rid).isdigit() else rid
|
||||
print(" [%d/%d] %-11s %-8s -> id=%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid))
|
||||
# Surface the idempotency evidence inline: contact dedupes and
|
||||
# thirdparty-update read-back diffs are the proof a re-apply is a no-op.
|
||||
extra = ""
|
||||
if isinstance(parsed, dict):
|
||||
if parsed.get("deduped"):
|
||||
extra += " deduped=true (already on target — no write)"
|
||||
ch = parsed.get("changed")
|
||||
if isinstance(ch, dict):
|
||||
extra += " changed=%d%s" % (len(ch),
|
||||
(" [%s]" % ", ".join(sorted(ch))) if ch else " (no-op)")
|
||||
print(" [%d/%d] %-17s %-8s -> id=%s%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid, extra))
|
||||
print("OK — promote complete. ref -> id: %s" % json.dumps(refmap))
|
||||
PY
|
||||
|
||||
@@ -35,6 +35,16 @@ for i, op in enumerate(ops, 1):
|
||||
print(" invoice=%s mode=%s account=%s %s%s" % (inp.get("invoice_id"), inp.get("mode", "VIR"),
|
||||
inp.get("account_id"), ("amount=%s" % inp["amount"]) if inp.get("amount") else "(full)",
|
||||
(" tx=%s" % txid) if txid else " tx=MISSING"))
|
||||
elif t == "thirdparty_update":
|
||||
flds = inp.get("fields") or {}
|
||||
print(" socid=%s update %d dossier field(s): %s" % (inp.get("socid"), len(flds),
|
||||
", ".join(sorted(flds)) if flds else "NONE (will be refused)"))
|
||||
elif t == "contact":
|
||||
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)"
|
||||
% (inp.get("socid"), name,
|
||||
(" — %s" % inp["poste"]) if inp.get("poste") else "",
|
||||
(" <%s>" % inp["email"]) if inp.get("email") else ""))
|
||||
print("\nNext:")
|
||||
print(" promote-apply.sh <manifest> --target sandbox # rehearse the replay (safe)")
|
||||
print(" promote-apply.sh <manifest> --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY")
|
||||
|
||||
@@ -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}"
|
||||
@@ -1,20 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline tests for payment-record.sh's transaction_id normalization — the writer
|
||||
# side of the varchar(50) fix (Dolibarr's num_payment columns truncate at 50 chars,
|
||||
# Qonto ids run ~67). Uses tests/stub-dol-write.sh via the DOL_WRITE env hook, so
|
||||
# NOTHING is written to the sandbox or prod.
|
||||
# Offline tests for the write skill — everything runs against
|
||||
# tests/stub-dol-write.sh via the DOL_WRITE env hook, so NOTHING is written to
|
||||
# the sandbox or prod (zero credentials, zero network).
|
||||
#
|
||||
# payment-record.sh (the erp#37 varchar(50) fix):
|
||||
# 1. Long Qonto id → POST carries the UUID suffix; JSON reports it; stderr says so.
|
||||
# 2. Wise numeric id → passes through untouched, no normalization notice.
|
||||
# 3. Id still >50 chars after normalization → refused BEFORE any POST, error
|
||||
# cites varchar(50) (never a silent truncation).
|
||||
# client-dossier ops (erp#65 phase 1):
|
||||
# 4. thirdparty-update.sh refuses a non-allowlisted field (code_client) BEFORE
|
||||
# any PUT, naming the offender.
|
||||
# 5. contact-create.sh dedupes on a case-insensitive email match → no POST,
|
||||
# {"deduped": true}; and refuses the WIP payloads' `soc2` (→ `poste`).
|
||||
# 6. happy path: thirdparty_update + contact through promote-apply
|
||||
# --target sandbox (stubbed); a second apply is a proven no-op
|
||||
# (changed=0 for the fiche, deduped=true for the contact).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PR="${SCRIPT_DIR}/../scripts/payment-record.sh"
|
||||
TU="${SCRIPT_DIR}/../scripts/thirdparty-update.sh"
|
||||
CC="${SCRIPT_DIR}/../scripts/contact-create.sh"
|
||||
PA="${SCRIPT_DIR}/../scripts/promote-apply.sh"
|
||||
PP="${SCRIPT_DIR}/../scripts/promote-plan.sh"
|
||||
STUB="${SCRIPT_DIR}/stub-dol-write.sh"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
bash -n "${PR}" || fail "bash -n payment-record.sh"
|
||||
bash -n "${TU}" || fail "bash -n thirdparty-update.sh"
|
||||
bash -n "${CC}" || fail "bash -n contact-create.sh"
|
||||
bash -n "${PA}" || fail "bash -n promote-apply.sh"
|
||||
bash -n "${PP}" || fail "bash -n promote-plan.sh"
|
||||
bash -n "${STUB}" || fail "bash -n stub-dol-write.sh"
|
||||
|
||||
STATE="$(mktemp -d -t prtest.XXXXXX)"
|
||||
@@ -56,3 +73,70 @@ printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transacti
|
||||
grep -q 'varchar(50)' "${STATE}/stderr3" || fail "overlong-id: error must cite the varchar(50) constraint"
|
||||
|
||||
echo "OK: payment-record normalization tests passed (long→short, wise untouched, >50 refused pre-POST)"
|
||||
|
||||
# --- Case 4: thirdparty-update refuses a non-allowlisted field pre-PUT ---
|
||||
S4="$(mktemp -d -t tutest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}"' EXIT
|
||||
rc=0
|
||||
printf '{"name":"KissMetrics","code_client":"CL9999"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S4}" bash "${TU}" 1 - >/dev/null 2>"${S4}/stderr" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "allowlist: payload with code_client must be refused (exit non-zero)"
|
||||
[[ ! -f "${S4}/put_body.json" ]] || fail "allowlist: refusal must happen BEFORE any PUT"
|
||||
grep -q 'code_client' "${S4}/stderr" || fail "allowlist: the error must name the offending field"
|
||||
grep -qi 'allowlist' "${S4}/stderr" || fail "allowlist: the error must say it is an allowlist refusal"
|
||||
echo "OK: thirdparty-update allowlist — code_client refused pre-PUT, offender named"
|
||||
|
||||
# --- Case 5: contact-create dedupes by case-insensitive email → no POST ---
|
||||
S5="$(mktemp -d -t cctest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}"' EXIT
|
||||
printf '%s' '[{"id":"41","socid":"1","lastname":"ROOTERING","firstname":"hendrik","poste":"COO","email":"[email protected]"}]' \
|
||||
> "${S5}/contacts.json"
|
||||
OUT="$(printf '{"socid":"1","lastname":"Rootering","firstname":"Hendrik","poste":"COO","email":"[email protected]"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" 2>/dev/null)" \
|
||||
|| fail "contact-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o == {'id': 41, 'deduped': True}, o
|
||||
" || fail "contact-dedupe: must return the existing id with deduped:true, got: ${OUT}"
|
||||
[[ ! -f "${S5}/contact_post_body.json" ]] || fail "contact-dedupe: must NOT POST when a match exists"
|
||||
# 5b — the WIP payloads' soc2 is not a Dolibarr field: refuse, point to poste
|
||||
rc=0
|
||||
printf '{"socid":"1","lastname":"Rootering","soc2":"COO"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" >/dev/null 2>"${S5}/stderr5b" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "soc2: must be refused (exit non-zero)"
|
||||
grep -q 'poste' "${S5}/stderr5b" || fail "soc2: the error must point to 'poste'"
|
||||
[[ ! -f "${S5}/contact_post_body.json" ]] || fail "soc2: refusal must happen BEFORE any POST"
|
||||
echo "OK: contact-create dedupe — email match returns existing id, no POST; soc2 refused → poste"
|
||||
|
||||
# --- Case 6: happy path — both ops through promote-apply; re-apply is a no-op ---
|
||||
S6="$(mktemp -d -t patest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}"' EXIT
|
||||
cat > "${S6}/manifest.json" <<'JSON'
|
||||
[
|
||||
{ "op": "thirdparty_update", "ref": "tp",
|
||||
"input": { "socid": 1,
|
||||
"fields": { "email": "[email protected]", "note_public": "NEW NOTE — dossier v2" } } },
|
||||
{ "op": "contact", "ref": "ct",
|
||||
"input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik",
|
||||
"poste": "COO", "email": "[email protected]" } }
|
||||
]
|
||||
JSON
|
||||
bash "${PP}" "${S6}/manifest.json" >/dev/null || fail "promote-plan: must render the new op kinds"
|
||||
OUT1="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \
|
||||
|| fail "promote-apply run 1: expected success, got $?"
|
||||
grep -q 'thirdparty_update' <<<"${OUT1}" || fail "run 1: thirdparty_update op must be reported"
|
||||
grep -q 'changed=2' <<<"${OUT1}" || fail "run 1: both fields must read back as changed, got: ${OUT1}"
|
||||
grep -q 'deduped' <<<"${OUT1}" && fail "run 1: nothing must dedupe on a fresh target"
|
||||
grep -q -- '-> id=88' <<<"${OUT1}" || fail "run 1: contact must be created (id 88), got: ${OUT1}"
|
||||
grep -q '"note_public": "NEW NOTE — dossier v2"' "${S6}/put_body.json" \
|
||||
|| fail "run 1: PUT body must carry the new note, got: $(cat "${S6}/put_body.json")"
|
||||
grep -q '"poste": "COO"' "${S6}/contact_post_body.json" \
|
||||
|| fail "run 1: contact POST must carry poste=COO, got: $(cat "${S6}/contact_post_body.json")"
|
||||
rm -f "${S6}/contact_post_body.json"
|
||||
OUT2="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \
|
||||
|| fail "promote-apply run 2: expected success, got $?"
|
||||
grep -q 'changed=0 (no-op)' <<<"${OUT2}" || fail "run 2: thirdparty_update must be a no-op, got: ${OUT2}"
|
||||
grep -q 'deduped=true' <<<"${OUT2}" || fail "run 2: contact must dedupe, got: ${OUT2}"
|
||||
grep -q -- '-> id=88' <<<"${OUT2}" || fail "run 2: dedupe must return the run-1 id"
|
||||
[[ ! -f "${S6}/contact_post_body.json" ]] || fail "run 2: must NOT POST a duplicate contact"
|
||||
echo "OK: promote-apply happy path — run 1 applies (changed=2, contact id 88), run 2 is a no-op (changed=0, deduped)"
|
||||
|
||||
echo "OK: all offline tests passed"
|
||||
|
||||
@@ -1,18 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline stand-in for dol-write.sh, used ONLY by tests/run-tests.sh (injected via
|
||||
# the DOL_WRITE env hook). Records the POST body under $STUB_STATE, then serves it
|
||||
# back as the payments list on GET so payment-record.sh can correlate. Never talks
|
||||
# to any host — sandbox and prod are both out of reach by construction.
|
||||
# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out
|
||||
# of reach by construction. Dispatch by endpoint:
|
||||
#
|
||||
# GET /thirdparties/<id> → serves $STUB_STATE/thirdparty.json (or a canned
|
||||
# "before" fiche on first read)
|
||||
# PUT /thirdparties/<id> → records put_body.json/put_endpoint, merges the body
|
||||
# into thirdparty.json (so the read-after sees it)
|
||||
# GET /contacts… → serves $STUB_STATE/contacts.json; mimics Dolibarr's
|
||||
# empty-list behavior (HTTP 404 + non-zero) when absent
|
||||
# POST /contacts… → records contact_post_body.json, appends the contact
|
||||
# (id 88) to contacts.json, echoes 88
|
||||
# POST <anything else> → payment behavior: records post_body.json/post_endpoint,
|
||||
# echoes 77 (unchanged from the erp#37 tests)
|
||||
# GET <anything else> → payments list served back from post_body.json
|
||||
set -euo pipefail
|
||||
STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}"
|
||||
METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}"
|
||||
case "${METHOD}" in
|
||||
POST)
|
||||
case "${METHOD} ${ENDPOINT}" in
|
||||
"GET /thirdparties/"*)
|
||||
if [[ -f "${STATE}/thirdparty.json" ]]; then
|
||||
cat "${STATE}/thirdparty.json"
|
||||
else
|
||||
printf '%s' '{"id":"1","name":"KissMetrics","name_alias":null,"email":"","note_public":"OLD NOTE (pre-dossier)","zip":null,"town":null}'
|
||||
fi
|
||||
;;
|
||||
"PUT /thirdparties/"*)
|
||||
printf '%s' "${BODY}" > "${STATE}/put_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/put_endpoint"
|
||||
python3 - "${STATE}" "${BODY}" <<'PY'
|
||||
import json, os, sys
|
||||
state, body = sys.argv[1], json.loads(sys.argv[2])
|
||||
p = os.path.join(state, "thirdparty.json")
|
||||
cur = (json.load(open(p)) if os.path.exists(p)
|
||||
else {"id": "1", "name": "KissMetrics", "name_alias": None, "email": "",
|
||||
"note_public": "OLD NOTE (pre-dossier)", "zip": None, "town": None})
|
||||
cur.update(body)
|
||||
json.dump(cur, open(p, "w"), ensure_ascii=False)
|
||||
print(json.dumps(cur, ensure_ascii=False))
|
||||
PY
|
||||
;;
|
||||
"GET /contacts"*)
|
||||
if [[ -f "${STATE}/contacts.json" ]]; then
|
||||
cat "${STATE}/contacts.json"
|
||||
else
|
||||
# Dolibarr's list endpoints answer 404 (not []) when nothing matches.
|
||||
printf '%s' '{"error":{"code":404,"message":"No contact found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"POST /contacts"*)
|
||||
printf '%s' "${BODY}" > "${STATE}/contact_post_body.json"
|
||||
python3 - "${STATE}" "${BODY}" <<'PY'
|
||||
import json, os, sys
|
||||
state, body = sys.argv[1], json.loads(sys.argv[2])
|
||||
p = os.path.join(state, "contacts.json")
|
||||
rows = json.load(open(p)) if os.path.exists(p) else []
|
||||
body = dict(body); body["id"] = "88"
|
||||
rows.append(body)
|
||||
json.dump(rows, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
echo "88"
|
||||
;;
|
||||
POST\ *)
|
||||
printf '%s' "${BODY}" > "${STATE}/post_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
||||
echo "77"
|
||||
;;
|
||||
GET)
|
||||
GET\ *)
|
||||
python3 - "${STATE}/post_body.json" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
|
||||
Reference in New Issue
Block a user