/* eslint-disable */ /** * One-shot seed + matcher verification. * * Creates a tiny fleet of test quotes + orders covering every * matcher strategy: * * - Quote A + Order A: matched on `quote_number` * - Quote B + Order B: matched on `rfq_number` * - Quote C + Order C: NO reference text — fuzzy fallback * (matched on company + customer + items + price) * * Runs the actual matcher (`findMatchingQuotesForOrder`) on each * order and prints what it picked + how confident it was. Does NOT * persist the match verdicts to PB — pure read against the fresh * data so you can re-run safely. * * Run with: * npx tsx scripts/dev/seed-and-test-matcher.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // Load .env.local (tsx doesn't auto-load it). function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch { // ignore } } loadEnvFile(".env"); loadEnvFile(".env.local"); // Import the matcher AFTER env is loaded so PB config is available. async function loadMatcher() { // Absolute path so tsx resolves it from the script's location. const mod = await import("../../src/lib/pb-helpers"); return mod; } async function main() { const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; const email = process.env.PB_ADMIN_EMAIL; const password = process.env.PB_ADMIN_PASSWORD; if (!url || !email || !password) { throw new Error("Missing PB env vars (NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)."); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[seed-test] Authenticated against ${url}`); // Wipe any previous run of this script so we start clean. Use a // hard-coded marker tag on each created record so we never touch // real data. const MARKER = "[seed-test-matcher]"; const old = await pb.collection("quote_items").getFullList({ filter: `notes ~ "${MARKER}"` }); for (const r of old) await pb.collection("quote_items").delete(r.id); const oldQ = await pb.collection("quotes").getFullList({ filter: `notes ~ "${MARKER}"` }); for (const r of oldQ) await pb.collection("quotes").delete(r.id); const oldO = await pb.collection("order_items").getFullList({ filter: `notes ~ "${MARKER}"` }); for (const r of oldO) await pb.collection("order_items").delete(r.id); const oldO2 = await pb.collection("orders").getFullList({ filter: `notes ~ "${MARKER}"` }); for (const r of oldO2) await pb.collection("orders").delete(r.id); console.log(`[seed-test] Wiped ${old.length} quote_items, ${oldQ.length} quotes, ${oldO.length} order_items, ${oldO2.length} orders from previous runs.`); // Pick the first company + customer so the seed is realistic. const company = (await pb.collection("companies").getList(1, 1)).items[0]; const customer = (await pb.collection("customers").getList(1, 1, { filter: `company = "${company.id}"`, })).items[0] || (await pb.collection("customers").getList(1, 1)).items[0]; if (!company || !customer) { throw new Error("No companies or customers in PB — add at least one of each before running this seed."); } console.log(`[seed-test] Using company=${company.company} customer=${customer.customer}`); // ─── Three test quotes ──────────────────────────────────────── // Quote A: full set of identifiers + items that match what // Order A will reference. const quoteA = await pb.collection("quotes").create({ quote_number: "Q-TEST-A", quote_reference: "CUST-PO-AAA", rfq_number: "RFQ-AAA", external_number: "EXT-AAA", company: company.id, customer: customer.id, status: "approved", notes: MARKER, }); await pb.collection("quote_items").create({ quote: quoteA.id, description: "M20 x 40 hex nuts grade 8.8", quantity: 100, selling_price: 12.50, cost_price: 8.00, notes: MARKER, }); // Quote B: only rfq_number populated (this is the 45% case where // the customer didn't reference our quote number, just gave us // an RFQ id from their internal system). const quoteB = await pb.collection("quotes").create({ quote_number: "Q-TEST-B", rfq_number: "RFQ-BBB", company: company.id, customer: customer.id, status: "approved", notes: MARKER, }); await pb.collection("quote_items").create({ quote: quoteB.id, description: "Stainless steel cable gland M25 IP68", quantity: 50, selling_price: 28.75, cost_price: 18.50, notes: MARKER, }); // Quote C: no identifying ref at all (the deepest 45% slice). // Only items + company + customer for the fuzzy path. const quoteC = await pb.collection("quotes").create({ quote_number: "Q-TEST-C", company: company.id, customer: customer.id, status: "approved", notes: MARKER, }); await pb.collection("quote_items").create({ quote: quoteC.id, description: "Heavy duty ratchet strap 50mm x 10m", quantity: 20, selling_price: 145.00, cost_price: 95.00, notes: MARKER, }); console.log("[seed-test] Created 3 test quotes: Q-TEST-A, Q-TEST-B, Q-TEST-C"); // ─── Three test orders ──────────────────────────────────────── // Order A: operator typed "Q-TEST-A" as the reference. Should // match via exact_quote_number. const orderA = await pb.collection("orders").create({ order_number: "TEST-ORDER-A", company: company.id, customer: customer.id, quote_reference_text: "Q-TEST-A", notes: MARKER, }); await pb.collection("order_items").create({ order: orderA.id, description: "M20 x 40 hex nuts grade 8.8", quantity: 100, selling_price: 12.50, item_status: "pending", in_stock: false, notes: MARKER, }); // Order B: operator typed "RFQ-BBB" (just the RFQ id). Should // match via exact_rfq_number. const orderB = await pb.collection("orders").create({ order_number: "TEST-ORDER-B", company: company.id, customer: customer.id, quote_reference_text: "RFQ-BBB", notes: MARKER, }); await pb.collection("order_items").create({ order: orderB.id, description: "Stainless steel cable gland M25 IP68", quantity: 50, selling_price: 28.75, item_status: "pending", in_stock: false, notes: MARKER, }); // Order C: NO reference text at all — fuzzy fallback path. The // matcher has to find Quote C by company + customer + items + price. const orderC = await pb.collection("orders").create({ order_number: "TEST-ORDER-C", company: company.id, customer: customer.id, // Intentionally empty — fuzzy path. quote_reference_text: "", notes: MARKER, }); await pb.collection("order_items").create({ order: orderC.id, description: "Heavy duty ratchet strap 50mm x 10m", quantity: 20, selling_price: 145.00, item_status: "pending", in_stock: false, notes: MARKER, }); console.log("[seed-test] Created 3 test orders: TEST-ORDER-A, TEST-ORDER-B, TEST-ORDER-C"); // ─── Run the matcher against each order ────────────────────── // The matcher accepts an optional `pb` so server-side scripts can // pass an admin-authenticated instance. The client-side UI passes // nothing and gets the singleton. const { findMatchingQuotesForOrder, applyOrderQuoteMatch } = await loadMatcher(); const tests = [ { label: "Order A — exact quote_number", orderId: orderA.id, expected: "Q-TEST-A" }, { label: "Order B — exact rfq_number", orderId: orderB.id, expected: "Q-TEST-B" }, { label: "Order C — fuzzy fallback", orderId: orderC.id, expected: "Q-TEST-C" }, ]; console.log("\n=== MATCHER RESULTS ===\n"); let allOk = true; for (const t of tests) { // Re-fetch the order so the matcher sees `quote_reference_text` etc. const order = await pb.collection("orders").getOne(t.orderId); const items = await pb.collection("order_items").getFullList({ filter: `order = "${t.orderId}"` }); const result = await findMatchingQuotesForOrder(order as any, items as any, pb); const best = result.best; const got = best?.quote?.quote_number || "(none)"; const method = result.method; const conf = best ? (best.confidence * 100).toFixed(0) + "%" : "—"; const ok = got === t.expected; console.log(`${ok ? "PASS" : "FAIL"} ${t.label}`); console.log(` expected: ${t.expected} got: ${got} via: ${method} confidence: ${conf}`); if (!ok) allOk = false; if (best && best.alternatives && best.alternatives.length > 0) { console.log(` alternatives:`, best.alternatives.map((a) => a.quote.quote_number).join(", ")); } } console.log(`\n${allOk ? "ALL MATCHED" : "SOME FAILED"}`); // ─── Persist the verdicts so the operator can see them in the UI ─ // Hard-link each order to its suggested quote so the operator can // click around in the live UI and see the Linked Quote pill + // Linked Orders card. for (const t of tests) { const order = await pb.collection("orders").getOne(t.orderId); const items = await pb.collection("order_items").getFullList({ filter: `order = "${t.orderId}"` }); const result = await findMatchingQuotesForOrder(order as any, items as any, pb); await applyOrderQuoteMatch(t.orderId, result, true, pb); } console.log("[seed-test] Hard-linked all 3 orders to their suggested quotes."); console.log("[seed-test] Open /orders in the UI to see the Linked Quote pill, or /quotes/ to see the Linked Orders card."); } main().catch((err) => { console.error("[seed-test] Failed:", err); process.exit(1); });