WFDASH-WEBAPP/scripts/seed-gielie-quotes.py

233 lines
8.6 KiB
Python
Raw Permalink Normal View History

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