feat(promote): resolve pre-existing entities by business key (#entity:field=value)
#24
@@ -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
|
`ARCO_PROMOTE_CONFIRM` is set exactly. Pair it with `dolibarr-data-snapshot` (prod
|
||||||
before/after) to confirm only the intended records changed.
|
before/after) to confirm only the intended records changed.
|
||||||
|
|
||||||
Limitation: a manifest references entities it **creates** (via `@ref`). Referencing
|
A manifest value can reference another entity two ways, both resolved against the
|
||||||
a *pre-existing* prod entity by a sandbox id won't match — resolve those by business
|
**target** so the same file is portable sandbox↔prod:
|
||||||
key (name/code/ref) first. Self-contained change-sets (new thirdparty + its invoice
|
|
||||||
+ payment) replay cleanly today.
|
- **`@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
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,55 @@ 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"}
|
||||||
refmap = {}
|
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):
|
def resolve(v):
|
||||||
if isinstance(v, str) and v.startswith("@"):
|
if isinstance(v, str) and v.startswith("@"):
|
||||||
k = v[1:]
|
k = v[1:]
|
||||||
if k not in refmap:
|
if k not in refmap:
|
||||||
sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k)
|
sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k)
|
||||||
return refmap[k]
|
return refmap[k]
|
||||||
|
if isinstance(v, str) and v.startswith("#"):
|
||||||
|
return lookup(v[1:])
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
return {kk: resolve(vv) for kk, vv in v.items()}
|
return {kk: resolve(vv) for kk, vv in v.items()}
|
||||||
if isinstance(v, list):
|
if isinstance(v, list):
|
||||||
|
|||||||
Reference in New Issue
Block a user