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