Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import * as XLSX from "xlsx";
|
|
import {
|
|
buildBulkImportPlan,
|
|
buildStockExportWorkbook,
|
|
buildSupplierIdToNameMap,
|
|
buildSupplierLookup,
|
|
parseStockImportWorkbook,
|
|
type ParsedImportRow,
|
|
type StockExportRow,
|
|
} from "./stock-items-service";
|
|
import type { StockItem, Supplier } from "@/lib/types";
|
|
|
|
function row(rn: number, partial: Partial<ParsedImportRow>): ParsedImportRow {
|
|
return { __rowNumber: rn, ...partial };
|
|
}
|
|
|
|
function stockItem(partial: Partial<StockItem>): StockItem {
|
|
return {
|
|
id: "",
|
|
created: "2026-09-01T00:00:00Z",
|
|
updated: "2026-09-02T00:00:00Z",
|
|
collectionId: "pbc_xxx",
|
|
collectionName: "stock_items",
|
|
sku: "",
|
|
name: "",
|
|
description: "",
|
|
unit: "",
|
|
location: "",
|
|
current_qty: 0,
|
|
min_qty: 0,
|
|
target_qty: 0,
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
supplier: "",
|
|
is_active: true,
|
|
notes: "",
|
|
...partial,
|
|
} as StockItem;
|
|
}
|
|
|
|
function supplier(id: string, name: string): Supplier {
|
|
return { id, suppliername: name } as Supplier;
|
|
}
|
|
|
|
const SUPPLIER_LOOKUP = buildSupplierLookup([
|
|
supplier("sup-1", "ACME"),
|
|
supplier("sup-2", "Bosch"),
|
|
]);
|
|
|
|
describe("buildSupplierLookup", () => {
|
|
it("maps lower-cased trimmed names to ids", () => {
|
|
expect(SUPPLIER_LOOKUP.get("acme")).toBe("sup-1");
|
|
expect(SUPPLIER_LOOKUP.get("bosch")).toBe("sup-2");
|
|
expect(SUPPLIER_LOOKUP.get("nobody")).toBeUndefined();
|
|
});
|
|
|
|
it("buildBulkImportPlan resolves supplier names with surrounding whitespace", () => {
|
|
// End-to-end check: an imported row with " ACME " should
|
|
// resolve to the same supplier as "ACME". The planner trims
|
|
// before lookup, so this works in practice even though the
|
|
// raw map only stores the trimmed key.
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { sku: "NEW-1", name: "Widget", supplier: " ACME " })],
|
|
existingById: new Map(),
|
|
existingBySku: new Map(),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.create).toBe(1);
|
|
});
|
|
|
|
it("buildSupplierIdToNameMap reverses the lookup for diff comparisons", () => {
|
|
const idToName = buildSupplierIdToNameMap([
|
|
supplier("sup-1", "ACME"),
|
|
supplier("sup-2", "Bosch"),
|
|
]);
|
|
expect(idToName.get("sup-1")).toBe("ACME");
|
|
expect(idToName.get("sup-2")).toBe("Bosch");
|
|
expect(idToName.get("unknown")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("parseStockImportWorkbook", () => {
|
|
it("round-trips an exported workbook and produces the same row count", () => {
|
|
const bytes = buildStockExportWorkbook({
|
|
rows: [
|
|
{
|
|
id: "si-1",
|
|
sku: "WID-1",
|
|
name: "Widget",
|
|
description: "",
|
|
unit: "ea",
|
|
supplier: "ACME",
|
|
current_qty: 10,
|
|
min_qty: 2,
|
|
target_qty: 20,
|
|
cost_price: 100,
|
|
markup: 30,
|
|
selling_price: 130,
|
|
location: "A1",
|
|
notes: "",
|
|
is_active: true,
|
|
} as StockExportRow,
|
|
],
|
|
generatedAt: "2026-09-02T12:00:00.000Z",
|
|
supplierNames: ["ACME"],
|
|
});
|
|
const parsed = parseStockImportWorkbook(bytes);
|
|
expect(parsed.headers).toContain("id");
|
|
expect(parsed.headers).toContain("name");
|
|
expect(parsed.rows).toHaveLength(1);
|
|
expect(parsed.rows[0]?.sku).toBe("WID-1");
|
|
expect(parsed.rows[0]?.supplier).toBe("ACME");
|
|
// Number cells come back as strings — the planner coerces them.
|
|
expect(parsed.rows[0]?.current_qty).toBe("10");
|
|
});
|
|
|
|
it("throws ImportParseError when the sheet has no data rows", () => {
|
|
const wb = XLSX.utils.book_new();
|
|
const sheet = XLSX.utils.aoa_to_sheet([["id", "name", "sku"]]);
|
|
XLSX.utils.book_append_sheet(wb, sheet, "Stock");
|
|
const bytes = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
|
|
expect(() => parseStockImportWorkbook(bytes)).toThrow(/no data rows/);
|
|
});
|
|
});
|
|
|
|
describe("buildBulkImportPlan", () => {
|
|
it("classifies unchanged rows as noop", () => {
|
|
const existing = stockItem({ id: "si-1", sku: "WID-1", name: "Widget", current_qty: 10 });
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { id: "si-1", sku: "WID-1", name: "Widget", current_qty: "10" })],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.noop).toBe(1);
|
|
expect(plan.plans[0]?.kind).toBe("noop");
|
|
});
|
|
|
|
it("classifies is_active=false on an existing row as soft_delete", () => {
|
|
const existing = stockItem({ id: "si-1", sku: "WID-1", name: "Widget" });
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { id: "si-1", sku: "WID-1", name: "Widget", is_active: "FALSE" })],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.softDelete).toBe(1);
|
|
expect(plan.plans[0]?.kind).toBe("soft_delete");
|
|
});
|
|
|
|
it("classifies a qty change as update with the delta", () => {
|
|
const existing = stockItem({ id: "si-1", sku: "WID-1", name: "Widget", current_qty: 10 });
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { id: "si-1", sku: "WID-1", name: "Widget", current_qty: "12" })],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.update).toBe(1);
|
|
const p = plan.plans[0];
|
|
expect(p?.kind).toBe("update");
|
|
if (p?.kind === "update") {
|
|
expect(p.qtyDelta).toBe(2);
|
|
expect(p.costChanged).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("classifies a cost change as update with costChanged=true", () => {
|
|
const existing = stockItem({ id: "si-1", sku: "WID-1", name: "Widget", cost_price: 100 });
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { id: "si-1", sku: "WID-1", name: "Widget", cost_price: "110" })],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.update).toBe(1);
|
|
const p = plan.plans[0];
|
|
if (p?.kind === "update") {
|
|
expect(p.costChanged).toBe(true);
|
|
expect(p.qtyDelta).toBe(0);
|
|
} else {
|
|
throw new Error(`expected update, got ${p?.kind}`);
|
|
}
|
|
});
|
|
|
|
it("classifies a new row with a known supplier as create", () => {
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { sku: "NEW-1", name: "New Widget", supplier: "ACME" })],
|
|
existingById: new Map(),
|
|
existingBySku: new Map(),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.create).toBe(1);
|
|
expect(plan.plans[0]?.kind).toBe("create");
|
|
});
|
|
|
|
it("classifies a new row with an unknown supplier as error", () => {
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { sku: "NEW-1", name: "New Widget", supplier: "UnknownCo" })],
|
|
existingById: new Map(),
|
|
existingBySku: new Map(),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.error).toBe(1);
|
|
expect(plan.plans[0]?.kind).toBe("error");
|
|
});
|
|
|
|
it("classifies a new row with no sku/name as error", () => {
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { name: "" })],
|
|
existingById: new Map(),
|
|
existingBySku: new Map(),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.error).toBe(1);
|
|
});
|
|
|
|
it("falls back to sku match when the row has no id", () => {
|
|
const existing = stockItem({ id: "si-1", sku: "WID-1", name: "Widget", current_qty: 5 });
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, { sku: "WID-1", name: "Widget", current_qty: "6" })],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
});
|
|
expect(plan.summary.update).toBe(1);
|
|
const p = plan.plans[0];
|
|
if (p?.kind === "update") {
|
|
expect(p.stockItemId).toBe("si-1");
|
|
expect(p.qtyDelta).toBe(1);
|
|
} else {
|
|
throw new Error(`expected update, got ${p?.kind}`);
|
|
}
|
|
});
|
|
|
|
it("REGRESSION: a row whose supplier matches the existing supplier is a noop (not a phantom 'supplier changed' for every row)", () => {
|
|
// This is the exact bug we hit on 2026-09-02: the existing
|
|
// row's `supplier` field is a 15-char PB relation id, but
|
|
// the imported row's `supplier` is a name. Without the
|
|
// idToName map, every row's diff shows
|
|
// "supplier: <15charid> → <name>"
|
|
// even when the supplier is exactly the same.
|
|
//
|
|
// The fix: compare name-vs-name, not id-vs-name. Pass the
|
|
// supplierIdToName map to the planner and verify a no-change
|
|
// import shows zero updates.
|
|
const idToName = buildSupplierIdToNameMap([supplier("sup-1", "ACME")]);
|
|
const existing = stockItem({
|
|
id: "si-1",
|
|
sku: "WID-1",
|
|
name: "Widget",
|
|
supplier: "sup-1", // PB relation id
|
|
});
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, {
|
|
id: "si-1",
|
|
sku: "WID-1",
|
|
name: "Widget",
|
|
supplier: "ACME", // imported name
|
|
})],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
supplierIdToName: idToName,
|
|
});
|
|
expect(plan.summary.noop).toBe(1);
|
|
expect(plan.summary.update).toBe(0);
|
|
});
|
|
|
|
it("REGRESSION: a genuine supplier change (different name) is still detected as an update", () => {
|
|
const idToName = buildSupplierIdToNameMap([
|
|
supplier("sup-1", "ACME"),
|
|
supplier("sup-2", "Bosch"),
|
|
]);
|
|
const existing = stockItem({
|
|
id: "si-1",
|
|
sku: "WID-1",
|
|
name: "Widget",
|
|
supplier: "sup-1",
|
|
});
|
|
const plan = buildBulkImportPlan({
|
|
rows: [row(1, {
|
|
id: "si-1",
|
|
sku: "WID-1",
|
|
name: "Widget",
|
|
supplier: "Bosch",
|
|
})],
|
|
existingById: new Map([["si-1", existing]]),
|
|
existingBySku: new Map([["WID-1", existing]]),
|
|
supplierLookup: SUPPLIER_LOOKUP,
|
|
supplierIdToName: idToName,
|
|
});
|
|
expect(plan.summary.update).toBe(1);
|
|
const p = plan.plans[0];
|
|
if (p?.kind === "update") {
|
|
const supplierChange = p.changes.find((c) => c.field === "supplier");
|
|
expect(supplierChange?.before).toBe("ACME");
|
|
expect(supplierChange?.after).toBe("Bosch");
|
|
} else {
|
|
throw new Error(`expected update, got ${p?.kind}`);
|
|
}
|
|
});
|
|
});
|