feat(stock): inline qty editing + column sort + pricing-gap operator scripts

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.
This commit is contained in:
Dave 2026-09-14 13:04:26 +02:00
parent 7443776a14
commit 698abe9fbc
9 changed files with 2719 additions and 79 deletions

View file

@ -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> = {},
): 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<MarkupDecision, { kind: "no_match" }> = {
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<MarkupDecision, { kind: "skipped_idempotent" }> = {
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<MarkupDecision, { kind: "skipped_idempotent" }> = {
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");
});
});

View file

@ -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<MarkupDecision, { kind: "skipped_idempotent" | "no_match" }>;
}): 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<MarkupDecision, { kind: "patch" }>;
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<ReturnType<typeof getAdminPocketBase>>,
args: { sku: string | null; limit: number | null },
): Promise<MarkupStockSnapshot[]> {
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<ReturnType<typeof getAdminPocketBase>>,
): Promise<number> {
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<RunTotals> {
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<void> {
const opts = parseCli(process.argv.slice(2));
await runApplyDefaultMarkup(opts);
}
main().catch((err) => {
console.error("FATAL:", err);
process.exit(1);
});

317
scripts/list-irda-stock.ts Normal file
View file

@ -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<IrdaStockRow[]> {
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<IrdaStockRow>({
filter:
`(sku ~ "${safeTerm}" || name ~ "${safeTerm}" || description ~ "${safeTerm}") && is_active = true`,
expand: "supplier",
sort: "name",
batch: 500,
});
return rows;
}
// ─── Main ─────────────────────────────────────────────────────────────
async function main(): Promise<void> {
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);
});

View file

@ -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(<Page />);
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(<Page />);
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(<Page />);
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<string | undefined> = [];
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(<Page />);
await screen.findByText("M10 Hex Bolt");
fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i }));
await screen.findByTestId("sort-markup");
const sortArgs: Array<string | undefined> = [];
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(<Page />);
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<typeof vi.fn>;
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(<Page />);
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(<Page />);
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(<Page />);
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,
});
});
});
});

View file

