WFDASH-WEBAPP/scripts/backfill-stock-prices.test.ts
Dave 7443776a14 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.
2026-09-14 13:04:08 +02:00

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