Task 13 moved PO creation to upload time and needed a hash-dedup window matching the spec's "same 30-day dedup window as today" (design doc section 4.1). purchase_orders has no working created/updated autodate field on this PocketBase instance, and unlike delivery_notes it had no other timestamp field either -- the first cut of Task 13 correctly found this, but responded by dropping the time window entirely rather than adding one. That's a real behaviour change from spec, not just a workaround: a PO file legitimately re-sent long after the original would have been flagged a duplicate forever, with no way to age out. Added purchase_orders.date_uploaded (additive, mirrors how delivery_notes already uses its own date_received) and wired it through: stamped at create, filtered on in the dedup query. Also brought PurchaseOrderRecord in pb-types.ts in line with what Task 1 already put on the live schema and what the upload route now writes -- processing_status/attempts/last_error/next_retry_at/last_attempt_at/ file_hash/date_uploaded were live in production but absent from the TypeScript interface. docs/data-model.md section 7 documents this exact gap as a standing foot-gun; closing it now while it's fresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
3.5 KiB
Python
Executable file
96 lines
3.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
Add purchase_orders.date_uploaded.
|
|
|
|
PB_URL=... PB_ADMIN_EMAIL=... PB_ADMIN_PASSWORD=... \\
|
|
python3 scripts/migrations/2026-09-12-add-po-date-uploaded.py
|
|
|
|
Additive, idempotent.
|
|
|
|
Task 13 moved PO creation from process-batch (after OCR) to the upload
|
|
route (before OCR), and needed a hash-dedup window matching the spec's
|
|
"same 30-day dedup window as today" (design doc section 4.1). This
|
|
PocketBase instance has no working created/updated autodate field on
|
|
purchase_orders - confirmed live, same class of gap Task 9 hit on
|
|
delivery_notes (solved there with the pre-existing date_received field).
|
|
purchase_orders has no equivalent field to fall back to, so the first cut
|
|
of Task 13 dropped the time window entirely rather than invent one: dedup
|
|
became "any non-failed row with this hash, forever." That is a real
|
|
behaviour change from the spec, not just a workaround for a missing
|
|
autodate field - a PO file legitimately re-sent a year after the original
|
|
would be blocked as a duplicate permanently.
|
|
|
|
This field restores the bounded window the same way delivery_notes has
|
|
one: a real timestamp, stamped at upload time, filtered with >=.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
COLLECTION = "purchase_orders"
|
|
NEW_FIELD = {"name": "date_uploaded", "type": "date", "required": False}
|
|
|
|
|
|
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):
|
|
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}")
|
|
|
|
coll = api(base_url, token, "GET", f"/api/collections/{COLLECTION}")
|
|
if coll is None:
|
|
print(f"ABORT: {COLLECTION} not found")
|
|
return 1
|
|
|
|
fields = coll["fields"]
|
|
if any(f["name"] == NEW_FIELD["name"] for f in fields):
|
|
print(f"{COLLECTION}: no-op, {NEW_FIELD['name']} already present")
|
|
return 0
|
|
|
|
print(f"{COLLECTION}: adding {NEW_FIELD['name']}")
|
|
api(base_url, token, "PATCH",
|
|
f"/api/collections/{coll['id']}", {"fields": fields + [NEW_FIELD]})
|
|
|
|
back = api(base_url, token, "GET", f"/api/collections/{coll['id']}")
|
|
names = sorted(f["name"] for f in back["fields"])
|
|
assert NEW_FIELD["name"] in names, "field did not stick"
|
|
print(f"OK. final fields: {names}")
|
|
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)
|