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.
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/**
|
|
* 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)");
|
|
});
|
|
});
|