263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
|
|
/* 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);
|
||
|
|
});
|