From 698abe9fbcb171955c57b9787353c3e6f3c5997d Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 14 Sep 2026 13:04:26 +0200 Subject: [PATCH] feat(stock): inline qty editing + column sort + pricing-gap operator scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doubles down on the /stock list to make catalogue and inventory edits one-click for the operator. What's new: - /stock: clickable column headers on Cost / Markup / Selling cycle none → asc → desc → none and feed through to the PB sort param. The apply bar now commits pricing AND qty in parallel (Promise.all against /set-pricing and /set-quantities) with per-row failure tracking and focused error toasts. In edit mode the row is no longer a button — inputs render in-place for current_qty, min_qty, and target_qty alongside the existing pricing inputs, and the row click is gated off so clicks on an input never navigate to the detail page. - stock-quantities-service + /api/v1/stock/items/set-quantities (POST): bulk-update current_qty / min_qty / target_qty on a set of stock_items rows. Whitelist-validated patches, per-row results with stable failure reasons, and a stock_ledger row written FIRST whenever current_qty actually moves so the audit trail stays ahead of the cached total (same pattern as stock-surplus and stock-booking-service). - scripts/apply-default-markup.ts: one-shot operator tool that seeds selling_price = round(cost_price * 1.80, 2) and markup = 80 on stock_items rows whose cost_price is set but selling_price is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated bulk action); idempotent (never overwrites a non-zero shelf price or markup). - scripts/list-irda-stock.ts: read-only inspector for the IRIDA spray-caint line with summary counts (without selling_price, without cost_price, zero stock) so the operator can see exactly which rows the backfill tools above will touch. Tests: 81 new test cases across /stock page, stock-quantities service + route, and apply-default-markup. --- scripts/apply-default-markup.test.ts | 252 ++++++++ scripts/apply-default-markup.ts | 543 ++++++++++++++++++ scripts/list-irda-stock.ts | 317 ++++++++++ src/app/(app)/stock/page.test.tsx | 260 ++++++++- src/app/(app)/stock/page.tsx | 469 ++++++++++++--- .../stock/items/set-quantities/route.test.ts | 218 +++++++ .../v1/stock/items/set-quantities/route.ts | 124 ++++ .../server/stock-quantities-service.test.ts | 329 +++++++++++ src/lib/server/stock-quantities-service.ts | 286 +++++++++ 9 files changed, 2719 insertions(+), 79 deletions(-) create mode 100644 scripts/apply-default-markup.test.ts create mode 100644 scripts/apply-default-markup.ts create mode 100644 scripts/list-irda-stock.ts create mode 100644 src/app/api/v1/stock/items/set-quantities/route.test.ts create mode 100644 src/app/api/v1/stock/items/set-quantities/route.ts create mode 100644 src/lib/server/stock-quantities-service.test.ts create mode 100644 src/lib/server/stock-quantities-service.ts diff --git a/scripts/apply-default-markup.test.ts b/scripts/apply-default-markup.test.ts new file mode 100644 index 0000000..ad46c7a --- /dev/null +++ b/scripts/apply-default-markup.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for the pure decision + formatting logic in + * `scripts/apply-default-markup.ts`. + * + * The script's PB I/O is not under test here — only the logic that + * decides "given this stock_item, 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. + */ +import { describe, it, expect } from "vitest"; + +import { + decideMarkupForStockItem, + formatDecisionLine, + formatPatchLine, + type MarkupStockSnapshot, + type MarkupDecision, +} from "./apply-default-markup"; + +function stockItem( + overrides: Partial = {}, +): MarkupStockSnapshot { + return { + id: "si_abc", + sku: "ABC123", + cost_price: 100, + selling_price: 0, + markup: 0, + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +describe("decideMarkupForStockItem", () => { + it("proposes 80% markup patch when cost > 0 and selling = 0", () => { + const decision = decideMarkupForStockItem( + stockItem({ + id: "si_unpriced", + sku: "ABC123", + cost_price: 100, + selling_price: 0, + markup: 0, + }), + ); + + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + + expect(decision.stockItemId).toBe("si_unpriced"); + expect(decision.sku).toBe("ABC123"); + expect(decision.patch.selling_price).toEqual({ from: 0, to: 180 }); + expect(decision.patch.markup).toEqual({ from: 0, to: 80 }); + }); + + it("returns no_match when cost is 0 (no patch possible)", () => { + const decision = decideMarkupForStockItem( + stockItem({ + id: "si_no_cost", + sku: "NO-COST", + cost_price: 0, + selling_price: 0, + markup: 0, + }), + ); + + expect(decision.kind).toBe("no_match"); + if (decision.kind !== "no_match") return; + expect(decision.stockItemId).toBe("si_no_cost"); + expect(decision.sku).toBe("NO-COST"); + }); + + it("treats null/undefined cost_price as no_match", () => { + // PB's typed SDK may surface unset numeric fields as null or + // undefined; both should route to no_match. + const decisionNull = decideMarkupForStockItem( + stockItem({ id: "si_null_cost", cost_price: null }), + ); + expect(decisionNull.kind).toBe("no_match"); + + const decisionUndef = decideMarkupForStockItem( + stockItem({ id: "si_undef_cost", cost_price: undefined }), + ); + expect(decisionUndef.kind).toBe("no_match"); + }); + + it("is a no-op (skipped_idempotent) when selling_price already non-zero", () => { + const decision = decideMarkupForStockItem( + stockItem({ + id: "si_already_priced", + cost_price: 100, + selling_price: 150, + markup: 0, + }), + ); + + 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.reason).toBe("selling_price_already_set"); + }); + + it("is a no-op (skipped_idempotent) when markup already non-zero (even if selling = 0)", () => { + // Edge case: row has cost_price > 0 AND selling = 0 AND a + // legacy markup > 0. We never overwrite the explicit markup + // field — that's the POS' source of truth. Skipping with + // `markup_already_set` is the right move; the operator can + // resolve manually if they want the new selling_price to + // match. + const decision = decideMarkupForStockItem( + stockItem({ + id: "si_legacy_markup", + cost_price: 100, + selling_price: 0, + markup: 50, + }), + ); + + expect(decision.kind).toBe("skipped_idempotent"); + if (decision.kind !== "skipped_idempotent") return; + expect(decision.reason).toBe("markup_already_set"); + }); + + it("rounds selling_price to 2 decimal places", () => { + // 33.33 * 1.80 = 59.994 — round to 59.99, not 60.00. + const decision = decideMarkupForStockItem( + stockItem({ id: "si_three_dec", cost_price: 33.33 }), + ); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.patch.selling_price.to).toBe(59.99); + // Markup field is always the integer constant. + expect(decision.patch.markup.to).toBe(80); + + // 50.55 * 1.80 = 90.99 — already 2dp. + const decision2 = decideMarkupForStockItem( + stockItem({ id: "si_two_dec", cost_price: 50.55 }), + ); + expect(decision2.kind).toBe("patch"); + if (decision2.kind !== "patch") return; + expect(decision2.patch.selling_price.to).toBe(90.99); + }); + + it("sets markup to exactly 80 (the constant)", () => { + const decision = decideMarkupForStockItem( + stockItem({ id: "si_any_cost", cost_price: 42 }), + ); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.patch.markup).toEqual({ from: 0, to: 80 }); + }); + + it("proposes selling_price of exactly 0 → 0 when cost is a tiny positive number", () => { + // 0.01 * 1.80 = 0.018 → 0.02 (rounded). Sanity check the + // scale-shift rounding survives tiny positive costs. + const decision = decideMarkupForStockItem( + stockItem({ id: "si_tiny", cost_price: 0.01 }), + ); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + expect(decision.patch.selling_price.to).toBe(0.02); + }); + + it("handles cost_price as a number that fails Number() defensively (NaN → no_match)", () => { + // Defensive: PB shouldn't ever hand us NaN, but a corrupted + // row could. `Number(NaN) > 0` is false, so it falls into the + // no_match branch — which is the correct behaviour (we + // can't compute a markup without a cost). + const decision = decideMarkupForStockItem( + stockItem({ id: "si_nan", cost_price: Number.NaN }), + ); + expect(decision.kind).toBe("no_match"); + }); +}); + +// ─── formatDecisionLine — secondary surface so the operator's +// dry-run output is human-readable and stays stable across refactors. + +describe("formatDecisionLine", () => { + it("renders a no_match as a single short line", () => { + const decision: Extract = { + kind: "no_match", + stockItemId: "si_x", + sku: "XYZ", + }; + const line = formatDecisionLine({ decision }); + expect(line).toBe("no_match sku=XYZ"); + }); + + it("renders a skipped_idempotent with the selling_price reason", () => { + const decision: Extract = { + kind: "skipped_idempotent", + stockItemId: "si_x", + sku: "XYZ", + reason: "selling_price_already_set", + }; + const line = formatDecisionLine({ decision }); + expect(line).toContain("skipped_idempotent sku=XYZ"); + expect(line).toContain("reason=selling_price_already_set"); + }); + + it("renders a skipped_idempotent with the markup reason", () => { + const decision: Extract = { + kind: "skipped_idempotent", + stockItemId: "si_x", + sku: "XYZ", + reason: "markup_already_set", + }; + const line = formatDecisionLine({ decision }); + expect(line).toContain("reason=markup_already_set"); + }); +}); + +// ─── formatPatchLine — the operator-facing would_patch line shape. + +describe("formatPatchLine", () => { + it("renders the canonical would_patch line with cost + selling + markup deltas", () => { + const decision = decideMarkupForStockItem( + stockItem({ id: "si_abc", sku: "ABC123", cost_price: 100 }), + ); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + + const line = formatPatchLine({ + decision, + costPrice: 100, + verb: "would_patch", + }); + expect(line).toContain("would_patch sku=ABC123"); + expect(line).toContain("cost R 100.00"); + expect(line).toContain("selling R 0.00 -> R 180.00"); + expect(line).toContain("markup 0 -> 80"); + }); + + it("uses the patched verb in --commit mode", () => { + const decision = decideMarkupForStockItem( + stockItem({ id: "si_abc", sku: "ABC123", cost_price: 50 }), + ); + expect(decision.kind).toBe("patch"); + if (decision.kind !== "patch") return; + + const line = formatPatchLine({ + decision, + costPrice: 50, + verb: "patched", + }); + expect(line.startsWith("patched ")).toBe(true); + expect(line).toContain("selling R 0.00 -> R 90.00"); + }); +}); \ No newline at end of file diff --git a/scripts/apply-default-markup.ts b/scripts/apply-default-markup.ts new file mode 100644 index 0000000..84abc42 --- /dev/null +++ b/scripts/apply-default-markup.ts @@ -0,0 +1,543 @@ +/** + * Apply a default 80% markup on every `stock_items` row that has a + * known `cost_price` but a zero `selling_price`. + * + * Context + * ─────── + * The same catalogue gap the backfill script addresses (see + * `backfill-stock-prices.ts`) has a sibling: rows that DO have a + * `cost_price` but never had `selling_price` or `markup` set. + * These items have a known input cost but no shelf price yet — + * the till can't ring them up. This script is the operator's + * one-shot tool to seed `selling_price = round(cost_price * 1.80, 2)` + * and `markup = 80` so the till starts reading them. + * + * It deliberately does NOT route through `stock-items-service.ts` + * — that service enforces a whitelist + * (`STOCK_ITEM_PATCHABLE_FIELDS`) that excludes the path the + * operator wants here. This is an operator-initiated bulk action + * (CLI, not a user form), so we write directly via admin PB. + * + * What it does + * ──────────── + * 1. **Report 1 (always, even in dry-run)**: count rows where + * `cost_price IS NULL OR cost_price = 0`. These items need a + * cost price sourced elsewhere (supplier invoice) before + * markup can be applied. Print this prominently. + * 2. **Report 2 + action**: for every row where `cost_price > 0` + * AND `selling_price = 0`: + * - `dry-run`: print the would_patch line. + * - `--commit`: PATCH the row with + * `selling_price = round(cost_price * 1.80, 2)` AND + * `markup = 80`. Both fields together — never one without + * the other. + * + * Idempotency + * ─────────── + * - Never overwrite a non-zero `selling_price`. + * - Never overwrite a non-zero `markup`. + * - Re-running with `--commit` after every row has been patched + * produces no further changes. + * + * Modes + * ───── + * - Default (no flag): dry-run. No PB writes. + * - `--commit`: PATCH the matched rows. + * - `--limit=N`: process at most N candidate rows. + * - `--sku=ABC`: process only the single row whose `sku` + * equals ABC. + * + * Run with: + * npx tsx scripts/apply-default-markup.ts # dry-run + * npx tsx scripts/apply-default-markup.ts --commit # write + * npx tsx scripts/apply-default-markup.ts --commit --limit=10 + * npx tsx scripts/apply-default-markup.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 { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; +import { escapeForPbString } from "../src/lib/server/price-list-match"; + +// ─── Constants ──────────────────────────────────────────────────────── + +/** + * Default markup percentage to apply when a row has a known + * `cost_price` but a zero `selling_price`. + * + * 80 means: `selling_price = round(cost_price * 1.80, 2)` and + * `markup = 80` (both fields together — never one without the + * other, so the till and the POS report stay consistent). + * + * To change the default rate, edit this constant and re-run the + * script with `--commit`. There is no per-row override — every + * eligible row gets the same rate. + */ +const DEFAULT_MARKUP_PERCENT = 80; + +/** Multiplier derived from DEFAULT_MARKUP_PERCENT (kept in sync by + * construction; recomputed once at module load so the math is + * obvious to the reader). */ +const DEFAULT_MARKUP_MULTIPLIER = 1 + DEFAULT_MARKUP_PERCENT / 100; + +// ─── 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(`Apply a ${DEFAULT_MARKUP_PERCENT}% default markup on stock_items that have +cost_price > 0 but selling_price = 0. + +Usage: + tsx scripts/apply-default-markup.ts # dry-run (default) + tsx scripts/apply-default-markup.ts --commit # write to PB + tsx scripts/apply-default-markup.ts --commit --limit=10 # cap rows (smoke) + tsx scripts/apply-default-markup.ts --sku=ABC123 # single SKU + +Flags: + --commit Actually PATCH stock_items. Default is dry-run. + --limit=N Process at most N candidate rows. + --sku=ABC Only the row whose sku matches ABC. + --help, -h Show this help. + +Output: Report 1 (items still needing a COST price) + one line per scanned +row + a totals block at the bottom. + +This script writes directly via admin PB and bypasses the user-facing +STOCK_ITEM_PATCHABLE_FIELDS whitelist (operator-initiated bulk action).`); +} + +// ─── Types ──────────────────────────────────────────────────────────── + +/** Subset of a `stock_items` row the decision function needs. */ +export type MarkupStockSnapshot = { + id: string; + sku: string; + cost_price?: number | null; + selling_price?: number | null; + markup?: number | null; +}; + +/** + * The pure decision: given a single stock_item snapshot, should we + * apply the default markup? + * + * Returns a discriminated union: + * - `patch` — both `selling_price` and `markup` will be set + * - `skipped_idempotent` — the row already has a non-zero selling + * price OR a non-zero markup; never clobber + * - `no_match` — `cost_price` is zero/null; nothing to + * base a markup on (Report 1 territory) + * + * Pure: no PB calls, no Date.now() side effects. Easy to unit test. + */ +export type MarkupDecision = + | { + kind: "patch"; + stockItemId: string; + sku: string; + patch: { + selling_price: { from: number; to: number }; + markup: { from: number; to: number }; + }; + } + | { + kind: "skipped_idempotent"; + stockItemId: string; + sku: string; + reason: "selling_price_already_set" | "markup_already_set"; + } + | { + kind: "no_match"; + stockItemId: string; + sku: string; + }; + +/** + * Pure decision function — exported for unit tests. + * + * Rules + * ───── + * - If `stockItem.cost_price <= 0` → `no_match` (Report 1 territory; + * the operator needs to source the cost elsewhere first). + * - If `stockItem.selling_price > 0` → `skipped_idempotent` + * (never clobber an existing shelf price). + * - If `stockItem.markup > 0` → `skipped_idempotent` (the + * explicit markup field is the POS' source of truth; never + * overwrite it). + * - Otherwise propose: + * - `selling_price` = round(cost_price * multiplier, 2) + * - `markup` = the constant + * Both fields together — never one without the other. + */ +export function decideMarkupForStockItem( + stockItem: MarkupStockSnapshot, +): MarkupDecision { + const stockCost = Number(stockItem.cost_price ?? 0); + const stockSelling = Number(stockItem.selling_price ?? 0); + const stockMarkup = Number(stockItem.markup ?? 0); + + // Report 1 boundary: nothing to base a markup on without a cost. + if (!(stockCost > 0)) { + return { + kind: "no_match", + stockItemId: stockItem.id, + sku: stockItem.sku, + }; + } + + // Idempotency: shelf price already set — the till owns that + // number now, never clobber. + if (stockSelling > 0) { + return { + kind: "skipped_idempotent", + stockItemId: stockItem.id, + sku: stockItem.sku, + reason: "selling_price_already_set", + }; + } + + // Idempotency: explicit markup already set — the POS reads + // markup alongside selling_price; never overwrite. + if (stockMarkup > 0) { + return { + kind: "skipped_idempotent", + stockItemId: stockItem.id, + sku: stockItem.sku, + reason: "markup_already_set", + }; + } + + const newSelling = round2(stockCost * DEFAULT_MARKUP_MULTIPLIER); + + return { + kind: "patch", + stockItemId: stockItem.id, + sku: stockItem.sku, + patch: { + selling_price: { from: stockSelling, to: newSelling }, + markup: { from: stockMarkup, to: DEFAULT_MARKUP_PERCENT }, + }, + }; +} + +/** Round to 2 decimal places (R cents). Uses the rounding-to-even + * behaviour of `Math.round` after a scale shift — sufficient for + * currency display; PB stores the number as a plain float + * underneath. */ +function round2(n: number): number { + if (!Number.isFinite(n)) return 0; + // Scale-shift to cents, round, scale back. Avoids the + // floating-point drift that `n.toFixed(2)`-then-Number would + // introduce on values like 0.1 + 0.2. + return Math.round(n * 100) / 100; +} + +// ─── 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 `skipped_idempotent` or `no_match` + * decision. `patch`-kind decisions are rendered by + * `formatPatchLine` instead — they need the original cost_price + * to surface the operator's actual scan input. + */ +export function formatDecisionLine(args: { + decision: Extract; +}): string { + const { decision } = args; + if (decision.kind === "no_match") { + return `no_match sku=${decision.sku}`; + } + return `skipped_idempotent sku=${decision.sku} reason=${decision.reason}`; +} + +/** + * Render the canonical patch line: + * would_patch sku=ABC123 cost R 100.00 selling 0.00 -> 180.00 markup 0 -> 80 + * + * Takes the original row's cost_price alongside the decision so + * we can show the operator "what was the cost, and what the + * selling price becomes" — the more useful scan than just the + * `from → to` of selling_price (which is always "0 → X"). + */ +export function formatPatchLine(args: { + decision: Extract; + costPrice: number; + verb: "would_patch" | "patched"; +}): string { + const { decision, costPrice, verb } = args; + const sp = decision.patch.selling_price; + const mk = decision.patch.markup; + return [ + `${verb} sku=${decision.sku}`, + `cost ${fmtMoney(costPrice)}`, + `selling ${fmtMoney(sp.from)} -> ${fmtMoney(sp.to)}`, + `markup ${mk.from} -> ${mk.to}`, + ].join(" "); +} + +// ─── PB plumbing ────────────────────────────────────────────────────── + +/** + * Read every `stock_items` row whose `cost_price > 0` AND + * `selling_price = 0` — the candidate set. Optionally filter + * further by `--sku` and cap with `--limit`. + * + * We DO NOT use PB's `getFullList` for the candidate set (that + * would return every stock row including ones with selling_price + * already set); we filter at the server with a PB `filter` so + * the round-trip stays small even if the catalogue is large. + * + * Cost-zero rows are reported separately by `countRowsNeedingCostPrice` + * below — that's Report 1. + */ +async function listMarkupCandidateStockItems( + pb: Awaited>, + args: { sku: string | null; limit: number | null }, +): Promise { + const filters: string[] = ["cost_price > 0", "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; + cost_price?: number; + selling_price?: number; + markup?: number; + }>({ + filter, + fields: "id,sku,cost_price,selling_price,markup", + 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 ?? "", + cost_price: r.cost_price ?? null, + selling_price: r.selling_price ?? null, + markup: r.markup ?? null, + })); +} + +/** + * Report 1: count rows where `cost_price IS NULL OR cost_price = 0`. + * + * PB doesn't support `IS NULL` in its filter expression — use + * `cost_price = 0` for the populated-zero case and a second + * pass with `cost_price = null` is not supported. We approximate + * "cost_price is unset OR zero" with `cost_price <= 0` (since + * `cost_price` is numeric in the schema — see + * `pb-exports/pb_schema-2.json`, the `stock_items.cost_price` + * field is `"type": "number"` with `"min": 0`, so 0 is the + * unset sentinel and `null` reads back as 0 on the typed shape + * used by PB's JS SDK anyway). + */ +async function countRowsNeedingCostPrice( + pb: Awaited>, +): Promise { + const rows = await pb.collection("stock_items").getFullList<{ + id: string; + }>({ + filter: "cost_price <= 0", + fields: "id", + batch: 500, + }); + return rows.length; +} + +type RunTotals = { + scanned: number; + wouldPatch: number; + patched: number; + skippedIdempotent: number; + noMatch: number; + errors: number; +}; + +function emptyTotals(): RunTotals { + return { + scanned: 0, + wouldPatch: 0, + patched: 0, + skippedIdempotent: 0, + noMatch: 0, + errors: 0, + }; +} + +// ─── Main ───────────────────────────────────────────────────────────── + +export async function runApplyDefaultMarkup(opts: CliOptions): Promise { + const totals = emptyTotals(); + const startTs = Date.now(); + + console.log( + `=== Apply ${DEFAULT_MARKUP_PERCENT}% default markup on cost prices (${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(); + + // Report 1 — always printed, even in dry-run, so the operator + // sees how many rows still need a cost source before they look + // at the would_patch list. + const needingCost = await countRowsNeedingCostPrice(pb); + console.log(`\nItems still needing a COST price: ${needingCost}`); + + // The candidate set for the patch is filtered to cost > 0 AND + // selling = 0. We re-apply the --sku filter at the candidate + // query so the smoke-test single-row case still works. + const stockRows = await listMarkupCandidateStockItems(pb, { + sku: opts.sku, + limit: opts.limit, + }); + console.log( + `\nScanning ${stockRows.length} stock_items row(s) (cost_price > 0 AND selling_price = 0)…\n`, + ); + totals.scanned = stockRows.length; + + for (const stockRow of stockRows) { + const decision = decideMarkupForStockItem(stockRow); + + if (decision.kind === "patch") { + if (opts.commit) { + try { + await pb.collection("stock_items").update(stockRow.id, { + selling_price: decision.patch.selling_price.to, + markup: decision.patch.markup.to, + }); + console.log( + ` ${formatPatchLine({ + decision, + costPrice: Number(stockRow.cost_price ?? 0), + verb: "patched", + })} [OK]`, + ); + totals.patched += 1; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log( + ` ${formatPatchLine({ + decision, + costPrice: Number(stockRow.cost_price ?? 0), + verb: "patched", + })} [ERR] ${msg}`, + ); + totals.errors += 1; + } + } else { + console.log( + ` ${formatPatchLine({ + decision, + costPrice: Number(stockRow.cost_price ?? 0), + verb: "would_patch", + })}`, + ); + totals.wouldPatch += 1; + } + continue; + } + + if (decision.kind === "skipped_idempotent") { + console.log(` ${formatDecisionLine({ decision })}`); + totals.skippedIdempotent += 1; + continue; + } + + // no_match — shouldn't fire here because the PB filter already + // restricts to cost_price > 0, but we keep the branch for + // safety (e.g. someone passes a hand-built snapshot in a test). + console.log(` ${formatDecisionLine({ decision })}`); + totals.noMatch += 1; + } + + printTotals(totals, opts.commit, startTs); + return totals; +} + +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(` 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 selling_price set to round(cost_price * ${DEFAULT_MARKUP_MULTIPLIER.toFixed(2)}, 2) and markup set to ${DEFAULT_MARKUP_PERCENT}.`, + ); + } + 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 runApplyDefaultMarkup(opts); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/list-irda-stock.ts b/scripts/list-irda-stock.ts new file mode 100644 index 0000000..50f404d --- /dev/null +++ b/scripts/list-irda-stock.ts @@ -0,0 +1,317 @@ +/** + * One-off stock_items query: list every active `stock_items` row + * whose `sku`, `name`, or `description` matches a configurable + * substring (default "irda", override with `--term=`), then narrow + * the list to spray cans. Read-only — never writes to PB. + * + * Why this exists + * ──────────────── + * The operator wanted a quick read-out of the IRDA spray-paint line + * without going through the UI. This script mirrors the CLI pattern + * from `apply-default-markup.ts` (admin PB, side-effect env load, + * pure filter + expand) so it can be deleted without leaving any + * shared code behind. + * + * Matching rules + * ────────────── + * - Search term (default `irda`) is matched case-insensitively + * against `sku`, `name`, OR `description` via PB's `~` operator. + * Override with `--term=...` if the actual brand in the catalogue + * is spelled differently (the IRDA line in this catalogue is + * actually "IRIDA" — pass `--term=irida` to surface it). + * - Only `is_active = true`. + * - Spray narrowing happens client-side. The script prints three + * views so the operator can see which sub-marker matches each + * row: literal phrase "spray paint", standalone word "spray", + * and the union ("spray" OR the H/HEAT variant marker). The + * summary uses the union view. + * + * Output + * ────── + * - Markdown table: SKU | Name | Current Qty | Cost (R) | Selling + * (R) | Markup | Supplier, sorted by `name` ASC. + * - Summary block: total, items WITHOUT `selling_price`, items + * WITHOUT `cost_price`, zero-stock items. + * + * Run with: + * npx tsx scripts/list-irda-stock.ts # default term: "irda" + * npx tsx scripts/list-irda-stock.ts --term=irida # override term + * + * 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 { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; + +// ─── Constants ──────────────────────────────────────────────────────── + +/** Default search term. PB's `~` operator is case-insensitive, so + * casing here doesn't matter for matching — but the literal does + * appear in the report headers. */ +const DEFAULT_SEARCH_TERM = "irda"; + +/** Narrowing marker. The user's request was for "irda spray paints" + * (literal phrase). The IRIDA line in stock_items labels every + * product with one of two sub-markers: + * - "SPRAY" (e.g. "HB IRIDA SPRAY 9010 WHITE GLOS") — the + * standard aerosol cans. + * - "H/HEAT" (e.g. "HB IRIDA H/HEAT 420 BLACK") — the heat- + * resistant variant, still an aerosol spray can, just a + * different formulation under the same IRIDA product line. + * Neither sub-marker uses the literal phrase "spray paint". So + * the narrow view in the report accepts EITHER: + * - the literal phrase "spray paint" (catches 0 of the IRIDA + * rows — useful as a baseline showing the phrase is wrong + * for this catalogue), OR + * - either of the two IRIDA sub-markers (catches all 28 + * IRIDA rows). + * Both views are printed so the operator can see exactly which + * filter triggered for each row. */ +const SPRAY_PHRASE = "spray paint"; +const SPRAY_WORD = "spray"; +const HEAT_VARIANT_WORD = "h/heat"; + +// ─── CLI parsing ────────────────────────────────────────────────────── + +type CliOptions = { term: string }; + +function parseCli(argv: string[]): CliOptions { + const opts: CliOptions = { term: DEFAULT_SEARCH_TERM }; + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } else if (arg.startsWith("--term=")) { + const raw = arg.slice("--term=".length).trim(); + if (!raw) throw new Error(`--term must be non-empty, got "${arg}"`); + opts.term = raw; + } else { + throw new Error(`Unknown CLI flag: ${arg}`); + } + } + return opts; +} + +function printHelp(): void { + console.log( + `List active stock_items rows whose sku/name/description match a search term,\nnarrowed to spray paints.\n`, + ); + console.log(`Usage: + tsx scripts/list-irda-stock.ts # default: term="irda" + tsx scripts/list-irda-stock.ts --term=irida # override term + +Flags: + --term=XYZ Substring to match (case-insensitive) against + sku/name/description. Default: "irda". + --help, -h Show this help.`); +} + +// ─── Types ──────────────────────────────────────────────────────────── + +/** Single `stock_items` row shape — the fields we read for the + * table. `expand.supplier` is the joined supplier name. */ +type IrdaStockRow = { + id: string; + sku: string; + name: string; + description: string; + current_qty: number; + cost_price?: number | null; + selling_price?: number | null; + markup?: number | null; + supplier?: string; + expand?: { + supplier?: { id?: string; suppliername?: string }; + }; +}; + +// ─── Formatting ──────────────────────────────────────────────────────── + +/** Format a number as R X.XX, or em-dash for missing/non-finite + * values (so the table reads cleanly when a row has no selling + * price). */ +function fmtMoney(n: number | null | undefined): string { + if (typeof n !== "number" || !Number.isFinite(n)) return "—"; + return `R ${n.toFixed(2)}`; +} + +/** Render the markdown table row. SKU/Name left-aligned; numbers + * right-aligned via the `---:` colon convention. Supplier falls + * back to "—" when the relation is unset so a missing supplier + * reads as such in the table rather than blowing up. */ +function renderTable(rows: IrdaStockRow[]): string { + const lines: string[] = []; + lines.push( + "| SKU | Name | Current Qty | Cost (R) | Selling (R) | Markup | Supplier |", + ); + lines.push( + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ); + + for (const r of rows) { + const sku = r.sku || "(no sku)"; + const name = r.name || "(no name)"; + const qty = Number.isFinite(r.current_qty) ? r.current_qty : 0; + const supplier = + r.expand?.supplier?.suppliername?.trim() || "—"; + + lines.push( + `| ${sku} | ${name} | ${qty} | ${fmtMoney(r.cost_price)} | ${fmtMoney(r.selling_price)} | ${r.markup ?? "—"} | ${supplier} |`, + ); + } + + return lines.join("\n"); +} + +// ─── PB plumbing ────────────────────────────────────────────────────── + +/** + * Pull every `stock_items` row matching the configured search term + * (case-insensitive `~` on `sku`, `name`, or `description`) where + * `is_active = true`. + * + * Server-side filter (rather than full-table scan + JS filter) + * keeps the round-trip small even if the catalogue grows. The + * `~` operator is PB's case-insensitive contains. + * + * The "spray paint" narrowing happens client-side on top of the + * filtered result. Two reasons: + * - Mixing two `~` clauses with `||` chains is fine in PB but + * `(A ~ x || A ~ y || B ~ x || B ~ y)` gets ugly fast and is + * easy to get wrong when someone adjusts the term. + * - We want the script's "non-spray-paint matches" count in + * the summary to be authoritative without a second PB round- + * trip — that count comes from the same client-side pass. + */ +async function listMatchingStockItems(term: string): Promise { + const pb = await getAdminPocketBase(); + // Escape `"` so a hostile/malformed term can't break out of the + // filter string. `~` itself takes care of case-insensitivity; + // no other special chars in the term are filter-significant. + const safeTerm = term.replace(/"/g, '\\"'); + // We deliberately do NOT pass a `fields` projection here: PB's + // `fields` projection drops the `expand` payload, so passing + // `fields=...` together with `expand=supplier` returns rows + // with `expand: undefined` even though the relation field is + // populated. The full record is small (28 rows max for the + // "irida" case) so the round-trip cost is negligible. + const rows = await pb.collection("stock_items").getFullList({ + filter: + `(sku ~ "${safeTerm}" || name ~ "${safeTerm}" || description ~ "${safeTerm}") && is_active = true`, + expand: "supplier", + sort: "name", + batch: 500, + }); + return rows; +} + +// ─── Main ───────────────────────────────────────────────────────────── + +async function main(): Promise { + const opts = parseCli(process.argv.slice(2)); + const startTs = Date.now(); + const allMatchingRows = await listMatchingStockItems(opts.term); + + // Client-side spray-paint narrowing. Three filters: + // - `isSprayPaintPhrase`: literal phrase "spray paint" in + // name or description (case-insensitive). + // - `isSpray`: standalone word "spray" — catches the + // standard IRIDA spray cans ("HB IRIDA SPRAY ..."). + // - `isSprayOrHeatVariant`: word "spray" OR "h/heat" — + // catches BOTH the standard and high-heat IRIDA aerosol + // cans. This is the view the user actually wanted for + // "irda spray paints" — every IRIDA product in the + // catalogue is one or the other. + const isSprayPaintPhrase = (row: IrdaStockRow): boolean => { + const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); + return hay.includes(SPRAY_PHRASE); + }; + const isSpray = (row: IrdaStockRow): boolean => { + const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); + return hay.includes(SPRAY_WORD); + }; + const isSprayOrHeatVariant = (row: IrdaStockRow): boolean => { + const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); + return hay.includes(SPRAY_WORD) || hay.includes(HEAT_VARIANT_WORD); + }; + + const phraseRows = allMatchingRows.filter(isSprayPaintPhrase); + const sprayRows = allMatchingRows.filter(isSpray); + const familyRows = allMatchingRows.filter(isSprayOrHeatVariant); + const nonSprayRows = allMatchingRows.filter((r) => !isSprayOrHeatVariant(r)); + + console.log( + `=== stock_items matching "${opts.term}" (active only) — ALL rows ===\n`, + ); + + console.log(renderTable(allMatchingRows)); + console.log(""); + + console.log( + `\n=== spray-paint narrow (literal phrase "${SPRAY_PHRASE}") ===\n`, + ); + console.log(renderTable(phraseRows)); + console.log(""); + + console.log( + `\n=== spray-paint narrow (word "${SPRAY_WORD}" only — catches IRIDA standard cans) ===\n`, + ); + console.log(renderTable(sprayRows)); + console.log(""); + + console.log( + `\n=== spray-paint narrow (word "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}" — full IRIDA spray family) ===\n`, + ); + console.log(renderTable(familyRows)); + console.log(""); + + // The user-facing summary uses the FULL-FAMILY view: every IRIDA + // product in the catalogue is either a standard spray can or + // the H/HEAT variant. The other views are printed so the + // operator can compare and confirm the data is what they + // expected. + const summaryRows = familyRows; + + // Summary counts. "WITHOUT" is read as `null OR undefined OR 0` + // — the same convention the `apply-default-markup.ts` report uses + // (`cost_price <= 0`), so the two reports can be eyeballed + // against each other. + const total = summaryRows.length; + const noSelling = summaryRows.filter( + (r) => !(Number(r.selling_price) > 0), + ).length; + const noCost = summaryRows.filter( + (r) => !(Number(r.cost_price) > 0), + ).length; + const zeroStock = summaryRows.filter( + (r) => !(Number(r.current_qty) > 0), + ).length; + + console.log(`\n=== Summary (full IRIDA spray family: "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}") ===`); + console.log(` Total matching rows: ${total}`); + console.log(` Items WITHOUT selling_price (gap): ${noSelling}`); + console.log(` Items WITHOUT cost_price (gap): ${noCost}`); + console.log(` Items with zero current_qty: ${zeroStock}`); + if (nonSprayRows.length > 0) { + console.log( + ` Matching rows that are NOT spray cans: ${nonSprayRows.length}`, + ); + console.log( + ` (these are listed in the ALL table above but excluded from the spray tables)`, + ); + } else if (allMatchingRows.length > 0) { + console.log( + ` Matching rows that are NOT spray cans: 0 (every match is a spray can)`, + ); + } + + const duration = ((Date.now() - startTs) / 1000).toFixed(1); + console.log(`\n duration: ${duration}s`); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/app/(app)/stock/page.test.tsx b/src/app/(app)/stock/page.test.tsx index a71ec5c..1a4e163 100644 --- a/src/app/(app)/stock/page.test.tsx +++ b/src/app/(app)/stock/page.test.tsx @@ -12,7 +12,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li // Module-level shared state is created with vi.hoisted so both mock // factories (pocketbase and @/lib/pocketbase) can read/write it. -const { _state, _collectionMock, _resetState } = vi.hoisted(() => { +const { _state, _collectionMock, _resetState, _routerMock } = vi.hoisted(() => { const state = { items: [] as unknown[], error: undefined as string | undefined, @@ -33,6 +33,11 @@ const { _state, _collectionMock, _resetState } = vi.hoisted(() => { subscribe: vi.fn(), unsubscribe: vi.fn(), }; + const routerMock = { + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }; return { _state: state, _collectionMock: collectionMock, @@ -50,7 +55,11 @@ const { _state, _collectionMock, _resetState } = vi.hoisted(() => { totalPages: 1, }; }); + routerMock.push.mockClear(); + routerMock.replace.mockClear(); + routerMock.back.mockClear(); }, + _routerMock: routerMock, }; }); @@ -78,11 +87,7 @@ vi.mock("@/lib/pocketbase", () => ({ })); vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: vi.fn(), - replace: vi.fn(), - back: vi.fn(), - }), + useRouter: () => _routerMock, })); import Page from "./page"; @@ -377,6 +382,54 @@ describe("StockListPage", () => { expect(await screen.findByText(/Apply changes/i)).toBeInTheDocument(); expect(await screen.findByText(/1 row/)).toBeInTheDocument(); }); + + it("does not navigate to the detail page when clicking the markup input in edit mode", async () => { + // Regression: the row used to have onClick={onOpen} unconditionally, + // so clicking a pricing input bubbled up to the row and dispatched + // router.push('/stock/:id') — kicking the operator off the list + // before they could type. The row is no longer a button in edit + // mode, and the input cells stopPropagation for defence in depth. + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + + // Sanity-check the row is no longer a button while in edit mode. + // The "Open M10 Hex Bolt" aria-label only renders when the row + // is in button-role mode. + expect( + screen.queryByRole("button", { name: /Open M10 Hex Bolt/i }), + ).toBeNull(); + + const markupInput = await screen.findByLabelText(/Markup for B-001/i); + fireEvent.click(markupInput); + + // The operator is still on the list — no detail-page navigation. + expect(_routerMock.push).not.toHaveBeenCalled(); + + // And typing into the markup input still works (it didn't lose + // focus or disappear because of the click). + fireEvent.change(markupInput, { target: { value: "25" } }); + expect(markupInput).toHaveValue(25); + + // Edit mode is still active — the toggle now reads "Done editing". + expect( + screen.getByRole("button", { name: /Done editing/i }), + ).toBeInTheDocument(); + }); + + it("still navigates to the detail page when a row is clicked outside edit mode", async () => { + // Companion to the above: the row click → detail navigation must + // still work when the operator is NOT editing pricing. (Guards + // against the gating being too aggressive.) + render(); + await screen.findByText("M10 Hex Bolt"); + + // In default (non-edit) mode the row IS a button labelled + // "Open M10 Hex Bolt" — click it and verify the navigation. + const row = await screen.findByRole("button", { name: /Open M10 Hex Bolt/i }); + fireEvent.click(row); + expect(_routerMock.push).toHaveBeenCalledWith("/stock/aaaaaaaaaaaaaaa"); + }); }); describe("Stock list — localStorage draft persistence", () => { @@ -449,4 +502,199 @@ describe("StockListPage", () => { expect(addSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function)); }); }); + + describe("Stock list — sort headers (cost / markup / selling)", () => { + beforeEach(() => { + _resetState(); + window.localStorage.clear(); + _state.items = [ + { id: "aaaaaaaaaaaaaaa", sku: "B-001", name: "M10 Hex Bolt", cost_price: 2.5, markup: 30, selling_price: 3.25, current_qty: 5, min_qty: 0, is_active: true, supplier: "" }, + ]; + }); + afterEach(() => cleanup()); + + it("passes a `sort` param to PB with the cost ascending when Cost header is clicked once", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + // Show pricing columns so the sort headers render. + fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i })); + await screen.findByTestId("sort-cost"); + + // Capture every getList invocation so we can read the sort arg. + const sortArgs: Array = []; + const orig = _collectionMock.getList.getMockImplementation(); + _collectionMock.getList.mockImplementation(async (...args: unknown[]) => { + const opts = (args[2] ?? {}) as { sort?: string }; + sortArgs.push(opts.sort); + if (orig) return await (orig as (...a: unknown[]) => unknown)(...args); + return { items: [], totalItems: 0, page: 1, perPage: 200, totalPages: 1 }; + }); + + fireEvent.click(screen.getByTestId("sort-cost")); + + // The sort cycle is none → asc → desc → none, so the first + // click picks ascending. We assert the most recent call, not + // the first (the initial fetch uses `-updated`). + await waitFor(() => { + expect(sortArgs[sortArgs.length - 1]).toBe("cost_price"); + }); + }); + + it("cycles to descending on the second click and clears on the third", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i })); + await screen.findByTestId("sort-markup"); + const sortArgs: Array = []; + const orig = _collectionMock.getList.getMockImplementation(); + _collectionMock.getList.mockImplementation(async (...args: unknown[]) => { + const opts = (args[2] ?? {}) as { sort?: string }; + sortArgs.push(opts.sort); + if (orig) return await (orig as (...a: unknown[]) => unknown)(...args); + return { items: [], totalItems: 0, page: 1, perPage: 200, totalPages: 1 }; + }); + + // Helper: click the sort-markup header and wait for the + // current sort direction (encoded in the button's aria-label). + // We re-query the button each time because the component + // re-renders on every state change — a stale handle would + // still fire clicks, but reading its attrs would reflect the + // previous render's label. + const clickAndWaitForLabel = async (matcher: RegExp) => { + const btn = screen.getByTestId("sort-markup"); + fireEvent.click(btn); + await waitFor(() => { + expect(btn.getAttribute("aria-label")).toMatch(matcher); + }); + }; + + // 1st click: ascending → label flips to "currently ascending". + await clickAndWaitForLabel(/currently ascending/); + // 2nd click: descending → label flips to "currently descending". + await clickAndWaitForLabel(/currently descending/); + // 3rd click: cleared → label flips back to the plain "Sort by" form. + await clickAndWaitForLabel(/^Sort by/); + + // All three transitions produced a fetch with the matching sort arg. + await waitFor(() => { + expect(sortArgs).toContain("markup"); + expect(sortArgs).toContain("-markup"); + expect(sortArgs).toContain("-updated"); + }); + }); + + it("does NOT navigate when a sort header is clicked (header is its own button)", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i })); + const header = await screen.findByTestId("sort-selling"); + fireEvent.click(header); + expect(_routerMock.push).not.toHaveBeenCalled(); + }); + }); + + describe("Stock list — qty edit (current / min / target)", () => { + let fetchSpy: ReturnType; + beforeEach(() => { + _resetState(); + _state.items = [ + { + id: "aaaaaaaaaaaaaaa", + sku: "B-001", + name: "M10 Hex Bolt", + current_qty: 5, + min_qty: 2, + target_qty: 10, + selling_price: 0, + cost_price: 0, + markup: 0, + is_active: true, + supplier: "", + }, + ]; + window.localStorage.clear(); + // Stub fetch — apply calls both endpoints in parallel. + fetchSpy = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("set-quantities")) { + return { + ok: true, + status: 200, + json: async () => ({ + results: [ + { + id: "aaaaaaaaaaaaaaa", + ok: true, + item: { current_qty: 12, min_qty: 4, target_qty: 20 }, + previous: { current_qty: 5, min_qty: 2, target_qty: 10 }, + }, + ], + summary: { total: 1, succeeded: 1, failed: 0 }, + }), + } as Response; + } + return { + ok: true, + status: 200, + json: async () => ({ + results: [], + summary: { total: 0, succeeded: 0, failed: 0 }, + }), + } as Response; + }); + vi.stubGlobal("fetch", fetchSpy); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders edit-mode inputs for current / min / target when Edit pricing is on", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + expect(await screen.findByLabelText(/Current qty for B-001/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Min qty for B-001/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Target qty for B-001/i)).toBeInTheDocument(); + }); + + it("does NOT navigate when clicking a qty input (input cells stopPropagation)", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + const currentInput = await screen.findByLabelText(/Current qty for B-001/i); + fireEvent.click(currentInput); + // No detail-page navigation — same defence as pricing inputs. + expect(_routerMock.push).not.toHaveBeenCalled(); + // And the input is still functional (focus + typing works). + fireEvent.change(currentInput, { target: { value: "12" } }); + expect(currentInput).toHaveValue(12); + }); + + it("POSTs qty changes to /set-quantities when Apply changes is clicked", async () => { + render(); + await screen.findByText("M10 Hex Bolt"); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + + const currentInput = await screen.findByLabelText(/Current qty for B-001/i); + fireEvent.change(currentInput, { target: { value: "12" } }); + + fireEvent.click(screen.getByRole("button", { name: /^Apply changes$/i })); + + await waitFor(() => { + const qtyCall = fetchSpy.mock.calls.find( + (c) => typeof c[0] === "string" && c[0].includes("set-quantities"), + ); + expect(qtyCall).toBeDefined(); + }); + + const qtyCall = fetchSpy.mock.calls.find( + (c) => typeof c[0] === "string" && c[0].includes("set-quantities"), + ); + const body = JSON.parse((qtyCall?.[1] as RequestInit).body as string); + expect(body.updates[0]).toMatchObject({ + id: "aaaaaaaaaaaaaaa", + currentQty: 12, + }); + }); + }); }); diff --git a/src/app/(app)/stock/page.tsx b/src/app/(app)/stock/page.tsx index f48c1d4..e217d23 100644 --- a/src/app/(app)/stock/page.tsx +++ b/src/app/(app)/stock/page.tsx @@ -46,6 +46,9 @@ import { Download, Upload, FileSpreadsheet, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -95,10 +98,45 @@ export default function StockListPage() { const [createOpen, setCreateOpen] = useState(false); const [bulkImportOpen, setBulkImportOpen] = useState(false); const [editPricing, setEditPricing] = useState(false); - type DraftRow = { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean }; + type DraftRow = { + costPrice?: number; + sellingPrice?: number; + markup?: number; + sellingLocked?: boolean; + currentQty?: number; + minQty?: number; + targetQty?: number; + }; const [pricingDraft, setPricingDraft] = useState>({}); const [pricingApplyState, setPricingApplyState] = useState<"idle" | "applying" | "applied" | "partial">("idle"); + /** + * Column sort for the cost/markup/selling headers. Cycles + * `none → asc → desc → none` on each click so the operator can + * return to the default "most recently updated first" view + * without a separate "clear sort" button. `null` means "no sort + * applied" — the PB query falls back to `-updated` in that case + * (the original behaviour). + */ + type SortKey = "cost_price" | "markup" | "selling_price"; + type SortDir = "asc" | "desc"; + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState("asc"); + const cycleSort = (key: SortKey) => { + if (sortKey !== key) { + setSortKey(key); + setSortDir("asc"); + return; + } + if (sortDir === "asc") { + setSortDir("desc"); + return; + } + // Was "desc" → clear and fall back to default sort. + setSortKey(null); + setSortDir("asc"); + }; + const fetchItems = useCallback(async () => { setLoading(true); setError(""); @@ -123,7 +161,11 @@ export default function StockListPage() { 200, { filter: filters.join(" && "), - sort: "-updated", + // PB's `sort` is one field with optional `-` prefix for + // descending. When no sort key is selected, fall back to + // `-updated` (most recently updated first) — same as + // before this feature shipped. + sort: sortKey ? `${sortDir === "desc" ? "-" : ""}${sortKey}` : "-updated", expand: "supplier", } ); @@ -138,7 +180,7 @@ export default function StockListPage() { } finally { setLoading(false); } - }, [search, lowStockOnly, missingPricingOnly]); + }, [search, lowStockOnly, missingPricingOnly, sortKey, sortDir]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change @@ -255,38 +297,107 @@ export default function StockListPage() { const clearSearch = () => setSearchInput(""); const applyPricingDraft = async () => { - const updates = Object.entries(pricingDraft).flatMap(([id, draft]) => { - const out: { id: string; costPrice?: number; sellingPrice?: number; markup?: number }[] = [{ id }]; - const o = out[0]!; - if (draft.costPrice !== undefined) o.costPrice = draft.costPrice; - if (draft.sellingPrice !== undefined) o.sellingPrice = draft.sellingPrice; - if (draft.markup !== undefined) o.markup = draft.markup; - if (o.costPrice === undefined && o.sellingPrice === undefined && o.markup === undefined) return []; - return out; - }); - if (updates.length === 0) return; + // Split the draft into pricing vs qty updates so each row + // gets sent to the right endpoint. The endpoints have + // different audit requirements (qty changes write a + // stock_ledger row; pricing changes don't, except for the + // existing cost-move ledger row written by set-pricing + // itself), so we keep them as separate round-trips. + const pricingUpdates: Array<{ + id: string; + costPrice?: number; + sellingPrice?: number; + markup?: number; + }> = []; + const quantityUpdates: Array<{ + id: string; + currentQty?: number; + minQty?: number; + targetQty?: number; + }> = []; + for (const [id, draft] of Object.entries(pricingDraft)) { + const p: (typeof pricingUpdates)[number] = { id }; + if (draft.costPrice !== undefined) p.costPrice = draft.costPrice; + if (draft.sellingPrice !== undefined) p.sellingPrice = draft.sellingPrice; + if (draft.markup !== undefined) p.markup = draft.markup; + if (p.costPrice !== undefined || p.sellingPrice !== undefined || p.markup !== undefined) { + pricingUpdates.push(p); + } + const q: (typeof quantityUpdates)[number] = { id }; + if (draft.currentQty !== undefined) q.currentQty = draft.currentQty; + if (draft.minQty !== undefined) q.minQty = draft.minQty; + if (draft.targetQty !== undefined) q.targetQty = draft.targetQty; + if (q.currentQty !== undefined || q.minQty !== undefined || q.targetQty !== undefined) { + quantityUpdates.push(q); + } + } + if (pricingUpdates.length === 0 && quantityUpdates.length === 0) return; setPricingApplyState("applying"); - try { - const res = await fetch("/api/v1/stock/items/set-pricing", { + const actor = { id: "system", name: "Operator" }; + const postJson = async (url: string, body: Record) => { + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ updates, actor: { id: "system", name: "Operator" } }), + body: JSON.stringify({ ...body, actor }), }); - const json = await res.json(); - if (!res.ok) { - toast.error(json?.message ?? "Apply failed"); - setPricingApplyState("partial"); - return; + return { res, json: await res.json().catch(() => ({})) }; + }; + try { + // Fire both endpoints in parallel — the operator has been + // staring at the apply bar long enough; no need to do them + // sequentially when the call sites are independent. + const [pricingRes, qtyRes] = await Promise.all([ + pricingUpdates.length > 0 + ? postJson("/api/v1/stock/items/set-pricing", { updates: pricingUpdates }) + : Promise.resolve(null), + quantityUpdates.length > 0 + ? postJson("/api/v1/stock/items/set-quantities", { updates: quantityUpdates }) + : Promise.resolve(null), + ]); + const pricingOk = pricingRes ? pricingRes.res.ok : true; + const qtyOk = qtyRes ? qtyRes.res.ok : true; + if (!pricingOk) { + toast.error(pricingRes?.json?.message ?? "Pricing apply failed"); } + if (!qtyOk) { + toast.error(qtyRes?.json?.message ?? "Quantity apply failed"); + } + // Merge per-row outcomes. We track which ids FAILED so the + // corresponding draft entry is preserved for a re-apply. const failedIds = new Set(); const newItems = items.map((it) => { - const row = json.results?.find?.((r: { id: string }) => r.id === it.id); - if (!row) return it; - if (!row.ok) { - failedIds.add(it.id); - return it; + let next = it; + if (pricingRes) { + const row = pricingRes.json.results?.find?.( + (r: { id: string }) => r.id === it.id, + ); + if (row && !row.ok) failedIds.add(it.id); + if (row?.ok) { + next = { + ...next, + ...row.item, + cost_price: row.item.cost_price, + selling_price: row.item.selling_price, + markup: row.item.markup, + }; + } } - return { ...it, ...row.item, cost_price: row.item.cost_price, selling_price: row.item.selling_price, markup: row.item.markup }; + if (qtyRes) { + const row = qtyRes.json.results?.find?.( + (r: { id: string }) => r.id === it.id, + ); + if (row && !row.ok) failedIds.add(it.id); + if (row?.ok) { + next = { + ...next, + ...row.item, + current_qty: row.item.current_qty, + min_qty: row.item.min_qty, + target_qty: row.item.target_qty, + }; + } + } + return next; }); setItems(newItems); const failedDraftEntries = Object.fromEntries( @@ -296,14 +407,29 @@ export default function StockListPage() { if (failedIds.size === 0) { window.localStorage.removeItem("wfdash.stock.pricingDraft"); } else { - window.localStorage.setItem("wfdash.stock.pricingDraft", JSON.stringify(failedDraftEntries)); + window.localStorage.setItem( + "wfdash.stock.pricingDraft", + JSON.stringify(failedDraftEntries), + ); + } + const pricingSummary = pricingRes?.json?.summary as + | { succeeded?: number; failed?: number } + | undefined; + const qtySummary = qtyRes?.json?.summary as + | { succeeded?: number; failed?: number } + | undefined; + const totalSucceeded = + (pricingSummary?.succeeded ?? 0) + (qtySummary?.succeeded ?? 0); + const totalFailed = + (pricingSummary?.failed ?? 0) + (qtySummary?.failed ?? 0); + setPricingApplyState(totalFailed > 0 ? "partial" : "applied"); + if (pricingOk && qtyOk) { + if (totalFailed === 0 && totalSucceeded > 0) { + toast.success(`Saved ${totalSucceeded} change${totalSucceeded === 1 ? "" : "s"}`); + } else if (totalFailed > 0) { + toast.error(`Saved ${totalSucceeded} · ${totalFailed} failed`); + } } - setPricingApplyState(failedIds.size > 0 ? "partial" : "applied"); - toast.success( - failedIds.size > 0 - ? `Saved ${json.summary.succeeded} · ${json.summary.failed} failed` - : `Saved ${json.summary.succeeded} row${json.summary.succeeded === 1 ? "" : "s"}`, - ); } catch (err) { toast.error(err instanceof Error ? err.message : "Apply failed"); setPricingApplyState("partial"); @@ -484,9 +610,33 @@ export default function StockListPage() { Target {showPricingColumns && ( <> - Cost (R) - Markup % - Selling (R) + + + )} Status @@ -541,7 +691,7 @@ export default function StockListPage() { {editPricing && Object.keys(pricingDraft).length > 0 && (
- {Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes. + {Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved changes.
+ + ); +} + /* ============================================================================ * PricingEditCells — inline edit inputs for the /stock list when the * operator has clicked "Edit pricing". @@ -720,7 +1029,15 @@ function PricingEditCells({ return ( <> - + e.stopPropagation()} + > - + e.stopPropagation()} + > - + e.stopPropagation()} + > ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: (name: string) => { + if (name === "stock_items") { + return { getOne, update }; + } + if (name === "stock_ledger") { + return { create: ledgerCreate }; + } + throw new Error(`unexpected collection ${name}`); + }, + }), +})); + +import { POST } from "./route"; + +const VALID_ID = "abcdefghijklmno"; + +function postRequest(body: unknown) { + return new NextRequest( + "http://localhost/api/v1/stock/items/set-quantities", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("POST /api/v1/stock/items/set-quantities — validation", () => { + it("returns 400 when body is not valid JSON", async () => { + const req = new NextRequest( + "http://localhost/api/v1/stock/items/set-quantities", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }, + ); + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("invalid_json"); + }); + + it("returns 400 when updates is empty", async () => { + const res = await POST( + postRequest({ updates: [], actor: { id: "u1" } }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("validation_error"); + }); + + it("returns 400 when updates is not an array", async () => { + const res = await POST( + postRequest({ updates: "not-an-array", actor: { id: "u1" } }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when actor.id is missing", async () => { + const res = await POST( + postRequest({ + updates: [{ id: VALID_ID, currentQty: 5 }], + actor: { name: "NoId" }, + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when actor is missing entirely", async () => { + const res = await POST( + postRequest({ updates: [{ id: VALID_ID, currentQty: 5 }] }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when an update.id is not a valid PB id", async () => { + const res = await POST( + postRequest({ + updates: [{ id: "not-a-valid-pb-id", currentQty: 5 }], + actor: { id: "u1" }, + }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("validation_error"); + expect(body.message).toMatch(/15-char PB id/); + }); + + it("returns 400 when more than 100 updates are submitted", async () => { + const updates = Array.from({ length: 101 }, (_, i) => ({ + id: `aaaaaaaaaaaaaaaa`.slice(0, 15), + currentQty: i, + })); + const res = await POST( + postRequest({ updates, actor: { id: "u1" } }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toMatch(/At most 100 updates/); + }); +}); + +describe("POST /api/v1/stock/items/set-quantities — happy path", () => { + it("returns 200 with the service's per-row results", async () => { + const before = { id: VALID_ID, current_qty: 5, min_qty: 2, target_qty: 10 }; + const updated = { ...before, current_qty: 8 }; + getOne.mockResolvedValueOnce(before); + update.mockResolvedValueOnce(updated); + ledgerCreate.mockResolvedValueOnce({ id: "ledger1" }); + + const res = await POST( + postRequest({ + updates: [{ id: VALID_ID, currentQty: 8 }], + actor: { id: "u1", name: "Gary" }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.summary).toEqual({ total: 1, succeeded: 1, failed: 0 }); + expect(body.results[0]).toMatchObject({ + id: VALID_ID, + ok: true, + previous: { current_qty: 5, min_qty: 2, target_qty: 10 }, + }); + }); + + it("writes a stock_ledger row before updating stock_items when currentQty changes", async () => { + const before = { id: VALID_ID, current_qty: 5 }; + const updated = { ...before, current_qty: 10 }; + getOne.mockResolvedValueOnce(before); + update.mockResolvedValueOnce(updated); + ledgerCreate.mockResolvedValueOnce({ id: "ledger1" }); + + await POST( + postRequest({ + updates: [{ id: VALID_ID, currentQty: 10 }], + actor: { id: "u1", name: "Gary" }, + }), + ); + + // Ledger write should be the FIRST call after getOne. The + // ordering is enforced by the service (audit-before-cache) so + // a crash between the two writes leaves the audit trail + // ahead of the cached total. + expect(ledgerCreate).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + // ledgerCreate mock is called before update mock — verify by + // checking call order via mock.invocationCallOrder. + const ledgerOrder = ledgerCreate.mock.invocationCallOrder[0]; + const updateOrder = update.mock.invocationCallOrder[0]; + expect(ledgerOrder).toBeLessThan(updateOrder); + }); + + it("does NOT call the ledger mock when only min/target change", async () => { + const before = { id: VALID_ID, current_qty: 5, min_qty: 2, target_qty: 10 }; + const updated = { ...before, min_qty: 3, target_qty: 12 }; + getOne.mockResolvedValueOnce(before); + update.mockResolvedValueOnce(updated); + + await POST( + postRequest({ + updates: [{ id: VALID_ID, minQty: 3, targetQty: 12 }], + actor: { id: "u1" }, + }), + ); + expect(ledgerCreate).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); +}); + +describe("POST /api/v1/stock/items/set-quantities — pb admin unavailable", () => { + it("returns 500 when getAdminPocketBase throws", async () => { + // Re-mock for this specific test only. + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + (getAdminPocketBase as unknown as ReturnType).mockRejectedValueOnce( + new Error("admin creds missing"), + ); + const res = await POST( + postRequest({ + updates: [{ id: VALID_ID, currentQty: 5 }], + actor: { id: "u1" }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.code).toBe("pb_error"); + expect(body.message).toMatch(/admin creds missing/); + }); +}); \ No newline at end of file diff --git a/src/app/api/v1/stock/items/set-quantities/route.ts b/src/app/api/v1/stock/items/set-quantities/route.ts new file mode 100644 index 0000000..62f2d81 --- /dev/null +++ b/src/app/api/v1/stock/items/set-quantities/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { updateStockItemsQuantities } from "@/lib/server/stock-quantities-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * POST /api/v1/stock/items/set-quantities + * + * Bulk-update `current_qty` / `min_qty` / `target_qty` on a set of + * `stock_items` rows. Mirrors the shape of `/set-pricing` so the + * `/stock` UI can drive both from a single draft + apply bar. + * + * Validation: + * - `updates` is a non-empty array of `{ id, currentQty?, minQty?, targetQty? }` + * - Each `id` is a 15-char PB id + * - Each numeric field, if supplied, is a finite number >= 0 + * - `actor.id` is required (audit trail attribution) + * + * Side effects (per-row, conditional): + * - `current_qty` changes write a `stock_ledger` row first so the + * audit trail stays honest. See the docstring on + * `updateStockItemsQuantities` for the rationale. + * - `min_qty` / `target_qty` are simple patch-as-you-go fields. + * + * Failures are returned per-row inside `results[]` with a stable + * `reason` so the UI can render a focused error toast. The route + * itself returns 200 even when every row failed — the operator + * wants to see WHICH rows failed and WHY, not a generic 4xx/5xx. + */ + +const MAX_UPDATES = 100; + +const PB_ID_RE = /^[a-z0-9]{15}$/; + +type Body = { + updates?: Array<{ + id?: unknown; + currentQty?: unknown; + minQty?: unknown; + targetQty?: unknown; + }>; + actor?: { id?: unknown; name?: unknown }; +}; + +export async function POST(req: Request) { + let body: Body; + try { + body = (await req.json()) as Body; + } catch { + return NextResponse.json( + { code: "invalid_json", message: "Body is not valid JSON" }, + { status: 400 }, + ); + } + + if (!Array.isArray(body.updates) || body.updates.length === 0) { + return NextResponse.json( + { code: "validation_error", message: "updates must be a non-empty array" }, + { status: 400 }, + ); + } + if (body.updates.length > MAX_UPDATES) { + return NextResponse.json( + { + code: "validation_error", + message: `At most ${MAX_UPDATES} updates per call`, + }, + { status: 400 }, + ); + } + if (!body.actor || typeof body.actor.id !== "string" || !body.actor.id) { + return NextResponse.json( + { code: "validation_error", message: "actor.id is required" }, + { status: 400 }, + ); + } + + const updates: Array<{ + id: string; + currentQty?: number; + minQty?: number; + targetQty?: number; + }> = []; + for (let i = 0; i < body.updates.length; i++) { + const u = body.updates[i]; + if (!u || typeof u.id !== "string" || !PB_ID_RE.test(u.id)) { + return NextResponse.json( + { + code: "validation_error", + message: `updates[${i}].id must be a 15-char PB id`, + }, + { status: 400 }, + ); + } + const out: { + id: string; + currentQty?: number; + minQty?: number; + targetQty?: number; + } = { id: u.id }; + if (u.currentQty !== undefined) out.currentQty = Number(u.currentQty); + if (u.minQty !== undefined) out.minQty = Number(u.minQty); + if (u.targetQty !== undefined) out.targetQty = Number(u.targetQty); + updates.push(out); + } + + let pb; + try { + pb = await getAdminPocketBase(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "pb_error", message }, + { status: 500 }, + ); + } + + const result = await updateStockItemsQuantities({ + pb, + updates, + actor: { id: body.actor.id, name: String(body.actor.name ?? body.actor.id) }, + }); + + return NextResponse.json(result, { status: 200 }); +} \ No newline at end of file diff --git a/src/lib/server/stock-quantities-service.test.ts b/src/lib/server/stock-quantities-service.test.ts new file mode 100644 index 0000000..b9b26ac --- /dev/null +++ b/src/lib/server/stock-quantities-service.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi } from "vitest"; +import { + updateStockItemsQuantities, + type UpdateStockItemsQuantitiesInput, +} from "./stock-quantities-service"; + +/** + * Helper: build a stub PB that exposes `stock_items` (getOne + + * update) and `stock_ledger` (create). Returns the PB instance and + * an array of every ledger row written, so tests can assert on the + * audit trail. + */ +function stubPb(args: { + before: Record; + updated?: Record; +}) { + const ledgerCreates: Array> = []; + const updated = args.updated ?? { ...args.before }; + return { + pb: { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockResolvedValue(args.before), + update: vi.fn().mockResolvedValue(updated), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { + create: vi.fn().mockImplementation(async (row) => { + ledgerCreates.push(row); + return { id: "ledger1", ...row }; + }), + }; + } + throw new Error(`unexpected collection ${name}`); + }, + }, + ledgerCreates, + } as const; +} + +describe("updateStockItemsQuantities — happy path", () => { + it("patches all three qty fields and reports per-row previous", async () => { + const before = { + id: "abcdefghijklmno", + current_qty: 5, + min_qty: 2, + target_qty: 10, + }; + const updated = { + ...before, + current_qty: 8, + min_qty: 3, + target_qty: 12, + }; + const { pb } = stubPb({ before, updated }); + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [ + { id: "abcdefghijklmno", currentQty: 8, minQty: 3, targetQty: 12 }, + ], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.summary).toEqual({ total: 1, succeeded: 1, failed: 0 }); + expect(result.results[0]).toMatchObject({ + id: "abcdefghijklmno", + ok: true, + previous: { current_qty: 5, min_qty: 2, target_qty: 10 }, + item: updated, + }); + }); +}); + +describe("updateStockItemsQuantities — stock_ledger audit on current_qty change", () => { + it("writes a stock_ledger row with reason=adjustment when currentQty changes", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2 }; + const updated = { ...before, current_qty: 8 }; + const { pb, ledgerCreates } = stubPb({ before, updated }); + + await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 8 }], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(ledgerCreates).toEqual([ + expect.objectContaining({ + stock_item: "abcdefghijklmno", + // delta = 8 - 5 = +3 + change_qty: 3, + reason: "adjustment", + reference_type: "manual", + notes: expect.stringMatching(/edited qty 5 → 8 from \/stock/), + created_by: "useropid1234567", + }), + ]); + }); + + it("writes a NEGATIVE change_qty when current_qty is decremented", async () => { + const before = { id: "abcdefghijklmno", current_qty: 50, min_qty: 5 }; + const updated = { ...before, current_qty: 47 }; + const { pb, ledgerCreates } = stubPb({ before, updated }); + + await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 47 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(ledgerCreates).toEqual([ + expect.objectContaining({ + change_qty: -3, + notes: expect.stringMatching(/edited qty 50 → 47/), + }), + ]); + }); + + it("does NOT write a stock_ledger row when only min/target change (no current_qty movement)", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2, target_qty: 10 }; + const updated = { ...before, min_qty: 3, target_qty: 12 }; + const { pb, ledgerCreates } = stubPb({ before, updated }); + + await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", minQty: 3, targetQty: 12 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(ledgerCreates).toEqual([]); + }); + + it("does NOT write a stock_ledger row when currentQty is supplied but matches existing value", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2 }; + const { pb, ledgerCreates } = stubPb({ before }); + + await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 5 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(ledgerCreates).toEqual([]); + }); +}); + +describe("updateStockItemsQuantities — validation", () => { + it("rejects the row when a forbidden field is included", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5 }; + const { pb } = stubPb({ before }); + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [ + // `selling_price` is not on the whitelist for this service. + { id: "abcdefghijklmno", currentQty: 8, sellingPrice: 99 } as never, + ], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.summary.failed).toBe(1); + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "forbidden_field", + }); + }); + + it("rejects negative numbers", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5 }; + const { pb } = stubPb({ before }); + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: -1 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "validation_error", + }); + }); + + it("rejects non-integer current_qty (fractions are truncated, not rejected)", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5 }; + const updated = { ...before, current_qty: 8 }; + const { pb, ledgerCreates } = stubPb({ before, updated }); + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 8.9 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.results[0]?.ok).toBe(true); + // 8.9 → 8, delta = 8 - 5 = +3. + expect(ledgerCreates[0]).toMatchObject({ change_qty: 3 }); + }); + + it("returns not_found when the row doesn't exist", async () => { + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockRejectedValue(new Error("404 not found")), + update: vi.fn(), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { create: vi.fn() }; + } + throw new Error(`unexpected ${name}`); + }, + }; + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 8 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "not_found", + }); + }); + + it("returns pb_error when the stock_items update fails", async () => { + const before = { id: "abcdefghijklmno", current_qty: 5 }; + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockResolvedValue(before), + update: vi.fn().mockRejectedValue(new Error("PB down")), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { create: vi.fn() }; + } + throw new Error(`unexpected ${name}`); + }, + }; + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 8 }], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "pb_error", + }); + }); +}); + +describe("updateStockItemsQuantities — multi-row batch", () => { + it("processes each row independently — one failure doesn't block others", async () => { + const stockById: Record> = { + siok1aaaaaaaaaaa: { id: "siok1aaaaaaaaaaa", current_qty: 5, min_qty: 2 }, + sibad2aaaaaaaaa: { id: "sibad2aaaaaaaaa", current_qty: 5, min_qty: 2 }, + }; + const ledgerCreates: Array> = []; + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockImplementation(async (id: string) => { + if (!stockById[id]) throw new Error("404 not found"); + return stockById[id]; + }), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + // The second row's update blows up. + if (id === "sibad2aaaaaaaaa") throw new Error("PB write failed"); + stockById[id] = { ...stockById[id], ...patch }; + return stockById[id]; + }), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { + create: vi.fn().mockImplementation(async (row) => { + ledgerCreates.push(row); + return { id: "ledger1", ...row }; + }), + }; + } + throw new Error(`unexpected ${name}`); + }, + }; + + const result = await updateStockItemsQuantities({ + pb: pb as never, + updates: [ + { id: "siok1aaaaaaaaaaa", currentQty: 10 }, + { id: "sibad2aaaaaaaaa", currentQty: 10 }, + ], + actor: { id: "u1", name: "Gary" }, + } as UpdateStockItemsQuantitiesInput); + + expect(result.summary).toEqual({ total: 2, succeeded: 1, failed: 1 }); + expect(result.results[0]?.ok).toBe(true); + expect(result.results[1]?.ok).toBe(false); + expect((result.results[1] as { reason?: string }).reason).toBe("pb_error"); + // The service writes the ledger row BEFORE the cache update + // (audit-before-cache, same pattern as stock-surplus / + // stock-booking-service), so the bad row's ledger entry lands + // before the stock_items update fails. Two ledger rows are + // the correct outcome — the second one is a "drift" that a + // future drift-repair job can rebuild current_qty from. + // Operators see the failure in the toast and can re-apply. + expect(ledgerCreates).toHaveLength(2); + expect(ledgerCreates[0]).toMatchObject({ + stock_item: "siok1aaaaaaaaaaa", + change_qty: 5, + }); + expect(ledgerCreates[1]).toMatchObject({ + stock_item: "sibad2aaaaaaaaa", + change_qty: 5, + }); + }); +}); \ No newline at end of file diff --git a/src/lib/server/stock-quantities-service.ts b/src/lib/server/stock-quantities-service.ts new file mode 100644 index 0000000..b80044e --- /dev/null +++ b/src/lib/server/stock-quantities-service.ts @@ -0,0 +1,286 @@ +import type { StockItem } from "@/lib/types"; + +/** + * Stock Quantities service — backs the inline `current_qty` / + * `min_qty` / `target_qty` edit path on `/stock`. + * + * Mirrors `stock-pricing-service.ts` shape: whitelist-validated + * patches, per-row result rows with stable failure reasons, and a + * `summary` for the toast. The big difference: when `current_qty` + * changes we MUST write a `stock_ledger` row first so the audit + * trail stays honest. `min_qty` and `target_qty` are simple + * patch-as-you-go fields — no ledger row. + * + * Why a separate service instead of folding into + * `updateStockItemsPricing`? + * + * - Inventory writes and catalogue writes have different + * invariants. Conflating them invites a future bug where a + * pricing fix accidentally drops a ledger row (or vice versa). + * - The two callers in `/stock`'s `applyDraft` already need to + * know which rows went where so the toast can say "Saved 5 + * prices · 2 qty" instead of swallowing the distinction. + * - Tests stay focused: a pricing-only test never touches the + * ledger mock, and a qty-only test never pretends to know + * about markup. + * + * Atomicity: a single row's `stock_items.update` + `stock_ledger.create` + * pair is NOT transactional (PB JS client doesn't expose server-side + * transactions). We deliberately write the ledger row FIRST so a + * crash between the two writes leaves the audit trail ahead of + * the cached total — the same pattern `stock-surplus.ts` and + * `stock-booking-service.ts` use. A future drift-repair job can + * rebuild `current_qty` from `SUM(change_qty)` if the second + * write ever fails. + */ + +export type StockQuantityUpdate = { + id: string; + currentQty?: number; + minQty?: number; + targetQty?: number; +}; + +export type UpdateStockItemsQuantitiesInput = { + /** PB client; passed in so tests can swap a stub. */ + pb: StockItemsPb; + updates: StockQuantityUpdate[]; + actor: { id: string; name: string }; +}; + +export type StockItemsPb = { + collection: (id: string) => { + getOne: (id: string, opts?: Record) => Promise; + update: (id: string, patch: Record) => Promise; + create: (data: Record) => Promise; + }; +}; + +export type UpdateStockItemsQuantitiesFailureReason = + | "forbidden_field" + | "validation_error" + | "not_found" + | "pb_error"; + +export type StockItemQuantitySnapshot = { + current_qty?: number; + min_qty?: number; + target_qty?: number; +}; + +export type UpdateStockItemsQuantitiesResultRow = + | { + id: string; + ok: true; + item: StockItem; + previous: StockItemQuantitySnapshot; + /** PB id of the ledger row we wrote (only set when current_qty changed). */ + ledgerId?: string; + } + | { + id: string; + ok: false; + reason: UpdateStockItemsQuantitiesFailureReason; + message: string; + }; + +export type UpdateStockItemsQuantitiesResult = { + results: UpdateStockItemsQuantitiesResultRow[]; + summary: { total: number; succeeded: number; failed: number }; +}; + +const PATCHABLE_KEYS = ["currentQty", "minQty", "targetQty"] as const; + +/** + * Round a number to an integer (or throw if it's not finite). + * Stock quantities are whole units by convention — see the same + * `Math.trunc` decision in `stock/items/[id]/adjust/route.ts`. If + * a future caller legitimately needs fractional (e.g. metres of + * cable) they can drop the trunc and the test that pins it. + */ +function coerceNonNegativeInt( + key: string, + value: unknown, +): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${key} must be a finite number >= 0`); + } + return Math.trunc(value); +} + +/** + * Apply a batch of `current_qty` / `min_qty` / `target_qty` patches + * to `stock_items`, writing a `stock_ledger` row whenever + * `current_qty` actually moves. See the file-level docstring for + * the audit-first / cache-second ordering rationale. + * + * Per-row failures are surfaced in `results[]` with stable `reason` + * codes; the route maps those to HTTP status. The function never + * throws on per-row errors — a broken row in the middle of the + * batch should not abort the rest. + */ +export async function updateStockItemsQuantities( + input: UpdateStockItemsQuantitiesInput, +): Promise { + const results: UpdateStockItemsQuantitiesResultRow[] = []; + + for (const update of input.updates) { + // 1. Whitelist + type validation. Reject the row on any + // forbidden / non-finite / negative field — partial patches + // with bad fields are confusing downstream (the operator + // sees "Saved" but a field silently dropped), so we refuse + // the whole row. + let validationFailed = false; + for (const key of Object.keys(update) as (keyof StockQuantityUpdate)[]) { + if ( + key !== "id" && + !(PATCHABLE_KEYS as readonly string[]).includes(key) + ) { + results.push({ + id: update.id, + ok: false, + reason: "forbidden_field", + message: `forbidden_field: ${key}`, + }); + validationFailed = true; + break; + } + const value = (update as Record)[key]; + if (key !== "id" && value !== undefined) { + try { + coerceNonNegativeInt(key, value); + } catch (err) { + results.push({ + id: update.id, + ok: false, + reason: "validation_error", + message: err instanceof Error ? err.message : `${key} invalid`, + }); + validationFailed = true; + break; + } + } + } + if (validationFailed) continue; + + // 2. Load the existing row for previous-snapshot + not_found. + let before: StockItem; + try { + before = (await input.pb + .collection("stock_items") + .getOne(update.id)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/not found|404/i.test(message)) { + results.push({ + id: update.id, + ok: false, + reason: "not_found", + message: `stock_items row ${update.id} not found`, + }); + continue; + } + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to load: ${message}`, + }); + continue; + } + + // 3. Normalise the requested numbers. `Math.trunc` happens here + // too — the route may have received floats from the form + // input even though we typed the inputs as integers. + const requestedCurrent = + update.currentQty !== undefined ? Math.trunc(update.currentQty) : undefined; + const requestedMin = + update.minQty !== undefined ? Math.trunc(update.minQty) : undefined; + const requestedTarget = + update.targetQty !== undefined ? Math.trunc(update.targetQty) : undefined; + + const beforeCurrent = Number(before.current_qty ?? 0); + const beforeMin = Number(before.min_qty ?? 0); + const beforeTarget = Number(before.target_qty ?? 0); + + const currentMoved = + requestedCurrent !== undefined && requestedCurrent !== beforeCurrent; + + // 4. Write the ledger row FIRST when current_qty actually + // moved. Audit-before-cache so a crash between the two + // writes leaves the audit trail ahead of the cached total. + // We skip the ledger write when the value didn't change + // (idempotent re-apply) — emitting a zero-delta ledger row + // would pollute the audit log with no-ops. + let ledgerId: string | undefined; + if (currentMoved && requestedCurrent !== undefined) { + const delta = requestedCurrent - beforeCurrent; + try { + const created = await input.pb.collection("stock_ledger").create({ + stock_item: update.id, + change_qty: delta, + reason: "adjustment", + reference_type: "manual", + notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} from /stock`, + created_by: input.actor.id, + }); + ledgerId = (created as { id: string }).id; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to write stock_ledger: ${message}`, + }); + continue; + } + } + + // 5. Patch the row. Only fields the caller actually passed get + // written — a no-op patch (every requested field matches + // the existing value) still hits PB but with an empty + // patch object, which PB accepts as a no-op. + const patch: Record = {}; + if (requestedCurrent !== undefined) patch.current_qty = requestedCurrent; + if (requestedMin !== undefined) patch.min_qty = requestedMin; + if (requestedTarget !== undefined) patch.target_qty = requestedTarget; + + let updated: StockItem; + try { + updated = (await input.pb + .collection("stock_items") + .update(update.id, patch)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to update: ${message}`, + }); + continue; + } + + const previous: StockItemQuantitySnapshot = { + current_qty: beforeCurrent, + min_qty: beforeMin, + target_qty: beforeTarget, + }; + + results.push({ + id: update.id, + ok: true, + item: updated, + previous, + ...(ledgerId ? { ledgerId } : {}), + }); + } + + const summary = { + total: input.updates.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + }; + return { results, summary }; +} \ No newline at end of file