@ -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<Record<string, DraftRow>>({});
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<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("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<string, unknown>) => {
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<string>();
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() {
<TableHead className="text-right w-24">Target</TableHead>
{showPricingColumns && (
<>
<TableHead className="text-right w-28">Cost (R)</TableHead>
<TableHead className="text-right w-24">Markup %</TableHead>
<TableHead className="text-right w-28">Selling (R)</TableHead>
<SortHeader
label="Cost (R)"
sortKey="cost_price"
activeKey={sortKey}
activeDir={sortDir}
onCycle={cycleSort}
className="w-28"
testId="sort-cost"
/>
<SortHeader
label="Markup %"
sortKey="markup"
activeKey={sortKey}
activeDir={sortDir}
onCycle={cycleSort}
className="w-24"
testId="sort-markup"
/>
<SortHeader
label="Selling (R)"
sortKey="selling_price"
activeKey={sortKey}
activeDir={sortDir}
onCycle={cycleSort}
className="w-28"
testId="sort-selling"
/>
</>
)}
<TableHead className="w-36">Status</TableHead>
@ -541,7 +691,7 @@ export default function StockListPage() {
{editPricing && Object.keys(pricingDraft).length > 0 && (
<div className="sticky bottom-0 left-0 right-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 bg-amber-50 border-t border-amber-300 shadow-md flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{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.
</span>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setPricingDraft({})} data-testid="discard-pricing">
@ -582,13 +732,24 @@ function StockRow({
showPricingColumns: boolean;
editPricing: boolean;
draft:
| { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean }
| {
costPrice?: number;
sellingPrice?: number;
markup?: number;
sellingLocked?: boolean;
currentQty?: number;
minQty?: number;
targetQty?: number;
}
| undefined;
onDraftChange: (patch: {
costPrice?: number;
sellingPrice?: number;
markup?: number;
sellingLocked?: boolean;
currentQty?: number;
minQty?: number;
targetQty?: number;
}) => void;
dirty: boolean;
onOpen: () => void;
@ -600,22 +761,37 @@ function StockRow({
const formatPercent = (n: number | undefined) =>
typeof n === "number" && n > 0 ? `${n}%` : "—";
// In edit mode we render editable cells for current / min /
// target too — the same draft state holds pricing + qty
// patches and the apply bar commits both. Stock is whole-unit
// by convention (matches `stock/items/[id]/adjust/route.ts`),
// so the inputs are step=1 and min=0.
const draftCurrent = draft?.currentQty ?? Number(item.current_qty ?? 0);
const draftMin = draft?.minQty ?? Number(item.min_qty ?? 0);
const draftTarget =
draft?.targetQty ?? Number(item.target_qty ?? 0);
return (
<TableRow
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
role={editPricing ? undefined : "button"}
tabIndex={editPricing ? undefined : 0}
onClick={editPricing ? undefined : onOpen}
onKeyDown={
editPricing
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}
}
className={cn(
"cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
!editPricing &&
"cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
dirty && "bg-amber-50",
)}
aria-label={`Open ${item.name}`}
aria-label={editPricing ? undefined : `Open ${item.name}`}
>
<TableCell className="font-mono text-xs">{item.sku || "—"}</TableCell>
<TableCell className="font-medium">
@ -627,26 +803,91 @@ function StockRow({
)}
</TableCell>
<TableCell className="text-sm">{supplierName}</TableCell>
<TableCell className="text-right tabular-nums">
{Number(item.current_qty ?? 0)}
{item.unit && (
<span className="text-xs text-muted-foreground ml-1">{item.unit}</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{Number(item.min_qty ?? 0)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{item.target_qty !== undefined && item.target_qty !== null ? (
Number(item.target_qty) > 0 ? (
<span>{Number(item.target_qty)}</span>
) : (
<span className="italic">—</span>
)
) : (
<span className="italic">—</span>
)}
</TableCell>
{editPricing ? (
<>
<TableCell
className="text-right"
// Stop click bubbling so the row's button handler (when
// not in edit mode) can't navigate to the detail page.
// Defence in depth — the row handler is also gated on
// `!editPricing` upstream.
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Current qty for ${item.sku ?? item.name}`}
type="number"
inputMode="numeric"
step="1"
min="0"
className="h-8 text-right tabular-nums"
value={draftCurrent || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
onDraftChange({ currentQty: v });
}}
/>
</TableCell>
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Min qty for ${item.sku ?? item.name}`}
type="number"
inputMode="numeric"
step="1"
min="0"
className="h-8 text-right tabular-nums"
value={draftMin || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
onDraftChange({ minQty: v });
}}
/>
</TableCell>
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Target qty for ${item.sku ?? item.name}`}
type="number"
inputMode="numeric"
step="1"
min="0"
className="h-8 text-right tabular-nums"
value={draftTarget || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
onDraftChange({ targetQty: v });
}}
/>
</TableCell>
</>
) : (
<>
<TableCell className="text-right tabular-nums">
{Number(item.current_qty ?? 0)}
{item.unit && (
<span className="text-xs text-muted-foreground ml-1">{item.unit}</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{Number(item.min_qty ?? 0)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{item.target_qty !== undefined && item.target_qty !== null ? (
Number(item.target_qty) > 0 ? (
<span>{Number(item.target_qty)}</span>
) : (
<span className="italic">—</span>
)
) : (
<span className="italic">—</span>
)}
</TableCell>
</>
)}
{showPricingColumns && !editPricing && (
<>
<TableCell className="text-right tabular-nums text-muted-foreground">
@ -677,6 +918,74 @@ function StockRow({
);
}
/* ============================================================================
* SortHeader — clickable column header that cycles `none → asc →
* desc → none` on each click. Used by the cost / markup / selling
* columns on `/stock` so the operator can re-sort the result list
* before they start editing.
*
* Visual rules:
* - inactive: muted icon (`ArrowUpDown`) next to the label.
* - active asc: solid `ArrowUp`, full opacity.
* - active desc: solid `ArrowDown`, full opacity.
*
* Implementation notes:
* - It's a real `<button>` so keyboard users can Tab to it and
* press Enter / Space (the TableHead itself is non-interactive).
* `type="button"` prevents any future form wrapper from treating
* it as a submit.
* - `aria-sort` on the TableHead would be the canonical a11y
* signal — we don't have a TableHead handle here, but the
* button's `aria-label` calls out the current sort state so
* screen readers still get the gist.
* ========================================================================== */
function SortHeader({
label,
sortKey,
activeKey,
activeDir,
onCycle,
className,
testId,
}: {
label: string;
sortKey: "cost_price" | "markup" | "selling_price";
activeKey: "cost_price" | "markup" | "selling_price" | null;
activeDir: "asc" | "desc";
onCycle: (key: "cost_price" | "markup" | "selling_price") => void;
className?: string;
testId?: string;
}) {
const isActive = activeKey === sortKey;
const Icon = !isActive ? ArrowUpDown : activeDir === "asc" ? ArrowUp : ArrowDown;
const ariaLabel = isActive
? `Sort by ${label} (currently ${activeDir === "asc" ? "ascending" : "descending"}, click to ${
activeDir === "asc" ? "switch to descending" : "clear sort"
})`
: `Sort by ${label}`;
return (
<TableHead className={cn("text-right p-0", className)}>
<button
type="button"
onClick={() => onCycle(sortKey)}
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"inline-flex items-center justify-end gap-1 w-full h-full px-3 py-2 text-xs font-medium uppercase tracking-wide",
"hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset rounded-none",
isActive ? "text-foreground" : "text-muted-foreground",
)}
>
<span>{label}</span>
<Icon
aria-hidden="true"
className={cn("h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-50")}
/>
</button>
</TableHead>
);
}
/* ============================================================================
* PricingEditCells — inline edit inputs for the /stock list when the
* operator has clicked "Edit pricing".
@ -720,7 +1029,15 @@ function PricingEditCells({
return (
<>
<TableCell className="text-right">
<TableCell
className="text-right"
// Stop click bubbling so the row's button handler can't navigate
// to the detail page when the operator is trying to focus the
// cost input. Defence in depth — the row handler is also gated
// on `!editPricing` upstream, but this guarantees the input
// always wins the click regardless of what the row does later.
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Cost for ${item.sku ?? item.name}`}
type="number"
@ -735,7 +1052,10 @@ function PricingEditCells({
}}
/>
</TableCell>
<TableCell className="text-right">
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Markup for ${item.sku ?? item.name}`}
type="number"
@ -750,7 +1070,10 @@ function PricingEditCells({
}}
/>
</TableCell>
<TableCell className="text-right">
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<Input
aria-label={`Selling for ${item.sku ?? item.name}`}
type="number"

View file

@ -0,0 +1,218 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* Tests for `POST /api/v1/stock/items/set-quantities`.
*
* The route is a thin HTTP wrapper over
* `updateStockItemsQuantities` — the real coverage of the
* service-layer behaviour lives in
* `src/lib/server/stock-quantities-service.test.ts`. These tests
* focus on HTTP-shape concerns: request validation, status codes,
* and JSON body shape.
*/
// ── Mocks ────────────────────────────────────────────────────────────────
const getOne = vi.fn();
const update = vi.fn();
const ledgerCreate = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
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<typeof vi.fn>).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/);
});
});

View file

@ -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 });
}

View file

@ -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<string, unknown>;
updated?: Record<string, unknown>;
}) {
const ledgerCreates: Array<Record<string, unknown>> = [];
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<string, Record<string, unknown>> = {
siok1aaaaaaaaaaa: { id: "siok1aaaaaaaaaaa", current_qty: 5, min_qty: 2 },
sibad2aaaaaaaaa: { id: "sibad2aaaaaaaaa", current_qty: 5, min_qty: 2 },
};
const ledgerCreates: Array<Record<string, unknown>> = [];
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<string, unknown>) => {
// 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,
});
});
});

View file

@ -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: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
create: (data: Record<string, unknown>) => Promise<unknown>;
};
};
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<UpdateStockItemsQuantitiesResult> {
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<string, unknown>)[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<string, number> = {};
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 };
}