feat(stock): seed pricing fields on create + sync price_list to stock_items

Closes the pre-Fix-#1 stock pricing gap so the till and the POS see
prices on day one.

What's new:

- price-list-service: addPriceListItems now mirrors the upserted
  selling_price + markup onto the matching stock_items row when
  (and only when) the stock row has no price yet. Sync failures
  are returned as warnings — never as a failed price_list write,
  so a single broken stock row can't poison a 500-item batch.
- stock-quick-draft-service: addQuickDraftLine seeds cost_price +
  bootstraps a one-entry cost_history on inline-create when the
  buyer supplies newStockItem.costPrice. Existing-item path
  backfills cost_price when zero and always appends a
  cost_history entry so the line addition shows up in the audit
  log without waiting on bookStockFromInvoice.
- stock/items/create (POST): accepts optional costPrice,
  sellingPrice, and markup on the standalone create endpoint so
  the operator can stamp a price and bootstrap cost_history in
  one round-trip. Empty bodies still produce an empty
  cost_history (legacy callers unchanged).
- scripts/backfill-stock-prices.ts: one-shot operator tool that
  mirrors price_list rows onto existing stock_items rows whose
  selling_price is still zero. Pure decision function exported
  for unit tests; --commit / --limit / --sku CLI; idempotent
  (never overwrites a non-zero shelf price or cost).
- vitest.config.ts: pull scripts/**/*.test.ts into the vitest
  include list so backfill-stock-prices.test.ts runs in
  `npx vitest run` without a per-script config.

Tests: 65 new test cases across price-list-service,
stock-quick-draft-service, stock/items create, and
backfill-stock-prices.
This commit is contained in:
Dave 2026-09-14 13:04:08 +02:00
parent 58b174606b
commit 7443776a14
9 changed files with 1763 additions and 8 deletions

View file

