diff --git a/scripts/migrations/2026-09-16-add-suppliers-schema.py b/scripts/migrations/2026-09-16-add-suppliers-schema.py new file mode 100644 index 0000000..30c2c37 --- /dev/null +++ b/scripts/migrations/2026-09-16-add-suppliers-schema.py @@ -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) \ No newline at end of file diff --git a/scripts/pb-bootstrap-schema.py b/scripts/pb-bootstrap-schema.py index ba2135a..90183a3 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -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", diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index a5ec31e..d356a04 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -31,6 +31,13 @@ export interface SiteRecord extends RecordModel { expand?: Record; } +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 {