WFDASH-WEBAPP/scripts/dev/seed-and-test-matcher.ts

263 lines
10 KiB
TypeScript
Raw Normal View History

/* 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/<id> to see the Linked Orders card.");
}
main().catch((err) => {
console.error("[seed-test] Failed:", err);
process.exit(1);
});