WFDASH-WEBAPP/scripts/migrations/2026-06-30-backfill-price-list-search-text.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

177 lines
5.7 KiB
TypeScript

/**
* One-time backfill — rebuild `price_list.search_text` for every
* existing row using the comprehensive builder in
* `src/lib/price-list-search.ts`, AND fix the `supplier` column on
* legacy rows where it stores a PB id instead of the resolved name.
*
* Why this exists alongside the search_text migration:
* The search_text migration only ADDS the column + index. It
* does not rewrite rows — adding the column left the existing
* 46 rows with `search_text = ""` and `supplier = "q6hpwbxsicyoiuu"`.
*
* This script:
* 1. Resolves the supplier name from raw_price_list →
* suppliers expansion (so `supplier` becomes the human
* name, not the PB id).
* 2. Rebuilds `search_text` from EVERY text column on the
* row PLUS the full `specifications` JSON PLUS the
* tokenised number columns PLUS the resolved supplier name.
*
* Together with `getPriceLists` (which now ORs against every
* text column AND the search_text blob), typing anything —
* "jeisa", "12mm", "118", "slugger", "turbo steel", "each" —
* matches.
*
* Idempotent: re-runs only PATCH a row if the rebuilt value differs
* from what's currently stored. Safe to run on every deploy.
*
* Run with:
* npm run pb:backfill:price-list-search-text
*/
import PocketBase from "pocketbase";
import {
buildPriceListSearchText,
type SearchTextSource,
} from "../../src/lib/price-list-search";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required.");
}
type PriceListRow = SearchTextSource & {
id: string;
supplier?: string;
source_file?: string;
};
type RawPriceListRow = {
id: string;
supplier_name?: string | null;
expand?: {
supplier_name?: {
id: string;
suppliername?: string;
};
};
};
/**
* Look up the supplier name for a price_list row by following
* `source_file` → `raw_price_list` → `expand.supplier_name`.
* Returns the resolved name (e.g. "JEISA - WESTSCOTT GROUP") or
* null if the chain is broken (manually-created row with no
* source_file, or the raw_price_list was deleted).
*/
async function resolveSupplierName(
pb: PocketBase,
sourceFile: string | undefined,
fallback: string | undefined,
): Promise<string | null> {
if (!sourceFile) return fallback ?? null;
try {
const raw = await pb
.collection("raw_price_list")
.getOne<RawPriceListRow>(sourceFile, { expand: "supplier_name" });
const name = raw.expand?.supplier_name?.suppliername;
if (name) return name;
} catch {
// raw_price_list row is gone — fall back to whatever the
// price_list row stored (could be an old id or a real name).
}
return fallback ?? null;
}
/**
* PB ids are 15 lowercase alphanumeric characters (a-z, 0-9).
* Used to decide whether a legacy `supplier` value is actually
* an id that the row expects the API to expand.
*/
function looksLikePbId(value: string | undefined): boolean {
return !!value && /^[a-z0-9]{15}$/.test(value);
}
async function main() {
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL!, ADMIN_PASSWORD!);
let page = 1;
const perPage = 200;
let scanned = 0;
let patchedSearch = 0;
let patchedSupplier = 0;
let unchanged = 0;
const sampleRebuilds: Array<{ id: string; before: string; after: string }> = [];
// One pagination loop over every price_list row.
while (true) {
const result = await pb.collection("price_list").getList<PriceListRow>(page, perPage, {
sort: "-updated",
});
if (result.items.length === 0) break;
for (const row of result.items) {
scanned++;
// 1. Resolve supplier name from raw_price_list.
const resolvedSupplier = await resolveSupplierName(
pb,
row.source_file,
looksLikePbId(row.supplier) ? "" : row.supplier,
);
// 2. Build the new search_text.
const newSearchText = buildPriceListSearchText(row, resolvedSupplier);
// 3. Decide what to PATCH.
const patch: Record<string, string> = {};
if ((row as unknown as Record<string, unknown>).search_text !== newSearchText) {
patch.search_text = newSearchText;
}
if (
resolvedSupplier &&
resolvedSupplier !== row.supplier
) {
patch.supplier = resolvedSupplier;
}
if (Object.keys(patch).length === 0) {
unchanged++;
} else {
if (patch.search_text !== undefined) patchedSearch++;
if (patch.supplier !== undefined) patchedSupplier++;
if (sampleRebuilds.length < 3 && patch.search_text !== undefined) {
sampleRebuilds.push({
id: row.id,
before: String((row as unknown as Record<string, unknown>).search_text ?? ""),
after: newSearchText,
});
}
await pb.collection("price_list").update(row.id, patch);
}
}
if (result.items.length < perPage) break;
page++;
}
console.log(`[backfill] scanned ${scanned} rows across ${page} page(s).`);
console.log(`[backfill] patched search_text: ${patchedSearch}, supplier: ${patchedSupplier}, unchanged: ${unchanged}`);
if (sampleRebuilds.length > 0) {
console.log("[backfill] sample rebuilds:");
for (const s of sampleRebuilds) {
console.log(` ${s.id}:`);
console.log(` before: ${s.before.slice(0, 120)}${s.before.length > 120 ? "…" : ""}`);
console.log(` after: ${s.after.slice(0, 240)}${s.after.length > 240 ? "…" : ""}`);
}
}
console.log("[backfill] done.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});