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:
@@ -4,16 +4,19 @@ description: >-
|
|||||||
WRITE operations against the Arcodange Dolibarr SANDBOX (erp-sandbox.arcodange.lab)
|
WRITE operations against the Arcodange Dolibarr SANDBOX (erp-sandbox.arcodange.lab)
|
||||||
— the rehearsal environment where an AI agent records thirdparties, invoices and
|
— the rehearsal environment where an AI agent records thirdparties, invoices and
|
||||||
payments before any change is promoted to prod. Create client/supplier fiches
|
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
|
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 —
|
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.
|
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
|
Use when the user asks to "create a thirdparty / supplier / client fiche",
|
||||||
une facture", "record an invoice with lines", "enregistrer un règlement / paiement",
|
"compléter / mettre à jour la fiche client", "add a contact to a thirdparty",
|
||||||
or to rehearse a write before promoting it to prod. SKIP for production writes
|
"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
|
(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).
|
separate, human-gated replay). Depends on the write-scoped `ai_agent_sandbox`
|
||||||
Depends on the write-scoped `ai_agent_sandbox` Dolibarr user + its API key.
|
Dolibarr user + its API key.
|
||||||
requires:
|
requires:
|
||||||
bins: [bash, curl, python3]
|
bins: [bash, curl, python3]
|
||||||
auth: ".env with DOLIBARR_SANDBOX_URL + DOLIBARR_SANDBOX_API_KEY (mode 600, gitignored)"
|
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
|
## 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`
|
### 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,
|
(`AVC…` for customer, `AVF…` for supplier). Emits `{id, ref, ref_supplier, total_ht,
|
||||||
total_ttc, fk_facture_source, statut}`.
|
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)
|
## 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
|
||||||
operations** to prod, with a human in the loop. The unit is a **manifest** — a JSON
|
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
|
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`.
|
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
|
```sh
|
||||||
scripts/promote-plan.sh change.json # 1. human-readable review
|
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`
|
- **Avoirs (credit notes)** → `creditnote-create.sh` (customer invoice `type=2`
|
||||||
referencing `source_invoice`; amounts negative, ref `AVC…`). Supplier avoirs
|
referencing `source_invoice`; amounts negative, ref `AVC…`). Supplier avoirs
|
||||||
are a follow-up.
|
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}`
|
- **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}`
|
||||||
(JSON on stdin) — `arcodange sandbox help` for the list.
|
(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
|
esac
|
||||||
done
|
done
|
||||||
case "${TARGET}" in
|
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" ;;
|
prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;;
|
||||||
*) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;;
|
*) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -32,7 +37,8 @@ import json, sys, subprocess, os
|
|||||||
manifest_path, script_dir = sys.argv[1], sys.argv[2]
|
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"}
|
||||||
refmap = {}
|
refmap = {}
|
||||||
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -99,13 +105,28 @@ for i, op in enumerate(ops, 1):
|
|||||||
sys.stderr.write(r.stdout + r.stderr + "\n")
|
sys.stderr.write(r.stdout + r.stderr + "\n")
|
||||||
sys.exit("promote-apply: op %d (%s) FAILED" % (i, t))
|
sys.exit("promote-apply: op %d (%s) FAILED" % (i, t))
|
||||||
out = r.stdout.strip()
|
out = r.stdout.strip()
|
||||||
|
parsed = None
|
||||||
try:
|
try:
|
||||||
rid = json.loads(out).get("id")
|
parsed = json.loads(out)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
rid = parsed.get("id")
|
||||||
|
else:
|
||||||
rid = out if out.isdigit() else None
|
rid = out if out.isdigit() else None
|
||||||
ref = op.get("ref")
|
ref = op.get("ref")
|
||||||
if ref and rid is not None:
|
if ref and rid is not None:
|
||||||
refmap[ref] = int(rid) if str(rid).isdigit() else rid
|
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))
|
print("OK — promote complete. ref -> id: %s" % json.dumps(refmap))
|
||||||
PY
|
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"),
|
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)",
|
inp.get("account_id"), ("amount=%s" % inp["amount"]) if inp.get("amount") else "(full)",
|
||||||
(" tx=%s" % txid) if txid else " tx=MISSING"))
|
(" 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("\nNext:")
|
||||||
print(" promote-apply.sh <manifest> --target sandbox # rehearse the replay (safe)")
|
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")
|
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
|
#!/usr/bin/env bash
|
||||||
# Offline tests for payment-record.sh's transaction_id normalization — the writer
|
# Offline tests for the write skill — everything runs against
|
||||||
# side of the varchar(50) fix (Dolibarr's num_payment columns truncate at 50 chars,
|
# tests/stub-dol-write.sh via the DOL_WRITE env hook, so NOTHING is written to
|
||||||
# Qonto ids run ~67). Uses tests/stub-dol-write.sh via the DOL_WRITE env hook, so
|
# the sandbox or prod (zero credentials, zero network).
|
||||||
# NOTHING is written to the sandbox or prod.
|
#
|
||||||
|
# payment-record.sh (the erp#37 varchar(50) fix):
|
||||||
# 1. Long Qonto id → POST carries the UUID suffix; JSON reports it; stderr says so.
|
# 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.
|
# 2. Wise numeric id → passes through untouched, no normalization notice.
|
||||||
# 3. Id still >50 chars after normalization → refused BEFORE any POST, error
|
# 3. Id still >50 chars after normalization → refused BEFORE any POST, error
|
||||||
# cites varchar(50) (never a silent truncation).
|
# 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
|
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"
|
||||||
|
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"
|
STUB="${SCRIPT_DIR}/stub-dol-write.sh"
|
||||||
|
|
||||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
bash -n "${PR}" || fail "bash -n payment-record.sh"
|
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"
|
bash -n "${STUB}" || fail "bash -n stub-dol-write.sh"
|
||||||
|
|
||||||
STATE="$(mktemp -d -t prtest.XXXXXX)"
|
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"
|
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)"
|
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
|
#!/usr/bin/env bash
|
||||||
# Offline stand-in for dol-write.sh, used ONLY by tests/run-tests.sh (injected via
|
# 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
|
# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out
|
||||||
# back as the payments list on GET so payment-record.sh can correlate. Never talks
|
# of reach by construction. Dispatch by endpoint:
|
||||||
# to any host — sandbox and prod are both out of reach by construction.
|
#
|
||||||
|
# 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
|
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:-}"
|
||||||
case "${METHOD}" in
|
case "${METHOD} ${ENDPOINT}" in
|
||||||
POST)
|
"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' "${BODY}" > "${STATE}/post_body.json"
|
||||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
||||||
echo "77"
|
echo "77"
|
||||||
;;
|
;;
|
||||||
GET)
|
GET\ *)
|
||||||
python3 - "${STATE}/post_body.json" <<'PY'
|
python3 - "${STATE}/post_body.json" <<'PY'
|
||||||
import json, sys
|
import json, sys
|
||||||
body = json.load(open(sys.argv[1]))
|
body = json.load(open(sys.argv[1]))
|
||||||
|
|||||||
Reference in New Issue
Block a user