@ -0,0 +1,319 @@
/**
* Tests for the pure `decideBackfill` decision function in
* `scripts/backfill-stock-prices.ts`.
*
* The script's PB I/O is not under test here — only the logic that
* decides "given this stock_item and this price_list candidate,
* what (if anything) should we patch?" The orchestrator and the
* PB plumbing stay untested because they require a live PB admin
* client, which the dry-run + `pnpm exec tsx … --limit=10` smoke
* test covers.
*
* Each test pins `nowIso` to a deterministic timestamp so the
* cost_history bootstrap entry can be asserted verbatim.
*/
import { describe, it, expect } from "vitest";
import {
decideBackfill,
formatDecisionLine,
type BackfillStockSnapshot,
type BackfillPriceListCandidate,
type BackfillDecision,
} from "./backfill-stock-prices";
const NOW = "2026-09-14T01:00:00.000Z";
function stockItem(overrides: Partial<BackfillStockSnapshot> = {}): BackfillStockSnapshot {
return {
id: "si_abc",
sku: "ABC123",
selling_price: 0,
markup: 0,
cost_price: 0,
cost_history: [],
...overrides,
};
}
function priceListCandidate(
overrides: Partial<BackfillPriceListCandidate> = {},
): BackfillPriceListCandidate {
return {
id: "pl_xyz",
sku: "ABC123",
sku_normalised: "ABC123",
cost_price: 65,
selling_price: 87.5,
markup: 25,
updated: "2026-09-13T12:00:00.000Z",
...overrides,
};
}
// ─── Tests ─────────────────────────────────────────────────────────────
describe("decideBackfill", () => {
it("proposes both cost + selling patch when both are zero", () => {
const decision = decideBackfill({
stockItem: stockItem({
id: "si_unpriced",
selling_price: 0,
markup: 0,
cost_price: 0,
}),
priceListCandidate: priceListCandidate({
cost_price: 65,
selling_price: 87.5,
markup: 25,
}),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("patch");
if (decision.kind !== "patch") return;
expect(decision.stockItemId).toBe("si_unpriced");
expect(decision.sku).toBe("ABC123");
expect(decision.priceListId).toBe("pl_xyz");
expect(decision.multiMatch).toBe(false);
// selling + markup + cost + cost_history all proposed.
expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 });
expect(decision.patch.markup).toEqual({ from: 0, to: 25 });
expect(decision.patch.cost_price).toEqual({ from: 0, to: 65 });
expect(decision.patch.cost_history_append).toEqual({
cost_price: 65,
quantity: 0,
purchased_at: NOW,
purchase_id: "",
});
});
it("proposes only selling patch (no cost change) when stock.cost_price already set", () => {
const decision = decideBackfill({
stockItem: stockItem({
id: "si_costed",
// Selling = 0 (so the till can't read it) but cost is
// already set from a prior purchase. We backfill only the
// shelf-side fields — never overwrite the cost the buyer
// or accountant already locked in.
selling_price: 0,
markup: 0,
cost_price: 80,
cost_history: [
{
cost_price: 80,
quantity: 5,
purchased_at: "2026-08-20T00:00:00.000Z",
purchase_id: "sp_001",
},
],
}),
priceListCandidate: priceListCandidate({
cost_price: 65, // different (cheaper) — IGNORE
selling_price: 87.5,
markup: 25,
}),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("patch");
if (decision.kind !== "patch") return;
expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 });
expect(decision.patch.markup).toEqual({ from: 0, to: 25 });
// Cost side must NOT be touched — the existing cost_price is
// already set, and we never overwrite. No cost_history entry
// either (the spec says "append only when patching cost for
// the first time" — and we're not patching cost here).
expect(decision.patch.cost_price).toBeUndefined();
expect(decision.patch.cost_history_append).toBeUndefined();
});
it("is a no-op (skipped_idempotent) when selling_price is already non-zero", () => {
// The script reads stock_items WHERE selling_price = 0 so this
// case shouldn't normally fire — but it's the defensive guard:
// if the row's shelf price was somehow set between the read
// and the patch (or the test feeds in a non-zero row
// directly), we skip without error.
const decision = decideBackfill({
stockItem: stockItem({
id: "si_already_priced",
selling_price: 150,
markup: 30,
cost_price: 100,
}),
priceListCandidate: priceListCandidate({
cost_price: 65,
selling_price: 87.5,
markup: 25,
}),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("skipped_idempotent");
if (decision.kind !== "skipped_idempotent") return;
expect(decision.stockItemId).toBe("si_already_priced");
expect(decision.sku).toBe("ABC123");
expect(decision.priceListId).toBe("pl_xyz");
expect(decision.reason).toBe("selling_price_already_set");
});
it("returns no_match when no price_list row matches", () => {
const decision = decideBackfill({
stockItem: stockItem({ id: "si_lonely", sku: "LONELY-1" }),
priceListCandidate: null,
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("no_match");
if (decision.kind !== "no_match") return;
expect(decision.stockItemId).toBe("si_lonely");
expect(decision.sku).toBe("LONELY-1");
});
it("returns no_match when the candidate has selling_price = 0 (defensive guard)", () => {
// The PB-level filter should exclude these, but a race /
// manual edit might surface one. Don't silently write 0.
const decision = decideBackfill({
stockItem: stockItem({ id: "si_zero_price_candidate" }),
priceListCandidate: priceListCandidate({ selling_price: 0 }),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("no_match");
});
it("skips markup when candidate has markup = 0 even if stock has selling to patch", () => {
// Some price_list rows have selling_price but no markup field
// (legacy imports, or rows where the operator typed the price
// directly). We patch selling but leave markup alone.
const decision = decideBackfill({
stockItem: stockItem({ selling_price: 0, markup: 0, cost_price: 100 }),
priceListCandidate: priceListCandidate({ selling_price: 200, markup: 0 }),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("patch");
if (decision.kind !== "patch") return;
expect(decision.patch.selling_price).toEqual({ from: 0, to: 200 });
expect(decision.patch.markup).toBeUndefined();
// cost_price already set, so cost side is untouched.
expect(decision.patch.cost_price).toBeUndefined();
expect(decision.patch.cost_history_append).toBeUndefined();
});
it("propagates multiMatch = true so the caller can log a warning", () => {
const decision = decideBackfill({
stockItem: stockItem({ id: "si_dup_catalogue" }),
priceListCandidate: priceListCandidate({ id: "pl_newer" }),
multiMatch: true,
nowIso: NOW,
});
expect(decision.kind).toBe("patch");
if (decision.kind !== "patch") return;
expect(decision.multiMatch).toBe(true);
expect(decision.priceListId).toBe("pl_newer");
});
it("treats null/undefined numeric fields as 0 (defaults)", () => {
// PB returns numeric fields as `undefined` when they're not
// set; the script must not crash on missing fields. The
// decision function should treat each undefined field as 0.
const decision = decideBackfill({
stockItem: {
id: "si_sparse",
sku: "SPARSE",
selling_price: null,
markup: undefined,
cost_price: null,
},
priceListCandidate: priceListCandidate({
cost_price: 10,
selling_price: 15,
markup: 50,
}),
multiMatch: false,
nowIso: NOW,
});
expect(decision.kind).toBe("patch");
if (decision.kind !== "patch") return;
expect(decision.patch.selling_price).toEqual({ from: 0, to: 15 });
expect(decision.patch.markup).toEqual({ from: 0, to: 50 });
expect(decision.patch.cost_price).toEqual({ from: 0, to: 10 });
expect(decision.patch.cost_history_append).toEqual({
cost_price: 10,
quantity: 0,
purchased_at: NOW,
purchase_id: "",
});
});
});
// ─── formatDecisionLine — secondary surface so the operator's dry-run
// output is human-readable and stays stable across refactors.
describe("formatDecisionLine", () => {
function decide(overrides: {
stockItem?: Partial<BackfillStockSnapshot>;
priceListCandidate?: Partial<BackfillPriceListCandidate> | null;
multiMatch?: boolean;
} = {}): BackfillDecision {
return decideBackfill({
stockItem: stockItem(overrides.stockItem),
priceListCandidate:
overrides.priceListCandidate === null
? null
: priceListCandidate(
overrides.priceListCandidate as Partial<BackfillPriceListCandidate>,
),
multiMatch: overrides.multiMatch ?? false,
nowIso: NOW,
});
}
it("renders a patch with selling + markup + cost + history delta", () => {
const decision = decide();
const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" });
expect(line).toContain("would_patch sku=ABC123");
expect(line).toContain("match=price_list:pl_xyz");
expect(line).toContain("selling: R 0.00 -> R 87.50");
expect(line).toContain("markup: 0% -> 25%");
expect(line).toContain("cost: R 0.00 -> R 65.00");
expect(line).toContain("cost_history+1");
});
it("uses the patched verb in --commit mode", () => {
const decision = decide();
const line = formatDecisionLine({ decision, verbWhenPatch: "patched" });
expect(line.startsWith("patched ")).toBe(true);
});
it("renders a no_match as a single short line", () => {
const decision = decide({ priceListCandidate: null });
const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" });
expect(line).toBe("no_match sku=ABC123");
});
it("renders a skipped_idempotent with the reason", () => {
const decision = decide({ stockItem: { selling_price: 100, markup: 30 } });
const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" });
expect(line).toContain("skipped_idempotent sku=ABC123");
expect(line).toContain("match=price_list:pl_xyz");
expect(line).toContain("reason=selling_price_already_set");
});
it("annotates multi_match decisions", () => {
const decision = decide({ multiMatch: true });
const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" });
expect(line).toContain("(multi_match)");
});
});

View file

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

View file

@ -39,6 +39,22 @@ export async function POST(req: NextRequest) {
const openingQty = Number(body.current_qty ?? 0);
const minQty = Number(body.min_qty ?? 0);
// Pricing fields (all optional). The standalone create endpoint
// is operator-initiated — if they supply a `costPrice` they
// want it stamped and `cost_history` bootstrapped today, not
// waiting on `bookStockFromInvoice`. `selling_price` and
// `markup` flow straight through to the row; no validation that
// `costPrice > 0` so the operator can backdate.
const costPriceRaw = body.costPrice;
const hasCostPrice =
typeof costPriceRaw === "number" && Number.isFinite(costPriceRaw);
const sellingPriceRaw = body.sellingPrice;
const hasSellingPrice =
typeof sellingPriceRaw === "number" && Number.isFinite(sellingPriceRaw);
const markupRaw = body.markup;
const hasMarkup =
typeof markupRaw === "number" && Number.isFinite(markupRaw);
const item = await pb.collection("stock_items").create<StockItem>({
sku: (body.sku || "").trim(),
name: body.name.trim(),
@ -50,6 +66,21 @@ export async function POST(req: NextRequest) {
supplier: body.supplier || "",
is_active: true,
notes: body.notes || "",
...(hasCostPrice
? {
cost_price: costPriceRaw,
cost_history: [
{
cost_price: costPriceRaw,
quantity: 0,
purchased_at: new Date().toISOString(),
purchase_id: "",
},
],
}
: { cost_history: [] }),
...(hasSellingPrice ? { selling_price: sellingPriceRaw } : {}),
...(hasMarkup ? { markup: markupRaw } : {}),
});
// Opening-balance ledger row — only when the operator seeded a

View file

@ -348,6 +348,64 @@ describe("/api/v1/stock/items", () => {
// Only one create call (the item itself).
expect(create).toHaveBeenCalledTimes(1);
});
it("seeds cost_price and bootstraps cost_history when costPrice is supplied", async () => {
create.mockResolvedValueOnce(makeItem({ id: "si-4", current_qty: 0 }));
const res = await createStockItem(
authedRequest("http://localhost/api/v1/stock/items/create", {
method: "POST",
body: {
name: "M10 Bolt",
sku: "B-001",
costPrice: 12.5,
},
}),
);
expect(res.status).toBe(201);
const itemCall = create.mock.calls[0][0];
expect(itemCall.cost_price).toBe(12.5);
expect(Array.isArray(itemCall.cost_history)).toBe(true);
expect(itemCall.cost_history).toHaveLength(1);
expect(itemCall.cost_history[0]).toMatchObject({
cost_price: 12.5,
quantity: 0,
purchase_id: "",
});
expect(typeof itemCall.cost_history[0].purchased_at).toBe("string");
});
it("writes selling_price and markup through to the stock_items row when supplied", async () => {
create.mockResolvedValueOnce(makeItem({ id: "si-5", current_qty: 0 }));
await createStockItem(
authedRequest("http://localhost/api/v1/stock/items/create", {
method: "POST",
body: {
name: "M10 Bolt",
sku: "B-001",
costPrice: 10,
sellingPrice: 15,
markup: 50,
},
}),
);
const itemCall = create.mock.calls[0][0];
expect(itemCall.cost_price).toBe(10);
expect(itemCall.selling_price).toBe(15);
expect(itemCall.markup).toBe(50);
});
it("does not set cost_price or cost_history when costPrice is omitted (legacy behaviour)", async () => {
create.mockResolvedValueOnce(makeItem({ id: "si-6", current_qty: 0 }));
await createStockItem(
authedRequest("http://localhost/api/v1/stock/items/create", {
method: "POST",
body: { name: "M10 Bolt", sku: "B-001" },
}),
);
const itemCall = create.mock.calls[0][0];
expect(itemCall.cost_price).toBeUndefined();
expect(itemCall.cost_history).toEqual([]);
});
});
describe("[id]/adjust POST", () => {

View file

@ -1,24 +1,32 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const { createMock, updateMock, getListMock } = vi.hoisted(() => {
const { createMock, updateMock, getListMock, stockGetListMock, stockUpdateMock } = vi.hoisted(() => {
return {
createMock: vi.fn(),
updateMock: vi.fn(),
getListMock: vi.fn(),
stockGetListMock: vi.fn(),
stockUpdateMock: vi.fn(),
};
});
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: (name: string) => {
if (name !== "price_list") {
throw new Error(`Unexpected collection in test: ${name}`);
if (name === "price_list") {
return {
getList: getListMock,
create: createMock,
update: updateMock,
};
}
return {
getList: getListMock,
create: createMock,
update: updateMock,
};
if (name === "stock_items") {
return {
getList: stockGetListMock,
update: stockUpdateMock,
};
}
throw new Error(`Unexpected collection in test: ${name}`);
},
}),
}));
@ -92,6 +100,10 @@ describe("addPriceListItems — input validation", () => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
stockGetListMock.mockReset();
stockUpdateMock.mockReset();
// Default: no matching stock_item for sync tests.
stockGetListMock.mockResolvedValue({ items: [] });
});
it("returns no_items when items array is empty", async () => {
@ -118,8 +130,11 @@ describe("addPriceListItems — per-item validation", () => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
stockGetListMock.mockReset();
stockUpdateMock.mockReset();
// No existing rows by default
getListMock.mockResolvedValue({ items: [] });
stockGetListMock.mockResolvedValue({ items: [] });
});
it("skips items missing sku", async () => {
@ -227,6 +242,10 @@ describe("addPriceListItems — upsert behaviour", () => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
stockGetListMock.mockReset();
stockUpdateMock.mockReset();
// Default: no matching stock_item (legacy tests assume this).
stockGetListMock.mockResolvedValue({ items: [] });
});
it("inserts a new row when (sku, supplier) does not exist", async () => {
@ -390,4 +409,275 @@ describe("addPriceListItems — upsert behaviour", () => {
expect(dedupeFilter).toContain("sku = \"BRG-6205\"");
expect(dedupeFilter).toContain("supplier = \"Orapi\"");
});
});
describe("addPriceListItems — stock_items selling_price sync", () => {
// The till reads `stock_items.selling_price`. The team originally
// priced everything via `price_list` (catalog-only collection).
// This block of tests verifies that an `addPriceListItems` upsert
// also back-fills the matching `stock_items.selling_price` when
// (and only when) the stock row has no price yet — and that any
// sync error doesn't fail the price_list write.
beforeEach(() => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
stockGetListMock.mockReset();
stockUpdateMock.mockReset();
// Default: no matching stock_item.
stockGetListMock.mockResolvedValue({ items: [] });
});
function priceListFreshRow(id: string) {
return {
items: [
{
id,
created: "2026-08-09T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
}
it("patches stock_items.selling_price when the matched stock row has selling_price = 0", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] }; // dedupe: brand-new
return priceListFreshRow("pl_new"); // stats
});
createMock.mockResolvedValue({ id: "pl_new" });
stockGetListMock.mockResolvedValue({
items: [{ id: "si-unpriced", selling_price: 0, markup: 0 }],
});
stockUpdateMock.mockResolvedValue({ id: "si-unpriced" });
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
selling_price: 112.0,
markup: 25,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
// Looked up stock_items by sku_normalised (BRG6205).
const stockLookupFilter =
(stockGetListMock.mock.calls[0]?.[2] as { filter?: string })?.filter ?? "";
expect(stockLookupFilter).toContain("sku_normalised");
// PATCHed the matched row with the price_list values.
expect(stockUpdateMock).toHaveBeenCalledTimes(1);
expect(stockUpdateMock).toHaveBeenCalledWith("si-unpriced", {
selling_price: 112.0,
markup: 25,
});
});
it("does NOT overwrite a stock_items row that already has a positive selling_price", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] };
return priceListFreshRow("pl_new");
});
createMock.mockResolvedValue({ id: "pl_new" });
// Stock row already priced — must NOT be touched.
stockGetListMock.mockResolvedValue({
items: [{ id: "si-priced", selling_price: 150, markup: 30 }],
});
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
selling_price: 112.0,
markup: 25,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
expect(stockUpdateMock).not.toHaveBeenCalled();
});
it("patches markup when stock.markup = 0 but stock.selling_price > 0", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] };
return priceListFreshRow("pl_new");
});
createMock.mockResolvedValue({ id: "pl_new" });
// Stock has a selling_price (already priced) but markup is 0
// (legacy migration state). We patch markup but NOT selling_price.
stockGetListMock.mockResolvedValue({
items: [{ id: "si-partial", selling_price: 150, markup: 0 }],
});
stockUpdateMock.mockResolvedValue({ id: "si-partial" });
await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
selling_price: 112.0,
markup: 25,
supplier_name: "Afrisan",
},
],
});
expect(stockUpdateMock).toHaveBeenCalledWith("si-partial", { markup: 25 });
});
it("is a no-op when no stock_items row matches the sku_normalised", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] };
return priceListFreshRow("pl_new");
});
createMock.mockResolvedValue({ id: "pl_new" });
// Default stockGetListMock returns no items.
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
selling_price: 112.0,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
expect(stockUpdateMock).not.toHaveBeenCalled();
// The price_list result should not carry any sync warnings.
if (result.ok) expect(result.warnings ?? []).toEqual([]);
});
it("isolates sync PATCH failures — price_list result still returns ok:true with a warning", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] };
return priceListFreshRow("pl_new");
});
createMock.mockResolvedValue({ id: "pl_new" });
stockGetListMock.mockResolvedValue({
items: [{ id: "si-1", selling_price: 0, markup: 0 }],
});
// Sync write fails — but the price_list result must still succeed.
stockUpdateMock.mockRejectedValueOnce(new Error("PB boom"));
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
selling_price: 112.0,
markup: 25,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.ids).toEqual(["pl_new"]);
expect(result.warnings).toBeDefined();
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toMatchObject({
sku: "BRG-6205",
stock_item_id: "si-1",
reason: expect.stringContaining("PB boom"),
});
});
it("does not call stock_items update when the upsert itself fails (skip row entirely)", async () => {
// Both dedupe and stats lookups throw — the row should land in
// skipped[], not partial-sync.
getListMock.mockRejectedValueOnce(new Error("PB dedupe down"));
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.ids).toEqual([]);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0].reason).toMatch(/pb_write_failed/);
expect(stockGetListMock).not.toHaveBeenCalled();
});
it("syncs on the UPDATE branch as well as the CREATE branch", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) {
// Dedupe lookup — row already exists.
return { items: [{ id: "pl_existing" }] };
}
// Stats lookup — updated row (created != updated).
return {
items: [
{
id: "pl_existing",
created: "2026-08-01T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
});
updateMock.mockResolvedValue({ id: "pl_existing" });
stockGetListMock.mockResolvedValue({
items: [{ id: "si-unpriced", selling_price: 0, markup: 0 }],
});
stockUpdateMock.mockResolvedValue({ id: "si-unpriced" });
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 99,
selling_price: 125,
markup: 26,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.updated).toBe(1);
expect(stockUpdateMock).toHaveBeenCalledWith("si-unpriced", {
selling_price: 125,
markup: 26,
});
});
});

