WFDASH-WEBAPP/scripts/seed-gielie-quotes.py
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
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
2026-07-03 07:29:43 +02:00

232 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()