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
138 lines
4.7 KiB
TypeScript
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);
|
|
});
|