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
177 lines
5.7 KiB
TypeScript
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);
|
|
});
|