diff --git a/scripts/backfill-stock-prices.test.ts b/scripts/backfill-stock-prices.test.ts new file mode 100644 index 0000000..0d875c6 --- /dev/null +++ b/scripts/backfill-stock-prices.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for the pure `decideBackfill` decision function in + * `scripts/backfill-stock-prices.ts`. + * + * The script's PB I/O is not under test here — only the logic that + * decides "given this stock_item and this price_list candidate, + * what (if anything) should we patch?" The orchestrator and the + * PB plumbing stay untested because they require a live PB admin + * client, which the dry-run + `pnpm exec tsx … --limit=10` smoke + * test covers. + * + * Each test pins `nowIso` to a deterministic timestamp so the + * cost_history bootstrap entry can be asserted verbatim. + */ +import { describe, it, expect } from "vitest"; + +import { + decideBackfill, + formatDecisionLine, + type BackfillStockSnapshot, + type BackfillPriceListCandidate, + type BackfillDecision, +} from "./backfill-stock-prices"; + +const NOW = "2026-09-14T01:00:00.000Z"; + +function stockItem(overrides: Partial = {}): BackfillStockSnapshot { + return { + id: "si_abc", + sku: "ABC123", + selling_price: 0, + markup: 0, + cost_price: 0, + cost_history: [], + ...overrides, + }; +} + +function priceListCandidate( + overrides: Partial = {}, +): BackfillPriceListCandidate { + return { + id: "pl_xyz", + sku: "ABC123", + sku_normalised: "ABC123", + cost_price: 65, + selling_price: 87.5, + markup: 25, + updated: "2026-09-13T12:00:00.000Z", + ...overrides, + }; +} + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe("decideBackfill", () => { + it("proposes both cost + selling patch when both are zero", () => { + const decision = decideBackfill({ + stockItem: stockItem({ + id: "si_unpriced", + selling_price: 0, + markup: 0, + cost_price: 0, + }), + priceListCandidate: priceListCandidate({ + cost_price: 65, + selling_price: 87.5, + markup: 25, + }), + multiMatch: false, + nowIso: NOW, + }); + + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + + expect(decision.stockItemId).toBe("si_unpriced"); + expect(decision.sku).toBe("ABC123"); + expect(decision.priceListId).toBe("pl_xyz"); + expect(decision.multiMatch).toBe(false); + + // selling + markup + cost + cost_history all proposed. + expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 }); + expect(decision.patch.markup).toEqual({ from: 0, to: 25 }); + expect(decision.patch.cost_price).toEqual({ from: 0, to: 65 }); + expect(decision.patch.cost_history_append).toEqual({ + cost_price: 65, + quantity: 0, + purchased_at: NOW, + purchase_id: "", + }); + }); + + it("proposes only selling patch (no cost change) when stock.cost_price already set", () => { + const decision = decideBackfill({ + stockItem: stockItem({ + id: "si_costed", + // Selling = 0 (so the till can't read it) but cost is + // already set from a prior purchase. We backfill only the + // shelf-side fields — never overwrite the cost the buyer + // or accountant already locked in. + selling_price: 0, + markup: 0, + cost_price: 80, + cost_history: [ + { + cost_price: 80, + quantity: 5, + purchased_at: "2026-08-20T00:00:00.000Z", + purchase_id: "sp_001", + }, + ], + }), + priceListCandidate: priceListCandidate({ + cost_price: 65, // different (cheaper) — IGNORE + selling_price: 87.5, + markup: 25, + }), + multiMatch: false, + nowIso: NOW, + }); + + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + + expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 }); + expect(decision.patch.markup).toEqual({ from: 0, to: 25 }); + + // Cost side must NOT be touched — the existing cost_price is + // already set, and we never overwrite. No cost_history entry + // either (the spec says "append only when patching cost for + // the first time" — and we're not patching cost here). + expect(decision.patch.cost_price).toBeUndefined(); + expect(decision.patch.cost_history_append).toBeUndefined(); + }); + + it("is a no-op (skipped_idempotent) when selling_price is already non-zero", () => { + // The script reads stock_items WHERE selling_price = 0 so this + // case shouldn't normally fire — but it's the defensive guard: + // if the row's shelf price was somehow set between the read + // and the patch (or the test feeds in a non-zero row + // directly), we skip without error. + const decision = decideBackfill({ + stockItem: stockItem({ + id: "si_already_priced", + selling_price: 150, + markup: 30, + cost_price: 100, + }), + priceListCandidate: priceListCandidate({ + cost_price: 65, + selling_price: 87.5, + markup: 25, + }), + multiMatch: false, + nowIso: NOW, + }); + + expect(decision.kind).toBe("skipped_idempotent"); + if (decision.kind !== "skipped_idempotent") return; + + expect(decision.stockItemId).toBe("si_already_priced"); + expect(decision.sku).toBe("ABC123"); + expect(decision.priceListId).toBe("pl_xyz"); + expect(decision.reason).toBe("selling_price_already_set"); + }); + + it("returns no_match when no price_list row matches", () => { + const decision = decideBackfill({ + stockItem: stockItem({ id: "si_lonely", sku: "LONELY-1" }), + priceListCandidate: null, + multiMatch: false, + nowIso: NOW, + }); + + expect(decision.kind).toBe("no_match"); + if (decision.kind !== "no_match") return; + + expect(decision.stockItemId).toBe("si_lonely"); + expect(decision.sku).toBe("LONELY-1"); + }); + + it("returns no_match when the candidate has selling_price = 0 (defensive guard)", () => { + // The PB-level filter should exclude these, but a race / + // manual edit might surface one. Don't silently write 0. + const decision = decideBackfill({ + stockItem: stockItem({ id: "si_zero_price_candidate" }), + priceListCandidate: priceListCandidate({ selling_price: 0 }), + multiMatch: false, + nowIso: NOW, + }); + expect(decision.kind).toBe("no_match"); + }); + + it("skips markup when candidate has markup = 0 even if stock has selling to patch", () => { + // Some price_list rows have selling_price but no markup field + // (legacy imports, or rows where the operator typed the price + // directly). We patch selling but leave markup alone. + const decision = decideBackfill({ + stockItem: stockItem({ selling_price: 0, markup: 0, cost_price: 100 }), + priceListCandidate: priceListCandidate({ selling_price: 200, markup: 0 }), + multiMatch: false, + nowIso: NOW, + }); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.patch.selling_price).toEqual({ from: 0, to: 200 }); + expect(decision.patch.markup).toBeUndefined(); + // cost_price already set, so cost side is untouched. + expect(decision.patch.cost_price).toBeUndefined(); + expect(decision.patch.cost_history_append).toBeUndefined(); + }); + + it("propagates multiMatch = true so the caller can log a warning", () => { + const decision = decideBackfill({ + stockItem: stockItem({ id: "si_dup_catalogue" }), + priceListCandidate: priceListCandidate({ id: "pl_newer" }), + multiMatch: true, + nowIso: NOW, + }); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.multiMatch).toBe(true); + expect(decision.priceListId).toBe("pl_newer"); + }); + + it("treats null/undefined numeric fields as 0 (defaults)", () => { + // PB returns numeric fields as `undefined` when they're not + // set; the script must not crash on missing fields. The + // decision function should treat each undefined field as 0. + const decision = decideBackfill({ + stockItem: { + id: "si_sparse", + sku: "SPARSE", + selling_price: null, + markup: undefined, + cost_price: null, + }, + priceListCandidate: priceListCandidate({ + cost_price: 10, + selling_price: 15, + markup: 50, + }), + multiMatch: false, + nowIso: NOW, + }); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.patch.selling_price).toEqual({ from: 0, to: 15 }); + expect(decision.patch.markup).toEqual({ from: 0, to: 50 }); + expect(decision.patch.cost_price).toEqual({ from: 0, to: 10 }); + expect(decision.patch.cost_history_append).toEqual({ + cost_price: 10, + quantity: 0, + purchased_at: NOW, + purchase_id: "", + }); + }); +}); + +// ─── formatDecisionLine — secondary surface so the operator's dry-run +// output is human-readable and stays stable across refactors. + +describe("formatDecisionLine", () => { + function decide(overrides: { + stockItem?: Partial; + priceListCandidate?: Partial | null; + multiMatch?: boolean; + } = {}): BackfillDecision { + return decideBackfill({ + stockItem: stockItem(overrides.stockItem), + priceListCandidate: + overrides.priceListCandidate === null + ? null + : priceListCandidate( + overrides.priceListCandidate as Partial, + ), + multiMatch: overrides.multiMatch ?? false, + nowIso: NOW, + }); + } + + it("renders a patch with selling + markup + cost + history delta", () => { + const decision = decide(); + const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); + expect(line).toContain("would_patch sku=ABC123"); + expect(line).toContain("match=price_list:pl_xyz"); + expect(line).toContain("selling: R 0.00 -> R 87.50"); + expect(line).toContain("markup: 0% -> 25%"); + expect(line).toContain("cost: R 0.00 -> R 65.00"); + expect(line).toContain("cost_history+1"); + }); + + it("uses the patched verb in --commit mode", () => { + const decision = decide(); + const line = formatDecisionLine({ decision, verbWhenPatch: "patched" }); + expect(line.startsWith("patched ")).toBe(true); + }); + + it("renders a no_match as a single short line", () => { + const decision = decide({ priceListCandidate: null }); + const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); + expect(line).toBe("no_match sku=ABC123"); + }); + + it("renders a skipped_idempotent with the reason", () => { + const decision = decide({ stockItem: { selling_price: 100, markup: 30 } }); + const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); + expect(line).toContain("skipped_idempotent sku=ABC123"); + expect(line).toContain("match=price_list:pl_xyz"); + expect(line).toContain("reason=selling_price_already_set"); + }); + + it("annotates multi_match decisions", () => { + const decision = decide({ multiMatch: true }); + const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); + expect(line).toContain("(multi_match)"); + }); +}); diff --git a/scripts/backfill-stock-prices.ts b/scripts/backfill-stock-prices.ts new file mode 100644 index 0000000..5c66447 --- /dev/null +++ b/scripts/backfill-stock-prices.ts @@ -0,0 +1,675 @@ +/** + * 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); +}); diff --git a/src/app/api/v1/stock/items/create/route.ts b/src/app/api/v1/stock/items/create/route.ts index 9bc4250..bfa86b8 100644 --- a/src/app/api/v1/stock/items/create/route.ts +++ b/src/app/api/v1/stock/items/create/route.ts @@ -39,6 +39,22 @@ export async function POST(req: NextRequest) { const openingQty = Number(body.current_qty ?? 0); const minQty = Number(body.min_qty ?? 0); + // Pricing fields (all optional). The standalone create endpoint + // is operator-initiated — if they supply a `costPrice` they + // want it stamped and `cost_history` bootstrapped today, not + // waiting on `bookStockFromInvoice`. `selling_price` and + // `markup` flow straight through to the row; no validation that + // `costPrice > 0` so the operator can backdate. + const costPriceRaw = body.costPrice; + const hasCostPrice = + typeof costPriceRaw === "number" && Number.isFinite(costPriceRaw); + const sellingPriceRaw = body.sellingPrice; + const hasSellingPrice = + typeof sellingPriceRaw === "number" && Number.isFinite(sellingPriceRaw); + const markupRaw = body.markup; + const hasMarkup = + typeof markupRaw === "number" && Number.isFinite(markupRaw); + const item = await pb.collection("stock_items").create({ sku: (body.sku || "").trim(), name: body.name.trim(), @@ -50,6 +66,21 @@ export async function POST(req: NextRequest) { supplier: body.supplier || "", is_active: true, notes: body.notes || "", + ...(hasCostPrice + ? { + cost_price: costPriceRaw, + cost_history: [ + { + cost_price: costPriceRaw, + quantity: 0, + purchased_at: new Date().toISOString(), + purchase_id: "", + }, + ], + } + : { cost_history: [] }), + ...(hasSellingPrice ? { selling_price: sellingPriceRaw } : {}), + ...(hasMarkup ? { markup: markupRaw } : {}), }); // Opening-balance ledger row — only when the operator seeded a diff --git a/src/app/api/v1/stock/items/route.test.ts b/src/app/api/v1/stock/items/route.test.ts index 6ddf612..b531568 100644 --- a/src/app/api/v1/stock/items/route.test.ts +++ b/src/app/api/v1/stock/items/route.test.ts @@ -348,6 +348,64 @@ describe("/api/v1/stock/items", () => { // Only one create call (the item itself). expect(create).toHaveBeenCalledTimes(1); }); + + it("seeds cost_price and bootstraps cost_history when costPrice is supplied", async () => { + create.mockResolvedValueOnce(makeItem({ id: "si-4", current_qty: 0 })); + const res = await createStockItem( + authedRequest("http://localhost/api/v1/stock/items/create", { + method: "POST", + body: { + name: "M10 Bolt", + sku: "B-001", + costPrice: 12.5, + }, + }), + ); + expect(res.status).toBe(201); + const itemCall = create.mock.calls[0][0]; + expect(itemCall.cost_price).toBe(12.5); + expect(Array.isArray(itemCall.cost_history)).toBe(true); + expect(itemCall.cost_history).toHaveLength(1); + expect(itemCall.cost_history[0]).toMatchObject({ + cost_price: 12.5, + quantity: 0, + purchase_id: "", + }); + expect(typeof itemCall.cost_history[0].purchased_at).toBe("string"); + }); + + it("writes selling_price and markup through to the stock_items row when supplied", async () => { + create.mockResolvedValueOnce(makeItem({ id: "si-5", current_qty: 0 })); + await createStockItem( + authedRequest("http://localhost/api/v1/stock/items/create", { + method: "POST", + body: { + name: "M10 Bolt", + sku: "B-001", + costPrice: 10, + sellingPrice: 15, + markup: 50, + }, + }), + ); + const itemCall = create.mock.calls[0][0]; + expect(itemCall.cost_price).toBe(10); + expect(itemCall.selling_price).toBe(15); + expect(itemCall.markup).toBe(50); + }); + + it("does not set cost_price or cost_history when costPrice is omitted (legacy behaviour)", async () => { + create.mockResolvedValueOnce(makeItem({ id: "si-6", current_qty: 0 })); + await createStockItem( + authedRequest("http://localhost/api/v1/stock/items/create", { + method: "POST", + body: { name: "M10 Bolt", sku: "B-001" }, + }), + ); + const itemCall = create.mock.calls[0][0]; + expect(itemCall.cost_price).toBeUndefined(); + expect(itemCall.cost_history).toEqual([]); + }); }); describe("[id]/adjust POST", () => { diff --git a/src/lib/server/price-list-service.test.ts b/src/lib/server/price-list-service.test.ts index 82c70cd..dd3d88d 100644 --- a/src/lib/server/price-list-service.test.ts +++ b/src/lib/server/price-list-service.test.ts @@ -1,24 +1,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -const { createMock, updateMock, getListMock } = vi.hoisted(() => { +const { createMock, updateMock, getListMock, stockGetListMock, stockUpdateMock } = vi.hoisted(() => { return { createMock: vi.fn(), updateMock: vi.fn(), getListMock: vi.fn(), + stockGetListMock: vi.fn(), + stockUpdateMock: vi.fn(), }; }); vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue({ collection: (name: string) => { - if (name !== "price_list") { - throw new Error(`Unexpected collection in test: ${name}`); + if (name === "price_list") { + return { + getList: getListMock, + create: createMock, + update: updateMock, + }; } - return { - getList: getListMock, - create: createMock, - update: updateMock, - }; + if (name === "stock_items") { + return { + getList: stockGetListMock, + update: stockUpdateMock, + }; + } + throw new Error(`Unexpected collection in test: ${name}`); }, }), })); @@ -92,6 +100,10 @@ describe("addPriceListItems — input validation", () => { createMock.mockReset(); updateMock.mockReset(); getListMock.mockReset(); + stockGetListMock.mockReset(); + stockUpdateMock.mockReset(); + // Default: no matching stock_item for sync tests. + stockGetListMock.mockResolvedValue({ items: [] }); }); it("returns no_items when items array is empty", async () => { @@ -118,8 +130,11 @@ describe("addPriceListItems — per-item validation", () => { createMock.mockReset(); updateMock.mockReset(); getListMock.mockReset(); + stockGetListMock.mockReset(); + stockUpdateMock.mockReset(); // No existing rows by default getListMock.mockResolvedValue({ items: [] }); + stockGetListMock.mockResolvedValue({ items: [] }); }); it("skips items missing sku", async () => { @@ -227,6 +242,10 @@ describe("addPriceListItems — upsert behaviour", () => { createMock.mockReset(); updateMock.mockReset(); getListMock.mockReset(); + stockGetListMock.mockReset(); + stockUpdateMock.mockReset(); + // Default: no matching stock_item (legacy tests assume this). + stockGetListMock.mockResolvedValue({ items: [] }); }); it("inserts a new row when (sku, supplier) does not exist", async () => { @@ -390,4 +409,275 @@ describe("addPriceListItems — upsert behaviour", () => { expect(dedupeFilter).toContain("sku = \"BRG-6205\""); expect(dedupeFilter).toContain("supplier = \"Orapi\""); }); +}); + +describe("addPriceListItems — stock_items selling_price sync", () => { + // The till reads `stock_items.selling_price`. The team originally + // priced everything via `price_list` (catalog-only collection). + // This block of tests verifies that an `addPriceListItems` upsert + // also back-fills the matching `stock_items.selling_price` when + // (and only when) the stock row has no price yet — and that any + // sync error doesn't fail the price_list write. + + beforeEach(() => { + createMock.mockReset(); + updateMock.mockReset(); + getListMock.mockReset(); + stockGetListMock.mockReset(); + stockUpdateMock.mockReset(); + // Default: no matching stock_item. + stockGetListMock.mockResolvedValue({ items: [] }); + }); + + function priceListFreshRow(id: string) { + return { + items: [ + { + id, + created: "2026-08-09T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + } + + it("patches stock_items.selling_price when the matched stock row has selling_price = 0", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; // dedupe: brand-new + return priceListFreshRow("pl_new"); // stats + }); + createMock.mockResolvedValue({ id: "pl_new" }); + stockGetListMock.mockResolvedValue({ + items: [{ id: "si-unpriced", selling_price: 0, markup: 0 }], + }); + stockUpdateMock.mockResolvedValue({ id: "si-unpriced" }); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + selling_price: 112.0, + markup: 25, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Looked up stock_items by sku_normalised (BRG6205). + const stockLookupFilter = + (stockGetListMock.mock.calls[0]?.[2] as { filter?: string })?.filter ?? ""; + expect(stockLookupFilter).toContain("sku_normalised"); + + // PATCHed the matched row with the price_list values. + expect(stockUpdateMock).toHaveBeenCalledTimes(1); + expect(stockUpdateMock).toHaveBeenCalledWith("si-unpriced", { + selling_price: 112.0, + markup: 25, + }); + }); + + it("does NOT overwrite a stock_items row that already has a positive selling_price", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; + return priceListFreshRow("pl_new"); + }); + createMock.mockResolvedValue({ id: "pl_new" }); + // Stock row already priced — must NOT be touched. + stockGetListMock.mockResolvedValue({ + items: [{ id: "si-priced", selling_price: 150, markup: 30 }], + }); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + selling_price: 112.0, + markup: 25, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + expect(stockUpdateMock).not.toHaveBeenCalled(); + }); + + it("patches markup when stock.markup = 0 but stock.selling_price > 0", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; + return priceListFreshRow("pl_new"); + }); + createMock.mockResolvedValue({ id: "pl_new" }); + // Stock has a selling_price (already priced) but markup is 0 + // (legacy migration state). We patch markup but NOT selling_price. + stockGetListMock.mockResolvedValue({ + items: [{ id: "si-partial", selling_price: 150, markup: 0 }], + }); + stockUpdateMock.mockResolvedValue({ id: "si-partial" }); + + await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + selling_price: 112.0, + markup: 25, + supplier_name: "Afrisan", + }, + ], + }); + + expect(stockUpdateMock).toHaveBeenCalledWith("si-partial", { markup: 25 }); + }); + + it("is a no-op when no stock_items row matches the sku_normalised", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; + return priceListFreshRow("pl_new"); + }); + createMock.mockResolvedValue({ id: "pl_new" }); + // Default stockGetListMock returns no items. + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + selling_price: 112.0, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + expect(stockUpdateMock).not.toHaveBeenCalled(); + // The price_list result should not carry any sync warnings. + if (result.ok) expect(result.warnings ?? []).toEqual([]); + }); + + it("isolates sync PATCH failures — price_list result still returns ok:true with a warning", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; + return priceListFreshRow("pl_new"); + }); + createMock.mockResolvedValue({ id: "pl_new" }); + stockGetListMock.mockResolvedValue({ + items: [{ id: "si-1", selling_price: 0, markup: 0 }], + }); + // Sync write fails — but the price_list result must still succeed. + stockUpdateMock.mockRejectedValueOnce(new Error("PB boom")); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + selling_price: 112.0, + markup: 25, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.ids).toEqual(["pl_new"]); + expect(result.warnings).toBeDefined(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings?.[0]).toMatchObject({ + sku: "BRG-6205", + stock_item_id: "si-1", + reason: expect.stringContaining("PB boom"), + }); + }); + + it("does not call stock_items update when the upsert itself fails (skip row entirely)", async () => { + // Both dedupe and stats lookups throw — the row should land in + // skipped[], not partial-sync. + getListMock.mockRejectedValueOnce(new Error("PB dedupe down")); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.ids).toEqual([]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].reason).toMatch(/pb_write_failed/); + expect(stockGetListMock).not.toHaveBeenCalled(); + }); + + it("syncs on the UPDATE branch as well as the CREATE branch", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) { + // Dedupe lookup — row already exists. + return { items: [{ id: "pl_existing" }] }; + } + // Stats lookup — updated row (created != updated). + return { + items: [ + { + id: "pl_existing", + created: "2026-08-01T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + }); + updateMock.mockResolvedValue({ id: "pl_existing" }); + stockGetListMock.mockResolvedValue({ + items: [{ id: "si-unpriced", selling_price: 0, markup: 0 }], + }); + stockUpdateMock.mockResolvedValue({ id: "si-unpriced" }); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 99, + selling_price: 125, + markup: 26, + supplier_name: "Afrisan", + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.updated).toBe(1); + expect(stockUpdateMock).toHaveBeenCalledWith("si-unpriced", { + selling_price: 125, + markup: 26, + }); + }); }); \ No newline at end of file diff --git a/src/lib/server/price-list-service.ts b/src/lib/server/price-list-service.ts index 9173587..9a5ef4d 100644 --- a/src/lib/server/price-list-service.ts +++ b/src/lib/server/price-list-service.ts @@ -110,6 +110,26 @@ export type AddPriceListItemsOk = { ids: string[]; /** Items the service refused to commit, with the reason. */ skipped: Array<{ index: number; sku: string; reason: string }>; + /** + * Stock-item sync problems encountered after the price_list write + * landed. The price_list write itself always succeeds (or lands + * in `skipped`); the stock_items patch is a best-effort step + * that fires only when an `addPriceListItems` row matches a + * `stock_items` row by `sku_normalised` AND that stock row's + * `selling_price` is zero. Sync failures are collected here so a + * single broken stock row can never poison a 500-item batch. + * + * Empty when no sync attempt was needed (no matching stock_item) + * or every sync attempt succeeded. + */ + warnings?: Array<{ + /** The sku whose sync attempt failed. */ + sku: string; + /** The stock_items row id the sync was targeting. */ + stock_item_id: string; + /** Short failure reason (PB error message). */ + reason: string; + }>; }; export type AddPriceListItemsErr = { @@ -179,6 +199,115 @@ export function computeSellingPrice( return Math.max(0, Math.round(costPrice * multiplier * 100) / 100); } +/** + * After a successful `price_list` upsert, mirror the row's + * selling_price + markup onto the matching `stock_items` row so + * the till (which reads `stock_items.selling_price`) sees the new + * price. The price_list catalogue is the source of truth for + * items that have *no* shelf price; the POS cashier or catalogue + * patch is the source once a price has been set — so we never + * overwrite a non-zero `stock_items.selling_price`. + * + * Sync rules: + * - Match by `sku_normalised` on `stock_items` (single row only; + * if multiple match, do nothing — ambiguous). + * - Match only when the stock row's `selling_price` is 0/null AND + * the price_list row has a positive `selling_price` to seed. + * - If `stock_items.markup` is 0/null AND `price_list.markup` is + * positive, also patch `markup`. + * - Sync failures are returned to the caller, never thrown. A + * broken sync must not poison a 500-item batch. + * + * Returns `null` when no sync was needed (no match, already priced, + * or missing selling_price on the price_list row); otherwise + * returns `{ stock_item_id, warnings? }` describing the outcome. + */ +async function syncPriceListToStockItem( + pb: Awaited>, + args: { + sku: string; + sellingPrice: number | undefined; + markup: number | undefined; + }, +): Promise< + | { stock_item_id: string; warning?: { sku: string; stock_item_id: string; reason: string } } + | null +> { + // No point syncing if the price_list row doesn't carry a price. + if ( + typeof args.sellingPrice !== "number" || + !Number.isFinite(args.sellingPrice) || + args.sellingPrice <= 0 + ) { + return null; + } + + const skuNormalised = normaliseSku(args.sku); + if (!skuNormalised) return null; + + // Look up the stock row by canonical SKU. We don't auto-create + // from price_list — supplier catalogues carry thousands of SKUs + // that have never been stocked. + let stockItems: Array<{ id: string; selling_price?: number | null; markup?: number | null }>; + try { + stockItems = ( + await pb.collection("stock_items").getList<{ + id: string; + selling_price?: number | null; + markup?: number | null; + }>(1, 2, { + filter: `sku_normalised = "${escapeForPbString(skuNormalised)}"`, + fields: "id,selling_price,markup", + }) + ).items; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + stock_item_id: "", + warning: { sku: args.sku, stock_item_id: "", reason: `lookup_failed: ${message}` }, + }; + } + + // Ambiguous match → skip without error. The price_list still + // landed; the operator can resolve the dupes in the catalogue. + if (stockItems.length !== 1) return null; + + const stockRow = stockItems[0]; + const stockSellingPrice = Number(stockRow.selling_price ?? 0); + const stockMarkup = Number(stockRow.markup ?? 0); + + const patch: { selling_price?: number; markup?: number } = {}; + // Idempotency: never overwrite a non-zero stock shelf price. + if (!(stockSellingPrice > 0)) { + patch.selling_price = args.sellingPrice; + } + if ( + !(stockMarkup > 0) && + typeof args.markup === "number" && + Number.isFinite(args.markup) && + args.markup > 0 + ) { + patch.markup = args.markup; + } + + if (Object.keys(patch).length === 0) return null; + + try { + await pb.collection("stock_items").update(stockRow.id, patch); + return { stock_item_id: stockRow.id }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + stock_item_id: stockRow.id, + warning: { + sku: args.sku, + stock_item_id: stockRow.id, + reason: message, + }, + }; + } +} + export async function addPriceListItems( input: AddPriceListItemsInput, ): Promise { @@ -211,6 +340,11 @@ export async function addPriceListItems( const skipped: AddPriceListItemsOk["skipped"] = []; const ids: string[] = []; + // Best-effort stock_items sync problems. Always empty when no + // sync was needed (no matching stock_item) or every sync + // succeeded. Populated when syncPriceListToStockItem returned + // a `warning` (lookup failure or PATCH rejection). + const syncWarnings: NonNullable = []; for (let index = 0; index < items.length; index += 1) { const raw = items[index]; @@ -289,6 +423,18 @@ export async function addPriceListItems( const created = await pb.collection("price_list").create(row); ids.push(created.id); } + // Mirror selling_price + markup onto the matching stock_items + // row when (and only when) the stock row has no price yet. + // Errors are captured into `syncWarnings` below — they must + // never fail the price_list write. + const syncOutcome = await syncPriceListToStockItem(pb, { + sku, + sellingPrice: sellingPrice, + markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : undefined, + }); + if (syncOutcome?.warning) { + syncWarnings.push(syncOutcome.warning); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); skipped.push({ index, sku, reason: `pb_write_failed: ${message}` }); @@ -326,5 +472,6 @@ export async function addPriceListItems( updated, ids, skipped, + ...(syncWarnings.length > 0 ? { warnings: syncWarnings } : {}), }; } \ No newline at end of file diff --git a/src/lib/server/stock-quick-draft-service.test.ts b/src/lib/server/stock-quick-draft-service.test.ts index 2feed77..86be6f1 100644 --- a/src/lib/server/stock-quick-draft-service.test.ts +++ b/src/lib/server/stock-quick-draft-service.test.ts @@ -536,6 +536,135 @@ describe("addQuickDraftLine", () => { expect(lines[0].description).toBe("Brand-new Widget"); }); + it("seeds cost_price and a one-entry cost_history on inline-create when costPrice is supplied", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" }) + ).id; + + const result = await addQuickDraftLine(pbMock as never, { + supplierId, + quantity: 10, + costPrice: 5.5, + newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1", costPrice: 7.25 }, + }); + + expect(result.ok).toBe(true); + + const items = await pbMock.collection("stock_items").getFullList(); + expect(items).toHaveLength(1); + // cost_price was seeded from newStockItem.costPrice (NOT from + // the top-line costPrice which is the per-line purchase cost; + // those are two different numbers — the inline-create is the + // "this is what we'd previously paid" seed, the per-line is + // "this is what we're paying today"). + expect(items[0].cost_price).toBe(7.25); + expect(Array.isArray(items[0].cost_history)).toBe(true); + const history = items[0].cost_history as Array>; + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + cost_price: 7.25, + quantity: 0, + purchase_id: "", + }); + expect(typeof history[0].purchased_at).toBe("string"); + }); + + it("does not seed cost_price when newStockItem.costPrice is omitted (legacy behaviour)", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "S" }) + ).id; + + const result = await addQuickDraftLine(pbMock as never, { + supplierId, + quantity: 10, + costPrice: 5.5, + newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-2" }, + }); + + expect(result.ok).toBe(true); + + const items = await pbMock.collection("stock_items").getFullList(); + expect(items).toHaveLength(1); + // No costPrice supplied → no cost_price field stamped and an + // empty cost_history. Existing callers see no change. + expect(items[0].cost_price).toBeUndefined(); + expect(items[0].cost_history).toEqual([]); + }); + + it("backfills cost_price + appends cost_history when the existing stock_item has cost_price = 0", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "S" }) + ).id; + // Item created in the gap, with cost_price = 0 and empty history. + await pbMock.collection("stock_items").create({ + id: "item-legacy", + sku: "STK-CD-115", + name: "115mm Cutting Disc", + cost_price: 0, + cost_history: [], + }); + + const result = await addQuickDraftLine(pbMock as never, { + supplierId, + stockItemId: "item-legacy", + quantity: 20, + costPrice: 18.5, + }); + + expect(result.ok).toBe(true); + + const updated = await pbMock.collection("stock_items").getOne("item-legacy"); + expect(updated.cost_price).toBe(18.5); + const history = updated.cost_history as Array>; + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + cost_price: 18.5, + quantity: 0, + purchase_id: "", + }); + }); + + it("does not clobber an existing non-zero cost_price, but still appends to cost_history", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "S" }) + ).id; + await pbMock.collection("stock_items").create({ + id: "item-priced", + sku: "STK-CD-115", + name: "115mm Cutting Disc", + cost_price: 22.0, + cost_history: [ + { + cost_price: 22.0, + quantity: 10, + purchased_at: "2026-07-01T08:00:00.000Z", + purchase_id: "PO26070101STK", + }, + ], + }); + + const result = await addQuickDraftLine(pbMock as never, { + supplierId, + stockItemId: "item-priced", + quantity: 20, + costPrice: 18.5, + }); + + expect(result.ok).toBe(true); + + const updated = await pbMock.collection("stock_items").getOne("item-priced"); + // Don't clobber — keep the existing cost_price. + expect(updated.cost_price).toBe(22.0); + const history = updated.cost_history as Array>; + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ cost_price: 22.0, purchase_id: "PO26070101STK" }); + expect(history[1]).toMatchObject({ + cost_price: 18.5, + quantity: 0, + purchase_id: "", + }); + }); + it("rejects non-positive quantities", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) diff --git a/src/lib/server/stock-quick-draft-service.ts b/src/lib/server/stock-quick-draft-service.ts index bcac10a..626d467 100644 --- a/src/lib/server/stock-quick-draft-service.ts +++ b/src/lib/server/stock-quick-draft-service.ts @@ -7,6 +7,7 @@ * statuses. Admin PB only (consistent with the other service modules). */ import type PocketBase from "pocketbase"; +import type { StockCostHistoryEntry } from "@/lib/types"; import { generateStockPurchaseNumber } from "./stock-purchases-service"; const STOCK_ITEMS = "stock_items"; @@ -69,6 +70,20 @@ export interface AddQuickDraftLineNewStockItem { sku: string; description?: string; unit?: string; + /** + * Optional cost to seed onto the brand-new `stock_items` row at + * inline-create time. When supplied, `stock_items.cost_price` is + * stamped immediately and a one-entry `cost_history` is + * bootstrapped (matches `StockCostHistoryEntry` shape from + * `lib/types.ts`) so the till and the POS see a price on day + * one. The accountant's `bookStockFromInvoice` flow will append + * a second entry when the supplier invoice is booked. + * + * Existing callers (no `costPrice`) keep the previous behaviour: + * the row is created with no `cost_price` and an empty + * `cost_history`. + */ + costPrice?: number; } export interface AddQuickDraftLineInput { @@ -399,7 +414,15 @@ export async function addQuickDraftLine( } // ── Resolve the stock_item id (create if inline) ─────────────── + // `inlineCreated` distinguishes "we just made this row" from + // "the caller supplied an existing stockItemId". The cost-backfill + // step below should only run on the existing-item path: when the + // row is brand-new, the inline-create branch above has already + // handled the seed (or intentionally skipped it for legacy + // callers — running the backfill on top would re-seed a row the + // caller explicitly wanted left alone). let stockItemId = input.stockItemId; + let inlineCreated = false; if (!stockItemId && input.newStockItem) { if (!input.newStockItem.name || !input.newStockItem.sku) { return { @@ -409,6 +432,29 @@ export async function addQuickDraftLine( }; } try { + const inlineCost = input.newStockItem.costPrice; + const hasInlineCost = + typeof inlineCost === "number" && Number.isFinite(inlineCost) && inlineCost >= 0; + // Bootstrap a one-entry cost_history when the buyer supplied + // an inline cost on a brand-new item. Matches + // `StockCostHistoryEntry` shape (see `lib/types.ts`): + // `cost_price`, `quantity` (0 — the buyer hasn't booked the + // invoice yet), `purchased_at` (now, ISO), `purchase_id` ("" — + // this is a manual seed, not tied to a stock_purchase row). + // The accountant's `bookStockFromInvoice` flow appends a + // second entry with a real purchase_id when the supplier + // invoice is booked — so the inline seed is just the "we + // knew the price when we typed it" snapshot. + const seedHistory: StockCostHistoryEntry[] = hasInlineCost + ? [ + { + cost_price: inlineCost as number, + quantity: 0, + purchased_at: new Date().toISOString(), + purchase_id: "", + }, + ] + : []; const created = await pb.collection(STOCK_ITEMS).create({ name: input.newStockItem.name, sku: input.newStockItem.sku, @@ -417,8 +463,12 @@ export async function addQuickDraftLine( current_qty: 0, is_active: true, supplier: input.supplierId, + ...(hasInlineCost + ? { cost_price: inlineCost, cost_history: seedHistory } + : { cost_history: [] }), }); stockItemId = String(created.id); + inlineCreated = true; } catch (err) { return { ok: false, @@ -452,6 +502,57 @@ export async function addQuickDraftLine( const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId); const description = String(stockItem.name ?? ""); + // ── Cost backfill + history append (existing-item path only) ──── + // When the buyer adds a line for an item that was created in the + // pre-Fix-#1 gap (no `cost_price` stamped, empty `cost_history`), + // the buyer's just-typed line cost IS authoritative — backfill it + // onto the row so the till and the POS see a price today. + // + // Idempotency rules: + // - If `cost_price` is already non-zero, DON'T clobber it. The + // buyer or the booking flow owns that number; this line is + // just an audit-trail entry. + // - ALWAYS append a `cost_history` entry (even when not + // backfilling `cost_price`) so the line addition shows up in + // the audit log — `bookStockFromInvoice` would have written + // it after the invoice was booked, but for an inline draft + // add the receipt hasn't happened yet, so we capture the + // intent here. + // - On the inline-create path the seed step above already + // handled `cost_price` + `cost_history` (or intentionally + // skipped both). Skip the backfill so we don't double-write. + if (!inlineCreated) { + try { + const existingCostPrice = Number(stockItem.cost_price ?? 0); + const existingHistory = Array.isArray(stockItem.cost_history) + ? (stockItem.cost_history as unknown as StockCostHistoryEntry[]) + : []; + const shouldBackfill = + !(existingCostPrice > 0) && input.costPrice > 0; + const newEntry: StockCostHistoryEntry = { + cost_price: input.costPrice, + quantity: 0, + purchased_at: new Date().toISOString(), + purchase_id: "", + }; + const patch: { + cost_price?: number; + cost_history: StockCostHistoryEntry[]; + } = { + cost_history: [...existingHistory, newEntry], + }; + if (shouldBackfill) { + patch.cost_price = input.costPrice; + } + await pb.collection(STOCK_ITEMS).update(finalStockItemId, patch); + } catch { + // Cost-patch failures must NOT block the line append. The line + // is the buyer's primary intent; the cost backfill is a + // best-effort audit/cache step. Surface nothing — the till + // will read whatever `cost_price` it had before. + } + } + // ── Append the line ──────────────────────────────────────────── try { const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({ diff --git a/vitest.config.ts b/vitest.config.ts index 892371d..0312ec5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,11 @@ export default defineConfig({ globals: false, include: [ "src/**/*.{test,spec}.{ts,tsx}", + // One-shot scripts under `scripts/` ship their own *.test.ts + // (e.g. scripts/backfill-stock-prices.test.ts for the pure + // decision logic). Pulled in here so `vitest run scripts/` + // picks them up without a per-script config file. + "scripts/**/*.test.ts", ], // Exclude Playwright E2E specs from Vitest. exclude: [