From 04985fe15cb843c1197b90828ea033b97e1eb5fc Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Mon, 29 Jun 2026 23:57:49 +0200 Subject: [PATCH] feat(promote): resolve pre-existing entities by business key (#entity:field=value) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last promote gap: a manifest can now reference records it does NOT create. A value like "#thirdparty:name=KissMetrics" (or :code=CL0007) is looked up on the TARGET at apply time and resolved to that target's id — so the same manifest is portable (sandbox id on --target sandbox, prod id on --target prod). promote-apply.sh: resolve() gains a "#" branch + a lookup() helper that queries the target via the GET wrapper with sqlfilters. Supports thirdparty (name/code/supplier_code) and invoice/supplierinvoice (ref/ref_supplier). A lookup matching nothing OR more than one record ABORTS the run — it never guesses, so it cannot write to the wrong entity. Proven live: "#thirdparty:name=ACME Conseil" resolved to the existing client and invoiced it; a not-found code and an ambiguous (2-match) name both aborted with exit 1. Combined with @refs, arbitrary self-contained-or-referential change-sets now replay cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/dolibarr-sandbox-write/SKILL.md | 18 ++++++-- .../scripts/promote-apply.sh | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.claude/skills/dolibarr-sandbox-write/SKILL.md b/.claude/skills/dolibarr-sandbox-write/SKILL.md index 1666452..ec8b6be 100644 --- a/.claude/skills/dolibarr-sandbox-write/SKILL.md +++ b/.claude/skills/dolibarr-sandbox-write/SKILL.md @@ -141,10 +141,20 @@ dependent ops wire up on the target. `--target sandbox` writes via `dol-write.sh `ARCO_PROMOTE_CONFIRM` is set exactly. Pair it with `dolibarr-data-snapshot` (prod before/after) to confirm only the intended records changed. -Limitation: a manifest references entities it **creates** (via `@ref`). Referencing -a *pre-existing* prod entity by a sandbox id won't match — resolve those by business -key (name/code/ref) first. Self-contained change-sets (new thirdparty + its invoice -+ payment) replay cleanly today. +A manifest value can reference another entity two ways, both resolved against the +**target** so the same file is portable sandbox↔prod: + +- **`@ref`** — an entity **created earlier in this manifest** (resolves to the id + just created on the target). For new records. +- **`#entity:field=value`** — a **pre-existing** entity, looked up on the target by + business key, e.g. `#thirdparty:name=KissMetrics` or `#thirdparty:code=CL0007`. + Supports `thirdparty` (name/code/supplier_code) and `invoice`/`supplierinvoice` + (ref/ref_supplier). A lookup matching **nothing or more than one** record **aborts + the run** — it never guesses, so it can't write to the wrong entity. + +So a real change like "invoice the existing client KissMetrics and record its +payment" is `{"socid":"#thirdparty:name=KissMetrics", ...}` — it resolves to the +sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`. ## Gotchas diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh index 4048fe0..d1dbc8e 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh @@ -34,12 +34,55 @@ ops = json.load(open(manifest_path)) OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh", "creditnote": "creditnote-create.sh", "payment": "payment-record.sh"} refmap = {} + +import urllib.parse +DOL_WRITE = os.environ.get("DOL_WRITE") # GET wrapper for the chosen target + +# Business-key lookup of a PRE-EXISTING entity on the target, so a manifest can +# reference records it does not create (e.g. an invoice for an existing client). +# Resolves against the *target*, so #thirdparty:name=X becomes the sandbox id on +# --target sandbox and the prod id on --target prod (portability for real changes). +ENTITY_LOOKUP = { + "thirdparty": ("/thirdparties", {"name": "t.nom", "code": "t.code_client", + "supplier_code": "t.code_fournisseur"}), + "invoice": ("/invoices", {"ref": "t.ref"}), + "supplierinvoice": ("/supplierinvoices", {"ref": "t.ref", "ref_supplier": "t.ref_supplier"}), +} +def lookup(spec): + try: + ent, rest = spec.split(":", 1); field, value = rest.split("=", 1) + except ValueError: + sys.exit("promote-apply: bad lookup '#%s' (use #entity:field=value)" % spec) + if ent not in ENTITY_LOOKUP: + sys.exit("promote-apply: unknown lookup entity '%s'" % ent) + endpoint, cols = ENTITY_LOOKUP[ent] + if field not in cols: + sys.exit("promote-apply: cannot look %s up by '%s' (try %s)" % (ent, field, "/".join(cols))) + flt = "(%s:=:'%s')" % (cols[field], value.replace("'", "''")) + path = "%s?limit=2&sqlfilters=%s" % (endpoint, urllib.parse.quote(flt)) + r = subprocess.run([DOL_WRITE, "GET", path], capture_output=True, text=True, env=os.environ) + if r.returncode != 0: + sys.stderr.write(r.stdout + r.stderr + "\n") + sys.exit("promote-apply: lookup '#%s' query failed" % spec) + try: + rows = json.loads(r.stdout) + except Exception: + rows = [] + rows = rows if isinstance(rows, list) else [] + if len(rows) == 0: + sys.exit("promote-apply: lookup '#%s' matched nothing on the target" % spec) + if len(rows) > 1: + sys.exit("promote-apply: lookup '#%s' is ambiguous (%d matches) — use a unique key" % (spec, len(rows))) + return int(rows[0]["id"]) + def resolve(v): if isinstance(v, str) and v.startswith("@"): k = v[1:] if k not in refmap: sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k) return refmap[k] + if isinstance(v, str) and v.startswith("#"): + return lookup(v[1:]) if isinstance(v, dict): return {kk: resolve(vv) for kk, vv in v.items()} if isinstance(v, list):