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:
2026-07-15 19:49:13 +02:00
co-authored by Claude Fable 5
parent 6b546e77da
commit 35b227eb6d
9 changed files with 562 additions and 23 deletions
+61 -8
View File
@@ -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.