233 lines
8.6 KiB
Python
233 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()
|