feat(schema): add suppliers collection and purchase_orders.supplier_ref
This commit is contained in:
parent
dc85da3f36
commit
8025d297e6
3 changed files with 178 additions and 1 deletions
153
scripts/migrations/2026-09-16-add-suppliers-schema.py
Normal file
153
scripts/migrations/2026-09-16-add-suppliers-schema.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 1 of the suppliers feature (see
|
||||
docs/superpowers/specs/2026-09-11-suppliers-design.md).
|
||||
|
||||
PB_URL=https://pb.mega.infra.apphub.co.za \\
|
||||
PB_ADMIN_EMAIL=admin@mega.co.za \\
|
||||
PB_ADMIN_PASSWORD=... \\
|
||||
python3 scripts/migrations/2026-09-16-add-suppliers-schema.py [--apply]
|
||||
|
||||
DRY RUN BY DEFAULT.
|
||||
|
||||
Creates:
|
||||
suppliers - name (text, required). No site relation -- a
|
||||
supplier is not site-scoped (the same supplier
|
||||
can deliver to multiple sites).
|
||||
|
||||
Adds:
|
||||
purchase_orders.supplier_ref - relation -> suppliers, optional,
|
||||
maxSelect=1, cascadeDelete=false.
|
||||
Optional because it is populated by a
|
||||
later migration (Task 4) and by OCR
|
||||
matching (Task 5) -- not every PO has
|
||||
one on day one.
|
||||
|
||||
Does NOT touch purchase_orders.supplier (the existing free-text field).
|
||||
It stays exactly as it is, forever -- see spec section 2.2 for why.
|
||||
|
||||
Idempotent: a field/collection that already exists is skipped, not
|
||||
recreated.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
def admin_auth(base_url):
|
||||
body = json.dumps({
|
||||
"identity": os.environ["PB_ADMIN_EMAIL"],
|
||||
"password": os.environ["PB_ADMIN_PASSWORD"],
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/api/collections/_superusers/auth-with-password",
|
||||
data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())["token"]
|
||||
|
||||
|
||||
def api(base_url, token, method, path, body=None):
|
||||
"""404 softens to None only on GET. A mutation that 404s must raise --
|
||||
silently treating it as success would hide a real failure."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", data=data, method=method,
|
||||
headers={"Authorization": token, "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
txt = r.read().decode()
|
||||
return json.loads(txt) if txt else None
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404 and method == "GET":
|
||||
return None
|
||||
raise RuntimeError(
|
||||
f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}"
|
||||
) from e
|
||||
|
||||
|
||||
def main():
|
||||
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
|
||||
token = admin_auth(base_url)
|
||||
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
|
||||
|
||||
# --- 1. suppliers collection ---
|
||||
existing = api(base_url, token, "GET", "/api/collections/suppliers")
|
||||
if existing is not None:
|
||||
print("suppliers: already exists, skipping create")
|
||||
suppliers_id = existing["id"]
|
||||
else:
|
||||
print("suppliers: would create (name: text, required)")
|
||||
if APPLY:
|
||||
created = api(base_url, token, "POST", "/api/collections", {
|
||||
"name": "suppliers",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "name", "type": "text", "required": True, "min": 1, "max": 200},
|
||||
],
|
||||
"listRule": "@request.auth.id != ''",
|
||||
"viewRule": "@request.auth.id != ''",
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"updateRule": None,
|
||||
"deleteRule": None,
|
||||
})
|
||||
suppliers_id = created["id"]
|
||||
print(f" created id={suppliers_id}")
|
||||
else:
|
||||
suppliers_id = None # unknown until applied; fine for dry run
|
||||
|
||||
# --- 2. purchase_orders.supplier_ref ---
|
||||
po = api(base_url, token, "GET", "/api/collections/purchase_orders")
|
||||
if po is None:
|
||||
print("ABORT: purchase_orders collection not found")
|
||||
return 1
|
||||
has_field = any(f["name"] == "supplier_ref" for f in po["fields"])
|
||||
if has_field:
|
||||
print("purchase_orders.supplier_ref: already exists, skipping")
|
||||
else:
|
||||
print("purchase_orders.supplier_ref: would add (relation -> suppliers, optional)")
|
||||
if APPLY:
|
||||
if suppliers_id is None:
|
||||
# We just created suppliers in this same run; re-fetch its id.
|
||||
suppliers_id = api(base_url, token, "GET", "/api/collections/suppliers")["id"]
|
||||
new_field = {
|
||||
"name": "supplier_ref",
|
||||
"type": "relation",
|
||||
"required": False,
|
||||
"collectionId": suppliers_id,
|
||||
"maxSelect": 1,
|
||||
"cascadeDelete": False,
|
||||
}
|
||||
api(base_url, token, "PATCH", f"/api/collections/{po['id']}",
|
||||
{"fields": po["fields"] + [new_field]})
|
||||
print(" added")
|
||||
|
||||
if not APPLY:
|
||||
print("\nDRY RUN. Re-run with --apply to write.")
|
||||
return 0
|
||||
|
||||
# --- verify ---
|
||||
back = api(base_url, token, "GET", "/api/collections/suppliers")
|
||||
assert back is not None, "suppliers collection missing after apply"
|
||||
names = [f["name"] for f in back["fields"]]
|
||||
assert "name" in names, f"suppliers missing 'name' field: {names}"
|
||||
|
||||
po_back = api(base_url, token, "GET", "/api/collections/purchase_orders")
|
||||
po_names = [f["name"] for f in po_back["fields"]]
|
||||
assert "supplier_ref" in po_names, f"purchase_orders missing supplier_ref: {po_names}"
|
||||
assert "supplier" in po_names, "purchase_orders.supplier was removed -- it must stay"
|
||||
|
||||
print("\nOK. suppliers exists with 'name'; purchase_orders has both "
|
||||
"'supplier' (unchanged) and 'supplier_ref' (new).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyError as e:
|
||||
print(f"ERROR: missing environment variable {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
|
@ -137,6 +137,14 @@ DEFS["employees"] = {
|
|||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["suppliers"] = {
|
||||
"name": "SUPPLIERS", "type": "base",
|
||||
"fields": [
|
||||
field("name", "text", required=True, min=1, max=200),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["purchase_orders"] = {
|
||||
"name": "PURCHASE_ORDERS", "type": "base",
|
||||
"fields": [
|
||||
|
|
@ -144,6 +152,8 @@ DEFS["purchase_orders"] = {
|
|||
field("site", "relation", required=True,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("supplier", "text", required=False),
|
||||
field("supplier_ref", "relation", required=False,
|
||||
collectionId="__SUPPLIERS_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("required_by_date", "date", required=False),
|
||||
field("ordered_by", "text", required=False),
|
||||
field("items_count", "number", required=False),
|
||||
|
|
@ -407,6 +417,7 @@ DEFS["delivery_note_items"] = {
|
|||
ORDER = [
|
||||
"sites",
|
||||
"employees",
|
||||
"suppliers",
|
||||
"purchase_orders",
|
||||
"purchase_order_items",
|
||||
"fixed_costs",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ export interface SiteRecord extends RecordModel {
|
|||
expand?: Record<string, never>;
|
||||
}
|
||||
|
||||
export interface SupplierRecord extends RecordModel {
|
||||
collectionName: "suppliers";
|
||||
/** Stored pre-normalized (trim, collapse whitespace, uppercase) — see
|
||||
* normalizeSupplierName in purchase-order-schema.ts. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One diesel price change. `effective_from` is inclusive — a fuel log is
|
||||
* priced at the latest row on or before its own date. Created by
|
||||
|
|
@ -137,6 +144,12 @@ export interface PurchaseOrderRecord extends RecordModel {
|
|||
purchase_order_number: string;
|
||||
site: string;
|
||||
supplier: string;
|
||||
/**
|
||||
* The authoritative value. `supplier` above is kept as raw OCR
|
||||
* provenance — this is what everything else reads. Optional: not
|
||||
* every PO has a match yet.
|
||||
*/
|
||||
supplier_ref?: string;
|
||||
required_by_date: string;
|
||||
ordered_by: string;
|
||||
items_count: number;
|
||||
|
|
@ -161,7 +174,7 @@ export interface PurchaseOrderRecord extends RecordModel {
|
|||
last_error?: string;
|
||||
next_retry_at?: string;
|
||||
last_attempt_at?: string;
|
||||
expand?: { site?: SiteRecord };
|
||||
expand?: { site?: SiteRecord; supplier_ref?: SupplierRecord };
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItemRecord extends RecordModel {
|
||||
|
|
|
|||
Loading…
Reference in a new issue