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:
parent
58b174606b
commit
7443776a14
9 changed files with 1763 additions and 8 deletions
319
scripts/backfill-stock-prices.test.ts
Normal file
319
scripts/backfill-stock-prices.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
675
scripts/backfill-stock-prices.ts
Normal file
675
scripts/backfill-stock-prices.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue