WFDASH-WEBAPP/scripts/apply-default-markup.test.ts

252 lines
8.5 KiB
TypeScript
Raw Permalink Normal View History

feat(stock): inline qty editing + column sort + pricing-gap operator scripts 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.
2026-09-14 11:04:26 +00:00
/**
* 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");
});
});