Doubles down on the /stock list to make catalogue and inventory edits one-click for the operator. What's new: - /stock: clickable column headers on Cost / Markup / Selling cycle none → asc → desc → none and feed through to the PB sort param. The apply bar now commits pricing AND qty in parallel (Promise.all against /set-pricing and /set-quantities) with per-row failure tracking and focused error toasts. In edit mode the row is no longer a button — inputs render in-place for current_qty, min_qty, and target_qty alongside the existing pricing inputs, and the row click is gated off so clicks on an input never navigate to the detail page. - stock-quantities-service + /api/v1/stock/items/set-quantities (POST): bulk-update current_qty / min_qty / target_qty on a set of stock_items rows. Whitelist-validated patches, per-row results with stable failure reasons, and a stock_ledger row written FIRST whenever current_qty actually moves so the audit trail stays ahead of the cached total (same pattern as stock-surplus and stock-booking-service). - scripts/apply-default-markup.ts: one-shot operator tool that seeds selling_price = round(cost_price * 1.80, 2) and markup = 80 on stock_items rows whose cost_price is set but selling_price is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated bulk action); idempotent (never overwrites a non-zero shelf price or markup). - scripts/list-irda-stock.ts: read-only inspector for the IRIDA spray-caint line with summary counts (without selling_price, without cost_price, zero stock) so the operator can see exactly which rows the backfill tools above will touch. Tests: 81 new test cases across /stock page, stock-quantities service + route, and apply-default-markup.
252 lines
No EOL
8.5 KiB
TypeScript
252 lines
No EOL
8.5 KiB
TypeScript
/**
|
|
* Tests for the pure decision + formatting logic in
|
|
* `scripts/apply-default-markup.ts`.
|
|
*
|
|
* The script's PB I/O is not under test here — only the logic that
|
|
* decides "given this stock_item, what (if anything) should we
|
|
* patch?" The orchestrator and the PB plumbing stay untested
|
|
* because they require a live PB admin client, which the dry-run
|
|
* + `pnpm exec tsx … --limit=10` smoke test covers.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
decideMarkupForStockItem,
|
|
formatDecisionLine,
|
|
formatPatchLine,
|
|
type MarkupStockSnapshot,
|
|
type MarkupDecision,
|
|
} from "./apply-default-markup";
|
|
|
|
function stockItem(
|
|
overrides: Partial<MarkupStockSnapshot> = {},
|
|
): MarkupStockSnapshot {
|
|
return {
|
|
id: "si_abc",
|
|
sku: "ABC123",
|
|
cost_price: 100,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────
|
|
|
|
describe("decideMarkupForStockItem", () => {
|
|
it("proposes 80% markup patch when cost > 0 and selling = 0", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({
|
|
id: "si_unpriced",
|
|
sku: "ABC123",
|
|
cost_price: 100,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
|
|
expect(decision.stockItemId).toBe("si_unpriced");
|
|
expect(decision.sku).toBe("ABC123");
|
|
expect(decision.patch.selling_price).toEqual({ from: 0, to: 180 });
|
|
expect(decision.patch.markup).toEqual({ from: 0, to: 80 });
|
|
});
|
|
|
|
it("returns no_match when cost is 0 (no patch possible)", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({
|
|
id: "si_no_cost",
|
|
sku: "NO-COST",
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("no_match");
|
|
if (decision.kind !== "no_match") return;
|
|
expect(decision.stockItemId).toBe("si_no_cost");
|
|
expect(decision.sku).toBe("NO-COST");
|
|
});
|
|
|
|
it("treats null/undefined cost_price as no_match", () => {
|
|
// PB's typed SDK may surface unset numeric fields as null or
|
|
// undefined; both should route to no_match.
|
|
const decisionNull = decideMarkupForStockItem(
|
|
stockItem({ id: "si_null_cost", cost_price: null }),
|
|
);
|
|
expect(decisionNull.kind).toBe("no_match");
|
|
|
|
const decisionUndef = decideMarkupForStockItem(
|
|
stockItem({ id: "si_undef_cost", cost_price: undefined }),
|
|
);
|
|
expect(decisionUndef.kind).toBe("no_match");
|
|
});
|
|
|
|
it("is a no-op (skipped_idempotent) when selling_price already non-zero", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({
|
|
id: "si_already_priced",
|
|
cost_price: 100,
|
|
selling_price: 150,
|
|
markup: 0,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("skipped_idempotent");
|
|
if (decision.kind !== "skipped_idempotent") return;
|
|
expect(decision.stockItemId).toBe("si_already_priced");
|
|
expect(decision.sku).toBe("ABC123");
|
|
expect(decision.reason).toBe("selling_price_already_set");
|
|
});
|
|
|
|
it("is a no-op (skipped_idempotent) when markup already non-zero (even if selling = 0)", () => {
|
|
// Edge case: row has cost_price > 0 AND selling = 0 AND a
|
|
// legacy markup > 0. We never overwrite the explicit markup
|
|
// field — that's the POS' source of truth. Skipping with
|
|
// `markup_already_set` is the right move; the operator can
|
|
// resolve manually if they want the new selling_price to
|
|
// match.
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({
|
|
id: "si_legacy_markup",
|
|
cost_price: 100,
|
|
selling_price: 0,
|
|
markup: 50,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("skipped_idempotent");
|
|
if (decision.kind !== "skipped_idempotent") return;
|
|
expect(decision.reason).toBe("markup_already_set");
|
|
});
|
|
|
|
it("rounds selling_price to 2 decimal places", () => {
|
|
// 33.33 * 1.80 = 59.994 — round to 59.99, not 60.00.
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_three_dec", cost_price: 33.33 }),
|
|
);
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
expect(decision.patch.selling_price.to).toBe(59.99);
|
|
// Markup field is always the integer constant.
|
|
expect(decision.patch.markup.to).toBe(80);
|
|
|
|
// 50.55 * 1.80 = 90.99 — already 2dp.
|
|
const decision2 = decideMarkupForStockItem(
|
|
stockItem({ id: "si_two_dec", cost_price: 50.55 }),
|
|
);
|
|
expect(decision2.kind).toBe("patch");
|
|
if (decision2.kind !== "patch") return;
|
|
expect(decision2.patch.selling_price.to).toBe(90.99);
|
|
});
|
|
|
|
it("sets markup to exactly 80 (the constant)", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_any_cost", cost_price: 42 }),
|
|
);
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
expect(decision.patch.markup).toEqual({ from: 0, to: 80 });
|
|
});
|
|
|
|
it("proposes selling_price of exactly 0 → 0 when cost is a tiny positive number", () => {
|
|
// 0.01 * 1.80 = 0.018 → 0.02 (rounded). Sanity check the
|
|
// scale-shift rounding survives tiny positive costs.
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_tiny", cost_price: 0.01 }),
|
|
);
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
expect(decision.patch.selling_price.to).toBe(0.02);
|
|
});
|
|
|
|
it("handles cost_price as a number that fails Number() defensively (NaN → no_match)", () => {
|
|
// Defensive: PB shouldn't ever hand us NaN, but a corrupted
|
|
// row could. `Number(NaN) > 0` is false, so it falls into the
|
|
// no_match branch — which is the correct behaviour (we
|
|
// can't compute a markup without a cost).
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_nan", cost_price: Number.NaN }),
|
|
);
|
|
expect(decision.kind).toBe("no_match");
|
|
});
|
|
});
|
|
|
|
// ─── formatDecisionLine — secondary surface so the operator's
|
|
// dry-run output is human-readable and stays stable across refactors.
|
|
|
|
describe("formatDecisionLine", () => {
|
|
it("renders a no_match as a single short line", () => {
|
|
const decision: Extract<MarkupDecision, { kind: "no_match" }> = {
|
|
kind: "no_match",
|
|
stockItemId: "si_x",
|
|
sku: "XYZ",
|
|
};
|
|
const line = formatDecisionLine({ decision });
|
|
expect(line).toBe("no_match sku=XYZ");
|
|
});
|
|
|
|
it("renders a skipped_idempotent with the selling_price reason", () => {
|
|
const decision: Extract<MarkupDecision, { kind: "skipped_idempotent" }> = {
|
|
kind: "skipped_idempotent",
|
|
stockItemId: "si_x",
|
|
sku: "XYZ",
|
|
reason: "selling_price_already_set",
|
|
};
|
|
const line = formatDecisionLine({ decision });
|
|
expect(line).toContain("skipped_idempotent sku=XYZ");
|
|
expect(line).toContain("reason=selling_price_already_set");
|
|
});
|
|
|
|
it("renders a skipped_idempotent with the markup reason", () => {
|
|
const decision: Extract<MarkupDecision, { kind: "skipped_idempotent" }> = {
|
|
kind: "skipped_idempotent",
|
|
stockItemId: "si_x",
|
|
sku: "XYZ",
|
|
reason: "markup_already_set",
|
|
};
|
|
const line = formatDecisionLine({ decision });
|
|
expect(line).toContain("reason=markup_already_set");
|
|
});
|
|
});
|
|
|
|
// ─── formatPatchLine — the operator-facing would_patch line shape.
|
|
|
|
describe("formatPatchLine", () => {
|
|
it("renders the canonical would_patch line with cost + selling + markup deltas", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_abc", sku: "ABC123", cost_price: 100 }),
|
|
);
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
|
|
const line = formatPatchLine({
|
|
decision,
|
|
costPrice: 100,
|
|
verb: "would_patch",
|
|
});
|
|
expect(line).toContain("would_patch sku=ABC123");
|
|
expect(line).toContain("cost R 100.00");
|
|
expect(line).toContain("selling R 0.00 -> R 180.00");
|
|
expect(line).toContain("markup 0 -> 80");
|
|
});
|
|
|
|
it("uses the patched verb in --commit mode", () => {
|
|
const decision = decideMarkupForStockItem(
|
|
stockItem({ id: "si_abc", sku: "ABC123", cost_price: 50 }),
|
|
);
|
|
expect(decision.kind).toBe("patch");
|
|
if (decision.kind !== "patch") return;
|
|
|
|
const line = formatPatchLine({
|
|
decision,
|
|
costPrice: 50,
|
|
verb: "patched",
|
|
});
|
|
expect(line.startsWith("patched ")).toBe(true);
|
|
expect(line).toContain("selling R 0.00 -> R 90.00");
|
|
});
|
|
}); |