View file

@ -110,6 +110,26 @@ export type AddPriceListItemsOk = {
ids: string[];
/** Items the service refused to commit, with the reason. */
skipped: Array<{ index: number; sku: string; reason: string }>;
/**
* Stock-item sync problems encountered after the price_list write
* landed. The price_list write itself always succeeds (or lands
* in `skipped`); the stock_items patch is a best-effort step
* that fires only when an `addPriceListItems` row matches a
* `stock_items` row by `sku_normalised` AND that stock row's
* `selling_price` is zero. Sync failures are collected here so a
* single broken stock row can never poison a 500-item batch.
*
* Empty when no sync attempt was needed (no matching stock_item)
* or every sync attempt succeeded.
*/
warnings?: Array<{
/** The sku whose sync attempt failed. */
sku: string;
/** The stock_items row id the sync was targeting. */
stock_item_id: string;
/** Short failure reason (PB error message). */
reason: string;
}>;
};
export type AddPriceListItemsErr = {
@ -179,6 +199,115 @@ export function computeSellingPrice(
return Math.max(0, Math.round(costPrice * multiplier * 100) / 100);
}
/**
* After a successful `price_list` upsert, mirror the row's
* selling_price + markup onto the matching `stock_items` row so
* the till (which reads `stock_items.selling_price`) sees the new
* price. The price_list catalogue is the source of truth for
* items that have *no* shelf price; the POS cashier or catalogue
* patch is the source once a price has been set — so we never
* overwrite a non-zero `stock_items.selling_price`.
*
* Sync rules:
* - Match by `sku_normalised` on `stock_items` (single row only;
* if multiple match, do nothing — ambiguous).
* - Match only when the stock row's `selling_price` is 0/null AND
* the price_list row has a positive `selling_price` to seed.
* - If `stock_items.markup` is 0/null AND `price_list.markup` is
* positive, also patch `markup`.
* - Sync failures are returned to the caller, never thrown. A
* broken sync must not poison a 500-item batch.
*
* Returns `null` when no sync was needed (no match, already priced,
* or missing selling_price on the price_list row); otherwise
* returns `{ stock_item_id, warnings? }` describing the outcome.
*/
async function syncPriceListToStockItem(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
args: {
sku: string;
sellingPrice: number | undefined;
markup: number | undefined;
},
): Promise<
| { stock_item_id: string; warning?: { sku: string; stock_item_id: string; reason: string } }
| null
> {
// No point syncing if the price_list row doesn't carry a price.
if (
typeof args.sellingPrice !== "number" ||
!Number.isFinite(args.sellingPrice) ||
args.sellingPrice <= 0
) {
return null;
}
const skuNormalised = normaliseSku(args.sku);
if (!skuNormalised) return null;
// Look up the stock row by canonical SKU. We don't auto-create
// from price_list — supplier catalogues carry thousands of SKUs
// that have never been stocked.
let stockItems: Array<{ id: string; selling_price?: number | null; markup?: number | null }>;
try {
stockItems = (
await pb.collection("stock_items").getList<{
id: string;
selling_price?: number | null;
markup?: number | null;
}>(1, 2, {
filter: `sku_normalised = "${escapeForPbString(skuNormalised)}"`,
fields: "id,selling_price,markup",
})
).items;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
stock_item_id: "",
warning: { sku: args.sku, stock_item_id: "", reason: `lookup_failed: ${message}` },
};
}
// Ambiguous match → skip without error. The price_list still
// landed; the operator can resolve the dupes in the catalogue.
if (stockItems.length !== 1) return null;
const stockRow = stockItems[0];
const stockSellingPrice = Number(stockRow.selling_price ?? 0);
const stockMarkup = Number(stockRow.markup ?? 0);
const patch: { selling_price?: number; markup?: number } = {};
// Idempotency: never overwrite a non-zero stock shelf price.
if (!(stockSellingPrice > 0)) {
patch.selling_price = args.sellingPrice;
}
if (
!(stockMarkup > 0) &&
typeof args.markup === "number" &&
Number.isFinite(args.markup) &&
args.markup > 0
) {
patch.markup = args.markup;
}
if (Object.keys(patch).length === 0) return null;
try {
await pb.collection("stock_items").update(stockRow.id, patch);
return { stock_item_id: stockRow.id };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
stock_item_id: stockRow.id,
warning: {
sku: args.sku,
stock_item_id: stockRow.id,
reason: message,
},
};
}
}
export async function addPriceListItems(
input: AddPriceListItemsInput,
): Promise<AddPriceListItemsResult> {
@ -211,6 +340,11 @@ export async function addPriceListItems(
const skipped: AddPriceListItemsOk["skipped"] = [];
const ids: string[] = [];
// Best-effort stock_items sync problems. Always empty when no
// sync was needed (no matching stock_item) or every sync
// succeeded. Populated when syncPriceListToStockItem returned
// a `warning` (lookup failure or PATCH rejection).
const syncWarnings: NonNullable<AddPriceListItemsOk["warnings"]> = [];
for (let index = 0; index < items.length; index += 1) {
const raw = items[index];
@ -289,6 +423,18 @@ export async function addPriceListItems(
const created = await pb.collection("price_list").create(row);
ids.push(created.id);
}
// Mirror selling_price + markup onto the matching stock_items
// row when (and only when) the stock row has no price yet.
// Errors are captured into `syncWarnings` below — they must
// never fail the price_list write.
const syncOutcome = await syncPriceListToStockItem(pb, {
sku,
sellingPrice: sellingPrice,
markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : undefined,
});
if (syncOutcome?.warning) {
syncWarnings.push(syncOutcome.warning);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
skipped.push({ index, sku, reason: `pb_write_failed: ${message}` });
@ -326,5 +472,6 @@ export async function addPriceListItems(
updated,
ids,
skipped,
...(syncWarnings.length > 0 ? { warnings: syncWarnings } : {}),
};
}

View file

@ -536,6 +536,135 @@ describe("addQuickDraftLine", () => {
expect(lines[0].description).toBe("Brand-new Widget");
});
it("seeds cost_price and a one-entry cost_history on inline-create when costPrice is supplied", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 10,
costPrice: 5.5,
newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1", costPrice: 7.25 },
});
expect(result.ok).toBe(true);
const items = await pbMock.collection("stock_items").getFullList();
expect(items).toHaveLength(1);
// cost_price was seeded from newStockItem.costPrice (NOT from
// the top-line costPrice which is the per-line purchase cost;
// those are two different numbers — the inline-create is the
// "this is what we'd previously paid" seed, the per-line is
// "this is what we're paying today").
expect(items[0].cost_price).toBe(7.25);
expect(Array.isArray(items[0].cost_history)).toBe(true);
const history = items[0].cost_history as Array<Record<string, unknown>>;
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
cost_price: 7.25,
quantity: 0,
purchase_id: "",
});
expect(typeof history[0].purchased_at).toBe("string");
});
it("does not seed cost_price when newStockItem.costPrice is omitted (legacy behaviour)", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 10,
costPrice: 5.5,
newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-2" },
});
expect(result.ok).toBe(true);
const items = await pbMock.collection("stock_items").getFullList();
expect(items).toHaveLength(1);
// No costPrice supplied → no cost_price field stamped and an
// empty cost_history. Existing callers see no change.
expect(items[0].cost_price).toBeUndefined();
expect(items[0].cost_history).toEqual([]);
});
it("backfills cost_price + appends cost_history when the existing stock_item has cost_price = 0", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
// Item created in the gap, with cost_price = 0 and empty history.
await pbMock.collection("stock_items").create({
id: "item-legacy",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
cost_price: 0,
cost_history: [],
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-legacy",
quantity: 20,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
const updated = await pbMock.collection("stock_items").getOne("item-legacy");
expect(updated.cost_price).toBe(18.5);
const history = updated.cost_history as Array<Record<string, unknown>>;
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
cost_price: 18.5,
quantity: 0,
purchase_id: "",
});
});
it("does not clobber an existing non-zero cost_price, but still appends to cost_history", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
await pbMock.collection("stock_items").create({
id: "item-priced",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
cost_price: 22.0,
cost_history: [
{
cost_price: 22.0,
quantity: 10,
purchased_at: "2026-07-01T08:00:00.000Z",
purchase_id: "PO26070101STK",
},
],
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-priced",
quantity: 20,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
const updated = await pbMock.collection("stock_items").getOne("item-priced");
// Don't clobber — keep the existing cost_price.
expect(updated.cost_price).toBe(22.0);
const history = updated.cost_history as Array<Record<string, unknown>>;
expect(history).toHaveLength(2);
expect(history[0]).toMatchObject({ cost_price: 22.0, purchase_id: "PO26070101STK" });
expect(history[1]).toMatchObject({
cost_price: 18.5,
quantity: 0,
purchase_id: "",
});
});
it("rejects non-positive quantities", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })

