WFDASH-WEBAPP/scripts/backfill-stock-prices.ts

676 lines
24 KiB
TypeScript
Raw Normal View History

feat(stock): seed pricing fields on create + sync price_list to stock_items Closes the pre-Fix-#1 stock pricing gap so the till and the POS see prices on day one. What's new: - price-list-service: addPriceListItems now mirrors the upserted selling_price + markup onto the matching stock_items row when (and only when) the stock row has no price yet. Sync failures are returned as warnings — never as a failed price_list write, so a single broken stock row can't poison a 500-item batch. - stock-quick-draft-service: addQuickDraftLine seeds cost_price + bootstraps a one-entry cost_history on inline-create when the buyer supplies newStockItem.costPrice. Existing-item path backfills cost_price when zero and always appends a cost_history entry so the line addition shows up in the audit log without waiting on bookStockFromInvoice. - stock/items/create (POST): accepts optional costPrice, sellingPrice, and markup on the standalone create endpoint so the operator can stamp a price and bootstrap cost_history in one round-trip. Empty bodies still produce an empty cost_history (legacy callers unchanged). - scripts/backfill-stock-prices.ts: one-shot operator tool that mirrors price_list rows onto existing stock_items rows whose selling_price is still zero. Pure decision function exported for unit tests; --commit / --limit / --sku CLI; idempotent (never overwrites a non-zero shelf price or cost). - vitest.config.ts: pull scripts/**/*.test.ts into the vitest include list so backfill-stock-prices.test.ts runs in `npx vitest run` without a per-script config. Tests: 65 new test cases across price-list-service, stock-quick-draft-service, stock/items create, and backfill-stock-prices.
2026-09-14 11:04:08 +00:00
/**
* 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<ReturnType<typeof getAdminPocketBase>>,
args: { sku: string | null; limit: number | null },
): Promise<BackfillStockSnapshot[]> {
// 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<ReturnType<typeof getAdminPocketBase>>,
skuNormalised: string,
): Promise<BackfillPriceListCandidate[]> {
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<BackfillDecision, { kind: "patch" }>,
): 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<RunTotals> {
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<ReturnType<typeof getAdminPocketBase>>,
stockRow: BackfillStockSnapshot,
decision: Extract<BackfillDecision, { kind: "patch" }>,
): Promise<void> {
// Fast path: no cost_history append — one PATCH.
if (!decisionNeedsCostHistoryAppend(decision)) {
const payload: Record<string, unknown> = {};
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<string, unknown> = { 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<void> {
const opts = parseCli(process.argv.slice(2));
await runBackfill(opts);
}
main().catch((err) => {
console.error("FATAL:", err);
process.exit(1);
});