/** * Backfill: mirror `price_list` rows onto `stock_items` rows whose * shelf-price is still zero. * * Context * ─────── * The till reads `stock_items.selling_price` but the team originally * intended prices to live on `price_list`. The 2026-09-14 sync * (price-list-service.syncPriceListToStockItem) closes the gap for * *future* `addPriceListItems` calls — but ~393 stock rows already * exist with `selling_price = 0` and a clean match waiting in * `price_list`. This script is the one-shot operator tool to backfill * them in one go. * * It also addresses a secondary gap: ~177 of those stock rows have * `cost_price = 0` too (created in the pre-Fix-#1 gap). The same * `price_list` match carries the supplier cost, so this script can * seed `cost_price` AND bootstrap `cost_history` in one round-trip. * * What it does * ──────────── * 1. Read every `stock_items` row where `selling_price = 0` (the * --sku flag narrows this to one row for debugging). * 2. For each row, look up `price_list` by `sku_normalised` * (using `normaliseSku` from price-list-match.ts) AND * `selling_price > 0` — the same canonical-form dedupe the rest * of the catalogue code uses, so "JEITD-06" and "JEITD06" both * hit the same row. * 3. If a `price_list` row matches: * - Propose `stock_items.selling_price` + `markup` patches * when the stock values are zero (never overwrite a * non-zero shelf price — same idempotency rule as the * existing sync helper). * - Also propose a `cost_price` patch + bootstrap one * `cost_history` entry when the stock `cost_price` is zero * AND the `price_list` row has a positive `cost_price`. * 4. If multiple `price_list` rows match the same normalised SKU * (rare; data-quality issue), take the newest and log a * `multi_match` warning. * 5. If no `price_list` row matches, report as `no_match` so the * operator can resolve later (probably add a price_list row or * fix the SKU on the stock row). * * Idempotency * ─────────── * - Never overwrite a non-zero `stock_items.selling_price`. * - Never overwrite a non-zero `stock_items.cost_price` (when * we're proposing the cost-side sibling patch). * - Always APPEND to `cost_history` when patching `cost_price` * for the first time — matches the Quick Draft bootstrap shape * so the accountant's later `bookStockFromInvoice` can * recognise the seed entry. * - Re-running the script produces no further changes once every * row has either a match (patched) or a deliberate `no_match`. * * Modes * ───── * - Default (no flag): dry-run. Lists every candidate patch; does * not touch PB. This is the operator's safety net — review the * list, then re-run with --commit. * - --commit: actually PATCH the matched stock_items rows. * - --limit=N: process at most N stock rows (after --sku filter). * Useful for a smoke test on the first 10 rows. * - --sku=ABC: process only the single stock row whose `sku` * equals ABC (case-insensitive, whitespace-trimmed). * * Run with: * npx tsx scripts/backfill-stock-prices.ts # dry-run * npx tsx scripts/backfill-stock-prices.ts --commit # write * npx tsx scripts/backfill-stock-prices.ts --limit=10 # cap rows * npx tsx scripts/backfill-stock-prices.ts --sku=ABC123 # one SKU * * Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, * PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. */ import "./_env"; // side-effect import: loads .env.local before anything else import type { StockCostHistoryEntry } from "@/lib/types"; import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; import { normaliseSku, escapeForPbString } from "../src/lib/server/price-list-match"; // ─── CLI parsing ─────────────────────────────────────────────────────── type CliOptions = { commit: boolean; limit: number | null; sku: string | null; }; function parseCli(argv: string[]): CliOptions { const opts: CliOptions = { commit: false, limit: null, sku: null, }; for (const arg of argv) { if (arg === "--commit") { opts.commit = true; } else if (arg === "--dry-run") { // Default behaviour; explicit flag for clarity in cron logs. opts.commit = false; } else if (arg.startsWith("--limit=")) { const n = Number(arg.slice("--limit=".length)); if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) { throw new Error(`--limit must be a positive integer, got "${arg}"`); } opts.limit = n; } else if (arg.startsWith("--sku=")) { const raw = arg.slice("--sku=".length).trim(); if (!raw) throw new Error(`--sku must be non-empty, got "${arg}"`); opts.sku = raw; } else if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); } else { // Unknown flag — surface it so typos aren't silently ignored. throw new Error(`Unknown CLI flag: ${arg}`); } } return opts; } function printHelp(): void { console.log(`Backfill stock_items prices from price_list. Usage: tsx scripts/backfill-stock-prices.ts # dry-run (default) tsx scripts/backfill-stock-prices.ts --commit # write to PB tsx scripts/backfill-stock-prices.ts --commit --limit=10 # cap rows (smoke) tsx scripts/backfill-stock-prices.ts --sku=ABC123 # single SKU Flags: --commit Actually PATCH stock_items. Default is dry-run. --limit=N Process at most N stock rows. --sku=ABC Only the stock row whose sku matches ABC. --help, -h Show this help. Output: one line per scanned row + a totals block at the bottom.`); } // ─── Types ───────────────────────────────────────────────────────────── /** Subset of a `stock_items` row the decision function needs. */ export type BackfillStockSnapshot = { id: string; sku: string; selling_price?: number | null; markup?: number | null; cost_price?: number | null; cost_history?: unknown; }; /** Subset of a `price_list` row the decision function needs. */ export type BackfillPriceListCandidate = { id: string; sku: string; sku_normalised: string; cost_price: number; selling_price: number; markup: number; updated?: string; }; /** * The pure decision: given a stock_item snapshot and the price_list * match (or null when no match exists), what should we patch? * * Returns a discriminated union: * - `patch` — at least one field will change * - `skipped_idempotent` — defensive skip (the row already had a * non-zero value where we wanted to * write; never clobber) * - `no_match` — no price_list row matches; nothing to do * * Pure: no PB calls, no Date.now() side effects beyond the * `purchased_at` stamp on a potential cost_history entry. That * timestamp is exposed as a separate argument so tests can pin it * deterministically; the script's runner passes `new Date().toISOString()`. */ export type BackfillDecision = | { kind: "patch"; stockItemId: string; sku: string; priceListId: string; /** True when more than one price_list row matched the * sku_normalised — the caller should log a warning. */ multiMatch: boolean; patch: { selling_price?: { from: number; to: number }; markup?: { from: number; to: number }; cost_price?: { from: number; to: number }; cost_history_append?: StockCostHistoryEntry; }; } | { kind: "skipped_idempotent"; stockItemId: string; sku: string; priceListId: string; reason: "selling_price_already_set"; } | { kind: "no_match"; stockItemId: string; sku: string; }; /** The patch fields carried on a `BackfillDecision` of kind `patch`. */ export type BackfillDecisionPatch = { selling_price?: { from: number; to: number }; markup?: { from: number; to: number }; cost_price?: { from: number; to: number }; cost_history_append?: StockCostHistoryEntry; }; /** * Pure decision function — exported for unit tests. * * Rules * ───── * - If `priceListCandidate` is null → `no_match`. * - If `priceListCandidate.selling_price <= 0` → `no_match` * (caller is supposed to filter these out at the PB level; * we still guard here for safety). * - If `stockItem.selling_price > 0` → `skipped_idempotent` * (never clobber a shelf price already on the row). * - Propose `selling_price` patch when stock has 0 AND candidate * has positive value. * - Propose `markup` patch when stock has 0 AND candidate has * positive value. * - Propose `cost_price` patch AND `cost_history_append` when * stock `cost_price` is 0 AND candidate `cost_price` is * positive. The cost_history entry uses the same shape as * `stock-quick-draft-service` inline-create bootstrap: empty * `purchase_id`, zero quantity, the stamp from `nowIso`. * * The PB-level filter (`selling_price > 0` on `price_list`) means * a candidate will always have a sellable price — so once we * reach the bottom of the function with `stock.selling_price = 0`, * the `selling_price` patch is always at least proposed. */ export function decideBackfill(args: { stockItem: BackfillStockSnapshot; priceListCandidate: BackfillPriceListCandidate | null; /** Multiple price_list rows matched the same sku_normalised * (sorted newest-first; the caller picked one). Carried on the * decision so the script can log the warning. */ multiMatch: boolean; /** ISO timestamp used to stamp the bootstrap `cost_history` * entry. Injected for testability — the runner passes * `new Date().toISOString()`. */ nowIso: string; }): BackfillDecision { const { stockItem, priceListCandidate, multiMatch, nowIso } = args; if (!priceListCandidate) { return { kind: "no_match", stockItemId: stockItem.id, sku: stockItem.sku, }; } const stockSelling = Number(stockItem.selling_price ?? 0); const stockMarkup = Number(stockItem.markup ?? 0); const stockCost = Number(stockItem.cost_price ?? 0); // Defensive: the PB filter should already exclude candidates // with selling_price <= 0, but a race (or a manually-edited // row) might surface one. Treat as no-match rather than // silently writing 0. if (!(Number(priceListCandidate.selling_price) > 0)) { return { kind: "no_match", stockItemId: stockItem.id, sku: stockItem.sku, }; } // Idempotency: the shelf price is already set on the stock // row. We never overwrite — the till or a manual catalogue // edit owns that number now. if (stockSelling > 0) { return { kind: "skipped_idempotent", stockItemId: stockItem.id, sku: stockItem.sku, priceListId: priceListCandidate.id, reason: "selling_price_already_set", }; } const patch: BackfillDecisionPatch = {}; // Patch #1: selling_price. const candidateSelling = Number(priceListCandidate.selling_price); if (candidateSelling > 0) { patch.selling_price = { from: stockSelling, to: candidateSelling }; } // Patch #2: markup. Only when stock.markup is 0 AND // candidate.markup is positive. const candidateMarkup = Number(priceListCandidate.markup ?? 0); if (stockMarkup <= 0 && candidateMarkup > 0) { patch.markup = { from: stockMarkup, to: candidateMarkup }; } // Patch #3: cost_price + cost_history. Only when stock.cost_price // is 0 AND candidate.cost_price is positive. We APPEND to // cost_history (rather than overwriting) — matches the Quick // Draft bootstrap shape and lets the accountant's later // `bookStockFromInvoice` recognise the seed entry as a // "manual backfill" origin. const candidateCost = Number(priceListCandidate.cost_price ?? 0); if (stockCost <= 0 && candidateCost > 0) { patch.cost_price = { from: stockCost, to: candidateCost }; patch.cost_history_append = { cost_price: candidateCost, quantity: 0, purchased_at: nowIso, purchase_id: "", }; } return { kind: "patch", stockItemId: stockItem.id, sku: stockItem.sku, priceListId: priceListCandidate.id, multiMatch, patch, }; } // ─── Formatting ───────────────────────────────────────────────────────── /** Format a number as R X.XX — or R 0 if not finite. */ function fmtMoney(n: number): string { if (!Number.isFinite(n)) return "R 0.00"; return `R ${n.toFixed(2)}`; } /** * Render a one-liner for a decision. Used in both dry-run and * --commit output so the operator can scan a dry-run and know * exactly which rows would change before re-running with * --commit. */ export function formatDecisionLine(args: { decision: BackfillDecision; /** "would_patch" in dry-run, "patched" in --commit. Other kinds * are unchanged either way. */ verbWhenPatch: "would_patch" | "patched"; }): string { const { decision, verbWhenPatch } = args; const verb = (kind: "patch" | "skip") => kind === "patch" ? verbWhenPatch : "skipped_idempotent"; if (decision.kind === "no_match") { return `no_match sku=${decision.sku}`; } if (decision.kind === "skipped_idempotent") { return `${verb("skip")} sku=${decision.sku} match=price_list:${decision.priceListId} reason=${decision.reason}`; } const parts: string[] = []; parts.push(`${verb("patch")} sku=${decision.sku} match=price_list:${decision.priceListId}`); if (decision.multiMatch) { parts.push("(multi_match)"); } const p = decision.patch; if (p.selling_price) { parts.push(`selling: ${fmtMoney(p.selling_price.from)} -> ${fmtMoney(p.selling_price.to)}`); } if (p.markup) { parts.push(`markup: ${p.markup.from}% -> ${p.markup.to}%`); } if (p.cost_price) { parts.push(`cost: ${fmtMoney(p.cost_price.from)} -> ${fmtMoney(p.cost_price.to)}`); } if (p.cost_history_append) { parts.push(`cost_history+1`); } return parts.join(" "); } // ─── PB plumbing ─────────────────────────────────────────────────────── /** * Read every `stock_items` row whose `selling_price = 0`. Filter * further by --sku / --limit at the caller. * * `getFullList` paginates transparently so the whole ~393-row * unpriced bucket comes back in one round-trip. */ async function listUnpricedStockItems( pb: Awaited>, args: { sku: string | null; limit: number | null }, ): Promise { // NB: `stock_items` does NOT have a `sku_normalised` field (the // previous price_list-service sync helper assumed one — that // filter errors at PB). We only filter on the raw `sku` column // here. Canonical-form matching ("JEITD-06" vs "JEITD06") happens // client-side in `findPriceListCandidates`, where we look up // `price_list` (which DOES have `sku_normalised`) by the // normalised form. const filters: string[] = ["selling_price = 0"]; if (args.sku) { filters.push(`sku = "${escapeForPbString(args.sku)}"`); } const filter = filters.join(" && "); const rows = await pb.collection("stock_items").getFullList<{ id: string; sku: string; selling_price?: number; markup?: number; cost_price?: number; cost_history?: unknown; }>({ filter, fields: "id,sku,selling_price,markup,cost_price,cost_history", batch: 500, sort: "sku", }); const sliced = args.limit !== null ? rows.slice(0, args.limit) : rows; return sliced.map((r) => ({ id: r.id, sku: r.sku ?? "", selling_price: r.selling_price ?? null, markup: r.markup ?? null, cost_price: r.cost_price ?? null, cost_history: r.cost_history, })); } /** * Look up `price_list` rows by the normalised SKU + selling_price > 0. * * Returns the rows sorted newest-first (PB's `-updated`). The * caller picks the first one; if `rows.length > 1`, the multi-match * warning fires. */ async function findPriceListCandidates( pb: Awaited>, skuNormalised: string, ): Promise { const rows = await pb.collection("price_list").getList<{ id: string; sku: string; sku_normalised: string; cost_price: number; selling_price: number; markup: number; updated?: string; }>(1, 10, { filter: `sku_normalised = "${escapeForPbString(skuNormalised)}" && selling_price > 0`, sort: "-updated", fields: "id,sku,sku_normalised,cost_price,selling_price,markup,updated", }); return rows.items.map((r) => ({ id: r.id, sku: r.sku, sku_normalised: r.sku_normalised, cost_price: Number(r.cost_price ?? 0), selling_price: Number(r.selling_price ?? 0), markup: Number(r.markup ?? 0), updated: r.updated, })); } /** Detect whether the decision wants to append a cost_history * entry (which forces a read-merge-write round-trip). */ function decisionNeedsCostHistoryAppend( decision: Extract, ): boolean { return Boolean(decision.patch.cost_history_append); } type RunTotals = { scanned: number; wouldPatch: number; patched: number; skippedIdempotent: number; noMatch: number; multiMatch: number; errors: number; }; function emptyTotals(): RunTotals { return { scanned: 0, wouldPatch: 0, patched: 0, skippedIdempotent: 0, noMatch: 0, multiMatch: 0, errors: 0, }; } // ─── Main ────────────────────────────────────────────────────────────── export async function runBackfill(opts: CliOptions): Promise { const totals = emptyTotals(); const startTs = Date.now(); console.log( `=== Backfill stock_items prices from price_list (${opts.commit ? "COMMIT" : "DRY RUN"}) ===`, ); if (opts.sku) console.log(` sku filter: ${opts.sku}`); if (opts.limit !== null) console.log(` limit: ${opts.limit}`); const pb = await getAdminPocketBase(); const stockRows = await listUnpricedStockItems(pb, { sku: opts.sku, limit: opts.limit, }); console.log(`\nScanning ${stockRows.length} stock_items row(s) (selling_price = 0)…\n`); totals.scanned = stockRows.length; for (const stockRow of stockRows) { // Compute the canonical form client-side (stock_items doesn't // have a sku_normalised column). The lookup against price_list // is then a single PB round-trip per row. const normalised = normaliseSku(stockRow.sku); if (!normalised) { console.log(` no_match sku=${stockRow.sku} reason=empty_normalised_sku`); totals.noMatch += 1; continue; } let candidates: BackfillPriceListCandidate[]; try { candidates = await findPriceListCandidates(pb, normalised); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.log(` error sku=${stockRow.sku} reason=price_list_lookup_failed: ${msg}`); totals.errors += 1; continue; } const candidate = candidates[0] ?? null; const multiMatch = candidates.length > 1; if (multiMatch) totals.multiMatch += 1; const decision = decideBackfill({ stockItem: stockRow, priceListCandidate: candidate, multiMatch, nowIso: new Date().toISOString(), }); if (decision.kind === "patch") { if (opts.commit) { try { await applyPatch(pb, stockRow, decision); console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "patched" })} [OK]`); totals.patched += 1; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.log( ` ${formatDecisionLine({ decision, verbWhenPatch: "patched" })} [ERR] ${msg}`, ); totals.errors += 1; } } else { console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`); totals.wouldPatch += 1; } continue; } if (decision.kind === "skipped_idempotent") { console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`); totals.skippedIdempotent += 1; continue; } // no_match console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`); totals.noMatch += 1; } printTotals(totals, opts.commit, startTs); return totals; } /** * Apply a `patch` decision to PB. * * The cost_history append needs a read-merge-write round-trip * because the existing array lives on the row. Everything else * is a straight PATCH. */ async function applyPatch( pb: Awaited>, stockRow: BackfillStockSnapshot, decision: Extract, ): Promise { // Fast path: no cost_history append — one PATCH. if (!decisionNeedsCostHistoryAppend(decision)) { const payload: Record = {}; if (decision.patch.selling_price) payload.selling_price = decision.patch.selling_price.to; if (decision.patch.markup) payload.markup = decision.patch.markup.to; if (decision.patch.cost_price) payload.cost_price = decision.patch.cost_price.to; await pb.collection("stock_items").update(stockRow.id, payload); return; } // Slow path: read the row, append to cost_history, write back // with the price fields + the merged history in one PATCH. const existing = await pb.collection("stock_items").getOne<{ cost_history?: unknown; }>(stockRow.id, { fields: "cost_history" }); const existingHistory = Array.isArray(existing.cost_history) ? (existing.cost_history as unknown as StockCostHistoryEntry[]).filter( (e): e is StockCostHistoryEntry => Boolean(e) && typeof e === "object" && typeof (e as { cost_price?: unknown }).cost_price === "number", ) : []; // decisionNeedsCostHistoryAppend guarantees the entry is present. const appended = [ ...existingHistory, decision.patch.cost_history_append as StockCostHistoryEntry, ]; const payload: Record = { cost_history: appended }; if (decision.patch.selling_price) payload.selling_price = decision.patch.selling_price.to; if (decision.patch.markup) payload.markup = decision.patch.markup.to; if (decision.patch.cost_price) payload.cost_price = decision.patch.cost_price.to; await pb.collection("stock_items").update(stockRow.id, payload); } function printTotals(totals: RunTotals, commit: boolean, startTs: number): void { const duration = ((Date.now() - startTs) / 1000).toFixed(1); console.log(`\n=== Totals ===`); console.log(` scanned: ${totals.scanned}`); console.log(` ${commit ? "patched" : "would_patch"}: ${commit ? totals.patched : totals.wouldPatch}`); console.log(` skipped_idempotent: ${totals.skippedIdempotent}`); console.log(` no_match: ${totals.noMatch}`); console.log(` multi_match: ${totals.multiMatch}`); console.log(` errors: ${totals.errors}`); console.log(` duration: ${duration}s`); if (commit && totals.patched > 0) { console.log( `\nWhat this script changed: ${totals.patched} stock_items row(s) had their price (and possibly cost) mirrored from price_list.`, ); } if (!commit && totals.wouldPatch > 0) { console.log(`\n(DRY RUN — re-run with --commit to apply the ${totals.wouldPatch} patch(es) above.)`); } } // ─── Entry point ────────────────────────────────────────────────────── async function main(): Promise { const opts = parseCli(process.argv.slice(2)); await runBackfill(opts); } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });