Add full sprint pricing workflow and supporting infrastructure: - New sidebar navigation entry for the sprint dashboard - PocketBase category collection schema and migration script - Server API routes for sprint item claims, attachments, counts, and file proxying - Dev identity utilities and UI switcher for multi-user testing - Reusable FilterTabs component replacing ItemStatusTabs - Updated TypeScript types for sprint-related entities Also include: - Fix PocketBase file URL method casing (getUrl → getURL) across codebase - Add development utility, smoke test, and helper scripts - Update package.json with new sprint migration command
232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Seed test data for GIELIE customer — 5 quotes with various item counts,
|
||
mix of catalogue and non-catalogue items, no pricing set.
|
||
|
||
Run with the PB admin token file at /tmp/pb_token already populated,
|
||
or pass it via $PB_TOKEN env. Auth here is non-interactive (we mint a
|
||
fresh token from the env file).
|
||
"""
|
||
import json
|
||
import random
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
|
||
PB = "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io"
|
||
|
||
|
||
def req(method, path, token=None, body=None):
|
||
url = f"{PB}{path}"
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = token
|
||
data = json.dumps(body).encode() if body is not None else None
|
||
r = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||
try:
|
||
with urllib.request.urlopen(r) as resp:
|
||
return json.loads(resp.read())
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", "replace")
|
||
raise RuntimeError(f"{method} {path} → {e.code}: {body}")
|
||
|
||
|
||
def mint_token():
|
||
auth = req("POST", "/api/collections/users/auth-with-password", body={
|
||
"identity": "david.els1@outlook.com",
|
||
"password": "1234567890",
|
||
})
|
||
return auth["token"]
|
||
|
||
|
||
def get_or_create_customer(token, name="GIELIE", email="gielie@example.com"):
|
||
existing = req("GET", f"/api/collections/customers/records?filter={urllib.parse.quote(f'customer=\"{name}\"')}&fields=id,customer,email&perPage=1", token=token)
|
||
if existing.get("items"):
|
||
print(f"[customer] {name} already exists: {existing['items'][0]['id']}")
|
||
return existing["items"][0]["id"]
|
||
|
||
# Pull the first company for optional relation (non-required)
|
||
company_id = None
|
||
try:
|
||
cos = req("GET", "/api/collections/companies/records?perPage=1&fields=id", token=token)
|
||
if cos.get("items"):
|
||
company_id = cos["items"][0]["id"]
|
||
except Exception:
|
||
pass
|
||
|
||
payload = {
|
||
"customer": name,
|
||
"email": email,
|
||
"alternative_email": "orders@gielie.co.za",
|
||
"number": "+27 11 555 0100",
|
||
"description": "Seeded test customer for agent auto-fill dry-runs.",
|
||
}
|
||
if company_id:
|
||
payload["company"] = company_id
|
||
created = req("POST", "/api/collections/customers/records", token=token, body=payload)
|
||
print(f"[customer] created {name}: {created['id']}")
|
||
return created["id"]
|
||
|
||
|
||
def next_quote_number(token):
|
||
# PB filter `quote_number~"Q"` plus sort by created desc — we want the latest
|
||
# numeric Q-number so we can produce the next monotonically increasing one.
|
||
raw = req("GET", "/api/collections/quotes/records?sort=-created&perPage=200&fields=quote_number", token=token)
|
||
nums = []
|
||
for it in raw.get("items", []):
|
||
qn = it.get("quote_number") or ""
|
||
# Accept formats: Q1782862717620 (numeric) or anything that ends in digits
|
||
digits = "".join(c for c in qn if c.isdigit())
|
||
if digits:
|
||
nums.append(int(digits))
|
||
return (max(nums) if nums else 0) + 1
|
||
|
||
|
||
def fetch_catalogue_sample(token, n=80):
|
||
"""Sample n catalogue rows across categories so the quote items look diverse."""
|
||
rows = []
|
||
page = 1
|
||
while len(rows) < n:
|
||
chunk = req(
|
||
"GET",
|
||
f"/api/collections/price_list/records?perPage=200&page={page}"
|
||
"&fields=id,sku,description,brand,category,cost_price,selling_price"
|
||
"&sort=sku",
|
||
token=token,
|
||
)
|
||
rows.extend(chunk.get("items", []))
|
||
if chunk.get("page", 1) >= chunk.get("totalPages", 1):
|
||
break
|
||
page += 1
|
||
return rows[:n]
|
||
|
||
|
||
# Sentinel embedded in `quotes.notes` to identify rows this script
|
||
# created. Used by cleanup scripts — do NOT rename without updating
|
||
# the cleanup section. (We can't use `claimed_by` because that field
|
||
# drives the UI's "Claimed by X" pill — see the make_quote() comment.)
|
||
CLEANUP_TAG = "[gielie-seed]"
|
||
|
||
|
||
# Non-catalogue "custom" line items the agent should NOT auto-match.
|
||
# Realistic ad-hoc jobs a buyer might request — never in the catalogue.
|
||
CUSTOM_DESCRIPTIONS = [
|
||
"Custom fabrication labour 3 hours (workshop)",
|
||
"Site delivery Gauteng region — small van",
|
||
"On-site installation and commissioning half day",
|
||
"Hire of scissor lift 8m working height, 1 day",
|
||
"Cable termination and labelling per point, 24 points",
|
||
"Custom printed banner 3m x 1m, full colour",
|
||
"Reprogram PLC to new ladder logic, supplier attendance",
|
||
"Sandblast and powder-coat steel brackets, batch of 12",
|
||
"Emergency callout after-hours weekend rate",
|
||
"Pressure-test hydraulic lines to 250 bar per circuit",
|
||
"Custom-cut stainless steel sheet 3mm, 600x600",
|
||
"3D printed jigs and fixtures, PLA batch",
|
||
]
|
||
|
||
|
||
def make_line(cat):
|
||
"""Build a 'catalogue match' line — uses the catalogue description verbatim
|
||
so the search_text fuzzy will hit."""
|
||
return {
|
||
"description": cat["description"],
|
||
"quantity": random.choice([1, 2, 3, 5, 10]),
|
||
}
|
||
|
||
|
||
def make_custom_line():
|
||
return {
|
||
"description": random.choice(CUSTOM_DESCRIPTIONS),
|
||
"quantity": random.choice([1, 2, 3, 4]),
|
||
}
|
||
|
||
|
||
def make_quote(token, customer_id, quote_number_int, line_count, custom_ratio, catalogue):
|
||
"""Build a quote + its quote_items. Returns (quote_id, item_ids)."""
|
||
n_catalogue = max(0, int(round(line_count * (1 - custom_ratio))))
|
||
n_custom = line_count - n_catalogue
|
||
chosen = random.sample(catalogue, k=min(n_catalogue, len(catalogue)))
|
||
lines = [make_line(c) for c in chosen]
|
||
for _ in range(n_custom):
|
||
lines.append(make_custom_line())
|
||
|
||
# IMPORTANT: do not set `claimed_by` here — even though status is `new`
|
||
# (so the Claim button still works), the quote detail page renders a
|
||
# "Claimed by X" pill as soon as `claimed_by` is truthy, and that
|
||
# signals to the operator that someone else has their hands on it.
|
||
# These seeds have no agent hold on them, so the field must stay
|
||
# empty. Use `notes` as the cleanup marker instead — see CLEANUP_TAG.
|
||
quote_payload = {
|
||
"quote_number": f"Q{quote_number_int}",
|
||
"quote_reference": f"GIELIE-TEST-{quote_number_int}",
|
||
"customer": customer_id,
|
||
"status": "new",
|
||
"notes": (
|
||
f"{CLEANUP_TAG} — {line_count} line items, {n_custom} non-catalogue. "
|
||
"No pricing set."
|
||
),
|
||
"description": f"Seed quote {quote_number_int} — {line_count} line items, {n_custom} non-catalogue.",
|
||
}
|
||
quote = req("POST", "/api/collections/quotes/records", token=token, body=quote_payload)
|
||
quote_id = quote["id"]
|
||
|
||
created_items = []
|
||
for i, line in enumerate(lines, 1):
|
||
item_payload = {
|
||
"quote": quote_id,
|
||
"description": line["description"],
|
||
"quantity": line["quantity"],
|
||
# intentionally leave cost_price, selling_price, supplier, sku, markup empty
|
||
# so the quote lands in the "unpriced" bucket the agent picks up.
|
||
"is_processed": False,
|
||
"user_edited": True, # seed as user-edited so the AI rewrite gate doesn't block dry-runs
|
||
}
|
||
it = req("POST", "/api/collections/quote_items/records", token=token, body=item_payload)
|
||
created_items.append(it["id"])
|
||
|
||
return {
|
||
"quote_id": quote_id,
|
||
"quote_number": f"Q{quote_number_int}",
|
||
"line_count": line_count,
|
||
"n_custom": n_custom,
|
||
"n_catalogue": n_catalogue,
|
||
"item_ids": created_items,
|
||
}
|
||
|
||
|
||
def main():
|
||
token = mint_token()
|
||
customer_id = get_or_create_customer(token, "GIELIE")
|
||
catalogue = fetch_catalogue_sample(token, n=80)
|
||
print(f"[catalogue] sampled {len(catalogue)} rows")
|
||
|
||
start_q = next_quote_number(token)
|
||
print(f"[quote_number] next = Q{start_q}")
|
||
|
||
# Layout: 1×1 line, 1×3 lines, 1×4 lines, 1×10 lines, 1×7 lines (heavy custom)
|
||
shapes = [
|
||
(1, 0.0),
|
||
(3, 0.34), # ~1 of 3 is custom
|
||
(4, 0.50), # 2 of 4 custom
|
||
(10, 0.30), # 3 of 10 custom
|
||
(7, 0.71), # 5 of 7 custom — heavy non-match to stress Tier 3 + no-match
|
||
]
|
||
|
||
random.seed(42)
|
||
results = []
|
||
for i, (size, custom_ratio) in enumerate(shapes):
|
||
qn = start_q + i
|
||
out = make_quote(token, customer_id, qn, size, custom_ratio, catalogue)
|
||
results.append(out)
|
||
print(f"[quote] {out['quote_number']} id={out['quote_id']} lines={out['line_count']} (custom {out['n_custom']} / catalogue {out['n_catalogue']})")
|
||
|
||
print("\n=== SUMMARY ===")
|
||
for r in results:
|
||
print(json.dumps(r, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|