WFDASH-WEBAPP/scripts/migrations/2026-06-30-add-quote-items-sku-and-pricing.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

163 lines
5.7 KiB
TypeScript

/* eslint-disable */
/**
* Migration — extend `quote_items` with the fields the new
* "Check pricing" feature needs:
*
* 1. `sku` (text, optional, indexed)
* What the buyer asked for. The Check-pricing button matches
* this against `price_list.sku` (and the normalised
* `price_list.sku_normalised`). For items extracted from email
* the n8n Code node copies it in from the LLM-extracted
* description. For manually-added items the operator types it
* in the process page input.
*
* 2. `pricing_source` (select, optional)
* Where the cost_price / selling_price values came from. Used
* by the UI badge on the process page ("✓ from catalogue",
* "○ no match", …). Free text for new sources we haven't
* enumerated yet (v2 RFQ flow).
*
* 3. `matched_price_list_id` (text, optional)
* The `price_list.id` row we matched against on the last
* Check-pricing run. Lets us re-resolve the supplier / spec /
* description without re-matching. Storing the id (not a
* relation) keeps the schema simple — the helper handles the
* resolution.
*
* 4. `pricing_notes` (text, optional)
* Free-text audit trail. Examples:
* "Auto-matched from price list (JEISA - WESTSCOTT GROUP) on 2026-06-30"
* "No match in catalogue — operator to follow up"
* "Catalogue had 3 candidates; picked newest"
* Free-text (not a select) so the operator / AI can append
* without a schema change.
*
* Idempotent — each step skips if its field already exists.
*
* Run with:
* npx tsx scripts/migrations/2026-06-30-add-quote-items-sku-and-pricing.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
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 {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
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(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[quote-items-sku] Authenticated against ${url}`);
const quoteItemsCol = await pb.collections.getOne("quote_items");
const existingNames = new Set(quoteItemsCol.fields.map((f) => f.name));
const existingIndexes = (quoteItemsCol.indexes ?? []) as string[];
// Build the additive field list. Skip any fields that already
// exist — this makes the migration safe to re-run after a partial
// success on a previous attempt.
const newFields: Array<Record<string, unknown>> = [];
if (!existingNames.has("sku")) {
newFields.push({
name: "sku",
type: "text",
required: false,
presentable: false,
options: { max: 100, pattern: "" },
});
}
if (!existingNames.has("pricing_source")) {
newFields.push({
name: "pricing_source",
type: "text",
required: false,
presentable: false,
// Free text rather than a select enum on purpose. The set of
// sources may grow (catalogue, rfq, manual, rfq_pending,
// catalogue_ambiguous in v2) and free text keeps the schema
// forward-compatible without a migration per addition. The UI
// badge maps known values to icons / colours and falls back to
// a neutral chip for anything new.
options: { max: 32, pattern: "" },
});
}
if (!existingNames.has("matched_price_list_id")) {
newFields.push({
name: "matched_price_list_id",
type: "text",
required: false,
presentable: false,
options: { max: 32, pattern: "" },
});
}
if (!existingNames.has("pricing_notes")) {
newFields.push({
name: "pricing_notes",
type: "text",
required: false,
presentable: false,
options: { max: 500, pattern: "" },
});
}
if (newFields.length > 0) {
await pb.collections.update(quoteItemsCol.id, {
fields: [...quoteItemsCol.fields, ...newFields],
});
console.log(`[quote-items-sku] Added fields: ${newFields.map((f) => f.name).join(", ")}.`);
} else {
console.log("[quote-items-sku] All pricing fields already present — skipping field add.");
}
// Re-fetch so we see the post-update indexes.
const fresh = await pb.collections.getOne("quote_items");
const hasSkuIdx = (fresh.indexes ?? []).some(
(i) => typeof i === "string" && i.includes("idx_qi_sku"),
);
if (!hasSkuIdx) {
await pb.collections.update(fresh.id, {
indexes: [
...(fresh.indexes ?? []),
"CREATE INDEX `idx_qi_sku` ON `quote_items` (`sku`)",
],
});
console.log("[quote-items-sku] Added idx_qi_sku.");
} else {
console.log("[quote-items-sku] idx_qi_sku already present — skipping.");
}
console.log("[quote-items-sku] Migration complete.");
// Quiet any unused-var warning when this script is checked with
// strict rules — `existingIndexes` is kept as documentation of the
// schema state we read.
void existingIndexes;
}
main().catch((err) => {
console.error("[quote-items-sku] Migration failed:", err);
process.exit(1);
});