WFDASH-WEBAPP/scripts/smoke/re-price.ts
SJCoetz 9cdcb3b096 feat: implement sku-based catalogue pricing matching workflow
This commit adds the full end-to-end SKU pricing match flow for quote items:
- Add new QuoteItem and PriceListRow fields for SKU and pricing metadata
- Add sku_normalised to price_list for robust format-agnostic SKU matching
- Refactor price list search to use modular search builder and filter helpers
- Add database migrations, backfill scripts, and new package commands
- Update catalogue search UI and fix quote list accidental click behavior
- Add comprehensive unit, smoke, and end-to-end test suites
- Update documentation for the new price search and extraction logic
2026-06-30 21:20:53 +02:00

131 lines
5 KiB
TypeScript

/* eslint-disable */
/**
* Smoke test — exercise the re-price flow end-to-end.
*
* 1. Pick an existing quote with at least one quote_item.
* 2. Snapshot the current state of N items.
* 3. Set SKU on each item to a real catalogue row.
* 4. Run the same matcher the endpoint uses (findFirstPriceListBySku),
* emulate the patch the endpoint would write.
* 5. Restore the original values (so we don't leave the live DB
* in a half-baked state for the test).
*
* Run with:
* env PB_ADMIN_EMAIL=… PB_ADMIN_PASSWORD=… NEXT_PUBLIC_POCKETBASE_URL=…
* npx tsx scripts/smoke/re-price.ts
*/
import {
findFirstPriceListBySku,
type PriceListMatch,
} from "../../src/lib/server/price-list-match";
import PocketBase from "pocketbase";
type QuoteItemSnapshot = {
id: string;
cost_price?: number;
selling_price?: number;
description?: string;
supplier?: string;
sku?: string;
pricing_source?: string;
matched_price_list_id?: string;
pricing_notes?: string;
};
async function main() {
const URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!URL || !ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, NEXT_PUBLIC_POCKETBASE_URL.");
}
const pb = new PocketBase(URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
// 1. Find a quote with at least 2 items we can play with.
const candidates = await pb.collection("quote_items").getList(1, 30, {
sort: "+created",
});
if (candidates.items.length < 2) {
throw new Error("Need ≥2 quote items to run this smoke test.");
}
const items = candidates.items.slice(0, 3);
const quoteId = items[0].quote;
// 2. Snapshot the originals so we can restore at the end.
const snapshots: QuoteItemSnapshot[] = items.map((it) => ({
id: it.id,
cost_price: it.cost_price ?? 0,
selling_price: it.selling_price ?? 0,
description: it.description ?? "",
supplier: it.supplier ?? "",
sku: it.sku ?? "",
pricing_source: it.pricing_source ?? "",
matched_price_list_id: it.matched_price_list_id ?? "",
pricing_notes: it.pricing_notes ?? "",
}));
console.log(`[re-price smoke] quote=${quoteId}, picked items: ${items.map((it) => it.id).join(", ")}`);
// 3. SKUs to inject. Two real catalogue SKUs + one nonsense.
const testCases: Array<{ id: string; sku: string; label: string }> = [
{ id: items[0].id, sku: "JEITD-06", label: "exact (twist drill 6mm)" },
{ id: items[1].id, sku: "jeitd-08", label: "case-insensitive (twist drill 8mm)" },
{ id: items[2].id, sku: "NOPE-NOPE-99", label: "garbage (expect no match)" },
];
for (const tc of testCases) {
await pb.collection("quote_items").update(tc.id, { sku: tc.sku });
}
// 4. Run the matcher + emulate the patch the endpoint writes.
console.log("\n[re-price smoke] Running matcher over each item");
for (const tc of testCases) {
const match = await findFirstPriceListBySku(tc.sku, {}, pb as unknown as PocketBase);
if (!match) {
console.log(` ${tc.label.padEnd(38)} → no match (expected for "${tc.sku}")`);
await pb.collection("quote_items").update(tc.id, {
pricing_notes: `No match for SKU "${tc.sku}"`,
});
continue;
}
const patch = {
cost_price: match.cost_price,
selling_price: match.selling_price,
description: (match.description ?? "").trim() || (snapshots.find((s) => s.id === tc.id)?.description ?? ""),
pricing_source: "catalogue",
matched_price_list_id: match.id,
pricing_notes: `Auto-matched from ${match.supplier} on ${new Date().toISOString().slice(0, 10)}`,
};
console.log(` ${tc.label.padEnd(38)} → matched ${match.sku} (${match.supplier})`);
console.log(` patch: cost=${patch.cost_price}, sell=${patch.selling_price}, pnotes="${patch.pricing_notes}"`);
await pb.collection("quote_items").update(tc.id, patch);
}
// 5. Verify by re-reading.
console.log("\n[re-price smoke] Verifying patched state");
for (const tc of testCases) {
const it = await pb.collection("quote_items").getOne(tc.id);
console.log(` ${tc.label.padEnd(38)} sku="${it.sku}" cost=${it.cost_price} sell=${it.selling_price} source="${it.pricing_source}" mid="${it.matched_price_list_id}"`);
}
// 6. Restore — don't leave the live DB half-set.
console.log("\n[re-price smoke] Restoring snapshots");
for (const snap of snapshots) {
await pb.collection("quote_items").update(snap.id, {
cost_price: snap.cost_price,
selling_price: snap.selling_price,
description: snap.description,
supplier: snap.supplier || null,
sku: snap.sku,
pricing_source: snap.pricing_source,
matched_price_list_id: snap.matched_price_list_id,
pricing_notes: snap.pricing_notes,
});
}
console.log("[re-price smoke] Done. DB restored.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});