WFDASH-WEBAPP/scripts/migrations/2026-06-30-add-price-list-sku-normalised.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

138 lines
4.7 KiB
TypeScript

/* eslint-disable */
/**
* Migration — add `sku_normalised` (text, indexed) to `price_list`.
*
* The catalogue SKU is normally uppercased and dash-separated
* (e.g. `JEITD-06`, `TS-DRL-12-55`). When a buyer's email / PO
* references the same product they often write it differently —
* lowercase x, no dashes, different ordering. The Check-pricing
* feature's `findPriceListBySku()` matcher needs to find "this is
* the same product" across those variants.
*
* `sku_normalised` is the uppercased, non-alphanumeric-stripped
* form of `sku`. `JEITD-06` becomes `JEITD06`. `TS-DRL-12-55`
* becomes `TSDRL1255`. The matcher uppercases + strips the buyer's
* input the same way, then a direct string equality gets
* canonical-form hits regardless of how the buyer typed it.
*
* Existing rows are backfilled once.
*
* Idempotent — re-runs add the column / index / backfill only if
* missing.
*
* Run with:
* npx tsx scripts/migrations/2026-06-30-add-price-list-sku-normalised.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 */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
/** Canonicalise a SKU: uppercase, strip everything that's not A-Z / 0-9.
* `JEITD-06` → `JEITD06`. Empty string → empty string. */
function normaliseSku(raw: string | null | undefined): string {
if (!raw) return "";
return raw.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
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(`[price-list-sku-normalised] Authenticated against ${url}`);
const priceList = await pb.collections.getOne("price_list");
// 1. Add the column if missing.
if (!priceList.fields.some((f) => f.name === "sku_normalised")) {
await pb.collections.update(priceList.id, {
fields: [
...priceList.fields,
{
name: "sku_normalised",
type: "text",
required: false,
presentable: false,
options: { max: 100, pattern: "" },
},
],
});
console.log("[price-list-sku-normalised] Added price_list.sku_normalised.");
} else {
console.log("[price-list-sku-normalised] price_list.sku_normalised already present — skipping.");
}
// 2. Add the index if missing.
const fresh = await pb.collections.getOne("price_list");
const hasIdx = (fresh.indexes ?? []).some(
(i) => typeof i === "string" && i.includes("idx_pl_sku_normalised"),
);
if (!hasIdx) {
await pb.collections.update(fresh.id, {
indexes: [
...(fresh.indexes ?? []),
"CREATE INDEX `idx_pl_sku_normalised` ON `price_list` (`sku_normalised`)",
],
});
console.log("[price-list-sku-normalised] Added idx_pl_sku_normalised.");
} else {
console.log("[price-list-sku-normalised] idx_pl_sku_normalised already present — skipping.");
}
// 3. Backfill — paginate, compute sku_normalised per row, PATCH if
// it differs from the stored value. Cheap with 46 rows; on 10k
// rows the index makes this O(rows).
let page = 1;
const perPage = 200;
let scanned = 0;
let patched = 0;
while (true) {
const batch = await pb.collection("price_list").getList(page, perPage, {
fields: "id,sku,sku_normalised",
});
if (batch.items.length === 0) break;
for (const row of batch.items) {
scanned++;
const expected = normaliseSku(row.sku as string | null);
if ((row as unknown as { sku_normalised?: string }).sku_normalised !== expected) {
await pb.collection("price_list").update(row.id, { sku_normalised: expected });
patched++;
}
}
if (batch.items.length < perPage) break;
page++;
}
console.log(`[price-list-sku-normalised] Backfilled ${patched} / ${scanned} rows.`);
console.log("[price-list-sku-normalised] Migration complete.");
}
main().catch((err) => {
console.error("[price-list-sku-normalised] Migration failed:", err);
process.exit(1);
});