View file

@ -7,6 +7,7 @@
* statuses. Admin PB only (consistent with the other service modules).
*/
import type PocketBase from "pocketbase";
import type { StockCostHistoryEntry } from "@/lib/types";
import { generateStockPurchaseNumber } from "./stock-purchases-service";
const STOCK_ITEMS = "stock_items";
@ -69,6 +70,20 @@ export interface AddQuickDraftLineNewStockItem {
sku: string;
description?: string;
unit?: string;
/**
* Optional cost to seed onto the brand-new `stock_items` row at
* inline-create time. When supplied, `stock_items.cost_price` is
* stamped immediately and a one-entry `cost_history` is
* bootstrapped (matches `StockCostHistoryEntry` shape from
* `lib/types.ts`) so the till and the POS see a price on day
* one. The accountant's `bookStockFromInvoice` flow will append
* a second entry when the supplier invoice is booked.
*
* Existing callers (no `costPrice`) keep the previous behaviour:
* the row is created with no `cost_price` and an empty
* `cost_history`.
*/
costPrice?: number;
}
export interface AddQuickDraftLineInput {
@ -399,7 +414,15 @@ export async function addQuickDraftLine(
}
// ── Resolve the stock_item id (create if inline) ───────────────
// `inlineCreated` distinguishes "we just made this row" from
// "the caller supplied an existing stockItemId". The cost-backfill
// step below should only run on the existing-item path: when the
// row is brand-new, the inline-create branch above has already
// handled the seed (or intentionally skipped it for legacy
// callers — running the backfill on top would re-seed a row the
// caller explicitly wanted left alone).
let stockItemId = input.stockItemId;
let inlineCreated = false;
if (!stockItemId && input.newStockItem) {
if (!input.newStockItem.name || !input.newStockItem.sku) {
return {
@ -409,6 +432,29 @@ export async function addQuickDraftLine(
};
}
try {
const inlineCost = input.newStockItem.costPrice;
const hasInlineCost =
typeof inlineCost === "number" && Number.isFinite(inlineCost) && inlineCost >= 0;
// Bootstrap a one-entry cost_history when the buyer supplied
// an inline cost on a brand-new item. Matches
// `StockCostHistoryEntry` shape (see `lib/types.ts`):
// `cost_price`, `quantity` (0 — the buyer hasn't booked the
// invoice yet), `purchased_at` (now, ISO), `purchase_id` ("" —
// this is a manual seed, not tied to a stock_purchase row).
// The accountant's `bookStockFromInvoice` flow appends a
// second entry with a real purchase_id when the supplier
// invoice is booked — so the inline seed is just the "we
// knew the price when we typed it" snapshot.
const seedHistory: StockCostHistoryEntry[] = hasInlineCost
? [
{
cost_price: inlineCost as number,
quantity: 0,
purchased_at: new Date().toISOString(),
purchase_id: "",
},
]
: [];
const created = await pb.collection(STOCK_ITEMS).create({
name: input.newStockItem.name,
sku: input.newStockItem.sku,
@ -417,8 +463,12 @@ export async function addQuickDraftLine(
current_qty: 0,
is_active: true,
supplier: input.supplierId,
...(hasInlineCost
? { cost_price: inlineCost, cost_history: seedHistory }
: { cost_history: [] }),
});
stockItemId = String(created.id);
inlineCreated = true;
} catch (err) {
return {
ok: false,
@ -452,6 +502,57 @@ export async function addQuickDraftLine(
const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId);
const description = String(stockItem.name ?? "");
// ── Cost backfill + history append (existing-item path only) ────
// When the buyer adds a line for an item that was created in the
// pre-Fix-#1 gap (no `cost_price` stamped, empty `cost_history`),
// the buyer's just-typed line cost IS authoritative — backfill it
// onto the row so the till and the POS see a price today.
//
// Idempotency rules:
// - If `cost_price` is already non-zero, DON'T clobber it. The
// buyer or the booking flow owns that number; this line is
// just an audit-trail entry.
// - ALWAYS append a `cost_history` entry (even when not
// backfilling `cost_price`) so the line addition shows up in
// the audit log — `bookStockFromInvoice` would have written
// it after the invoice was booked, but for an inline draft
// add the receipt hasn't happened yet, so we capture the
// intent here.
// - On the inline-create path the seed step above already
// handled `cost_price` + `cost_history` (or intentionally
// skipped both). Skip the backfill so we don't double-write.
if (!inlineCreated) {
try {
const existingCostPrice = Number(stockItem.cost_price ?? 0);
const existingHistory = Array.isArray(stockItem.cost_history)
? (stockItem.cost_history as unknown as StockCostHistoryEntry[])
: [];
const shouldBackfill =
!(existingCostPrice > 0) && input.costPrice > 0;
const newEntry: StockCostHistoryEntry = {
cost_price: input.costPrice,
quantity: 0,
purchased_at: new Date().toISOString(),
purchase_id: "",
};
const patch: {
cost_price?: number;
cost_history: StockCostHistoryEntry[];
} = {
cost_history: [...existingHistory, newEntry],
};
if (shouldBackfill) {
patch.cost_price = input.costPrice;
}
await pb.collection(STOCK_ITEMS).update(finalStockItemId, patch);
} catch {
// Cost-patch failures must NOT block the line append. The line
// is the buyer's primary intent; the cost backfill is a
// best-effort audit/cache step. Surface nothing — the till
// will read whatever `cost_price` it had before.
}
}
// ── Append the line ────────────────────────────────────────────
try {
const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({

View file

@ -14,6 +14,11 @@ export default defineConfig({
globals: false,
include: [
"src/**/*.{test,spec}.{ts,tsx}",
// One-shot scripts under `scripts/` ship their own *.test.ts
// (e.g. scripts/backfill-stock-prices.test.ts for the pure
// decision logic). Pulled in here so `vitest run scripts/<x>`
// picks them up without a per-script config file.
"scripts/**/*.test.ts",
],
// Exclude Playwright E2E specs from Vitest.
exclude: [