This commit adds the complete v2/v3 stock management functionality: - add new stock-specific item statuses and update the status visual map - implement full stock purchase API suite (create, list, retrieve, send, receive) - reorganize sidebar navigation to group stock-related pages together - add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic - add database migration for the `stock_items.target_qty` field - update type definitions and add comprehensive tests for all new stock components
137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
getStockStatus,
|
|
isLowStock,
|
|
isLowStockItem,
|
|
STOCK_STATUS_STYLES,
|
|
type StockStatus,
|
|
} from "./stock-status";
|
|
|
|
describe("getStockStatus", () => {
|
|
it("returns 'ok' when current_qty is strictly above min_qty", () => {
|
|
expect(getStockStatus(10, 5, true)).toBe<StockStatus>("ok");
|
|
expect(getStockStatus(1, 0, true)).toBe<StockStatus>("ok");
|
|
});
|
|
|
|
it("returns 'low' when current_qty equals min_qty", () => {
|
|
expect(getStockStatus(5, 5, true)).toBe<StockStatus>("low");
|
|
});
|
|
|
|
it("returns 'low' when current_qty is between 0 and min_qty (exclusive of 0)", () => {
|
|
expect(getStockStatus(1, 5, true)).toBe<StockStatus>("low");
|
|
expect(getStockStatus(3, 5, true)).toBe<StockStatus>("low");
|
|
});
|
|
|
|
it("returns 'out' when current_qty is zero", () => {
|
|
expect(getStockStatus(0, 5, true)).toBe<StockStatus>("out");
|
|
expect(getStockStatus(0, 0, true)).toBe<StockStatus>("out");
|
|
});
|
|
|
|
it("returns 'out' when current_qty is negative (treats as a bug, not a clamp)", () => {
|
|
expect(getStockStatus(-1, 5, true)).toBe<StockStatus>("out");
|
|
expect(getStockStatus(-100, 0, true)).toBe<StockStatus>("out");
|
|
});
|
|
|
|
it("returns 'inactive' regardless of qty when is_active is false", () => {
|
|
expect(getStockStatus(10, 5, false)).toBe<StockStatus>("inactive");
|
|
expect(getStockStatus(0, 5, false)).toBe<StockStatus>("inactive");
|
|
expect(getStockStatus(-1, 5, false)).toBe<StockStatus>("inactive");
|
|
});
|
|
|
|
it("treats null/undefined qty as zero (so missing fields default to 'out')", () => {
|
|
expect(getStockStatus(null, 5, true)).toBe<StockStatus>("out");
|
|
expect(getStockStatus(undefined, 5, true)).toBe<StockStatus>("out");
|
|
expect(getStockStatus(5, null, true)).toBe<StockStatus>("ok");
|
|
});
|
|
|
|
it("coerces string-like numbers (PB sometimes returns numeric fields as strings)", () => {
|
|
// `Number("5")` is 5, so this should work the same as the numeric path.
|
|
expect(getStockStatus("5" as unknown as number, 5, true)).toBe<StockStatus>("low");
|
|
expect(getStockStatus(3, "5" as unknown as number, true)).toBe<StockStatus>("low");
|
|
});
|
|
});
|
|
|
|
describe("isLowStock", () => {
|
|
it("includes 'low' AND 'out' but not 'ok' or 'inactive'", () => {
|
|
expect(isLowStock(0, 5, true)).toBe(true); // out
|
|
expect(isLowStock(3, 5, true)).toBe(true); // low
|
|
expect(isLowStock(5, 5, true)).toBe(true); // low (boundary)
|
|
expect(isLowStock(6, 5, true)).toBe(false); // ok
|
|
expect(isLowStock(10, 5, false)).toBe(false); // inactive excluded
|
|
});
|
|
});
|
|
|
|
describe("isLowStockItem", () => {
|
|
it("delegates to isLowStock using the StockItem field shape", () => {
|
|
expect(
|
|
isLowStockItem({ current_qty: 0, min_qty: 5, is_active: true }),
|
|
).toBe(true);
|
|
expect(
|
|
isLowStockItem({ current_qty: 100, min_qty: 5, is_active: true }),
|
|
).toBe(false);
|
|
// Inactive row with low stock — still excluded from the low-stock filter.
|
|
expect(
|
|
isLowStockItem({ current_qty: 0, min_qty: 5, is_active: false }),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("STOCK_STATUS_STYLES", () => {
|
|
it("exposes a labelled entry for every status", () => {
|
|
const labels = Object.values(STOCK_STATUS_STYLES).map((s) => s.label);
|
|
expect(labels).toContain("In stock");
|
|
expect(labels).toContain("Low stock");
|
|
expect(labels).toContain("Out of stock");
|
|
expect(labels).toContain("Inactive");
|
|
});
|
|
|
|
it("uses a red-leaning hex for 'out' so the list-page badge reads as urgent", () => {
|
|
const outHex = STOCK_STATUS_STYLES.out.hex.toLowerCase();
|
|
// #b91c1c is red-700. We don't pin the exact value — only assert
|
|
// the "red" intent: starts with #b and has a small green channel
|
|
// (1c). Future palette tweaks should not break this test.
|
|
expect(outHex.startsWith("#b")).toBe(true);
|
|
});
|
|
});
|
|
|
|
/* ============================================================================
|
|
* Reorder-alert badge test
|
|
*
|
|
* Pinned as a separate describe block so the "is this row flagged as
|
|
* needs-reordering" contract has a clearly-named home. A future
|
|
* "this row needs to be re-ordered" UI cue (toast, banner, dashboard
|
|
* widget) can target the same helper and add its own describe here.
|
|
* ========================================================================== */
|
|
describe("reorder-alert badge logic", () => {
|
|
it("flags 'low' as needing reorder (the operator's amber signal)", () => {
|
|
const status = getStockStatus(3, 5, true);
|
|
expect(status).toBe("low");
|
|
expect(STOCK_STATUS_STYLES[status].label).toBe("Low stock");
|
|
});
|
|
|
|
it("flags 'out' as needing reorder (the operator's red signal)", () => {
|
|
const status = getStockStatus(0, 5, true);
|
|
expect(status).toBe("out");
|
|
expect(STOCK_STATUS_STYLES[status].label).toBe("Out of stock");
|
|
});
|
|
|
|
it("does NOT flag 'ok' or 'inactive' as needing reorder", () => {
|
|
expect(getStockStatus(100, 5, true)).toBe("ok");
|
|
expect(getStockStatus(100, 5, false)).toBe("inactive");
|
|
});
|
|
|
|
it("treats the boundary case (current == min) as 'low', not 'ok'", () => {
|
|
// Boundary matters: a 5/5 item should already be in the
|
|
// re-order queue, not at full health.
|
|
expect(getStockStatus(5, 5, true)).toBe("low");
|
|
});
|
|
|
|
it("preserves the badge label across the list-page and detail-page call sites", () => {
|
|
// Same helper, same palette — no per-page override that could
|
|
// drift between the list and the detail view.
|
|
const s1 = getStockStatus(2, 10, true);
|
|
const s2 = getStockStatus(0, 10, true);
|
|
expect(STOCK_STATUS_STYLES[s1].label).toBe("Low stock");
|
|
expect(STOCK_STATUS_STYLES[s2].label).toBe("Out of stock");
|
|
});
|
|
});
|