WFDASH-WEBAPP/src/lib/server/stock-quick-draft-service.test.ts
2026-07-30 23:11:27 +02:00

747 lines
24 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
const { store, pbMock, clearStore } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
// Split on &&, then for each conjunct split on ||. Parens are
// grouping-only and can be stripped — none of the filters used
// here have nested groups mixing ANDs and ORs.
const conjuncts = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
conjuncts.every((conjunct) => {
const disjuncts = conjunct.split("||").map((d) => d.trim());
return disjuncts.some((d) => {
const clause = d.replace(/^\(/, "").replace(/\)$/, "").trim();
const m = clause.match(/^(\w+)\s*(=|!=|~|>=?|<=?)\s*(.+)$/);
if (!m) return true;
const [, field, op, rawValue] = m;
let value: string | number | boolean;
if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
value = rawValue.slice(1, -1);
} else if (rawValue === "true") {
value = true;
} else if (rawValue === "false") {
value = false;
} else {
value = Number(rawValue);
}
const actual = item[field];
if (op === "=") return actual == value;
if (op === "!=") return actual != value;
if (op === "~") {
return (
typeof actual === "string" &&
typeof value === "string" &&
actual.toLowerCase().includes(value.toLowerCase())
);
}
if (op === ">") return Number(actual) > Number(value);
if (op === "<") return Number(actual) < Number(value);
return true;
});
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
_page = 1,
perPage = 25,
opts: { filter?: string; sort?: string; expand?: string } = {},
) {
let items = where(name, opts.filter || "");
if (opts.sort) {
const desc = opts.sort.startsWith("-");
const field = desc ? opts.sort.slice(1) : opts.sort;
items = [...items].sort((a, b) => {
const av = a[field] as number | string | undefined;
const bv = b[field] as number | string | undefined;
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
const cmp = av < bv ? -1 : 1;
return desc ? -cmp : cmp;
});
}
const sliced = items.slice(0, perPage);
if (opts.expand) {
const expandFields = opts.expand.split(",").map((f) => f.trim());
for (const item of sliced) {
const expandObj: Record<string, unknown> = {};
for (const field of expandFields) {
const relatedId = item[field];
if (relatedId === undefined || relatedId === null) continue;
for (const collName of Object.keys(store)) {
const related = store[collName].get(String(relatedId));
if (related) {
expandObj[field] = related;
break;
}
}
}
item.expand = expandObj;
}
}
return { items: sliced, totalItems: items.length, page: 1, perPage };
},
async getFullList(opts: { filter?: string } = {}) {
return where(name, opts.filter || "");
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
existing[key] = value;
}
existing.updated = new Date().toISOString();
return { ...existing };
},
async delete(id: string) {
const map = getOrCreateMap(name);
if (!map.has(id)) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
map.delete(id);
return { id };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
addQuickDraftLine,
getStockItemSupplierHistory,
listOpenDrafts,
removeQuickDraftLine,
searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";
beforeEach(() => clearStore());
describe("searchStockItemsForQuickDraft", () => {
it("returns active stock_items whose sku or name matches the query", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-WR-6",
name: "Wire rope 6mm",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-CD-230",
name: "230mm Cutting Disc",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toHaveLength(2);
expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]);
});
it("excludes inactive items", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: false,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toEqual([]);
});
it("clamps limit to a max of 25", async () => {
for (let i = 0; i < 30; i++) {
await pbMock.collection("stock_items").create({
sku: `STK-X-${i}`,
name: `Cutting disc variant ${i}`,
is_active: true,
});
}
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100);
expect(results).toHaveLength(25);
});
it("returns narrow projection fields", async () => {
const created = await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results[0]).toEqual({
id: created.id,
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
});
});
});
describe("getStockItemSupplierHistory", () => {
it("returns empty when the item has no purchase history", async () => {
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
it("aggregates by supplier: lastCost, lastDate, timesBought", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const purchase1 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchase2 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26071401STK",
created: "2026-07-14T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase1.id,
stock_item: "item-1",
cost_price: 18.5,
quantity: 10,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase2.id,
stock_item: "item-1",
cost_price: 19.0,
quantity: 20,
quantity_received: 20,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(1);
expect(history[0]).toEqual({
supplierId,
supplierName: "Steel & Pipe Co",
lastCost: 19.0,
lastDate: "2026-07-14T08:00:00.000Z",
timesBought: 2,
});
});
it("returns one entry per supplier, sorted by lastDate desc", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
const supB = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier B" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchaseA = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchaseB = await pbMock.collection("stock_purchases").create({
supplier: supB,
status: "received",
purchase_number: "PO26071501STK",
created: "2026-07-15T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseA.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseB.id,
stock_item: "item-1",
cost_price: 21.0,
quantity_received: 5,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(2);
expect(history[0].supplierId).toBe(supB);
expect(history[1].supplierId).toBe(supA);
});
it("only counts lines with quantity_received > 0", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "draft",
purchase_number: "PO26070101STK",
created: "2026-07-01T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 0,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
});
describe("addQuickDraftLine", () => {
it("creates a new draft PO when none exists for the supplier", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 50,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(true);
expect(result.purchaseNumber).toMatch(/^PO\d{6}\d{2}STK$/);
const purchases = await pbMock.collection("stock_purchases").getFullList();
expect(purchases).toHaveLength(1);
expect(purchases[0].supplier).toBe(supplierId);
expect(purchases[0].status).toBe("draft");
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe("item-1");
expect(lines[0].quantity).toBe(50);
expect(lines[0].cost_price).toBe(18.5);
expect(lines[0].description).toBe("115mm Cutting Disc");
});
it("appends to an existing open draft for the supplier", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
await pbMock.collection("stock_items").create({
id: "item-2",
sku: "STK-WR-6",
name: "Wire rope 6mm",
});
// Existing draft for this supplier.
await pbMock.collection("stock_purchases").create({
id: "purchase-existing",
supplier: supplierId,
status: "draft",
purchase_number: "PO26070101STK",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-2",
quantity: 20,
costPrice: 145,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(false);
expect(result.purchaseId).toBe("purchase-existing");
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe("item-2");
});
it("ignores drafts that are not in 'draft' status when finding an open one", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
// Sent/received drafts must NOT be appended to — should create a new one.
await pbMock.collection("stock_purchases").create({
id: "purchase-sent",
supplier: supplierId,
status: "sent",
purchase_number: "PO26070101STK",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 50,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(true);
expect(result.purchaseId).not.toBe("purchase-sent");
const purchases = await pbMock.collection("stock_purchases").getFullList();
expect(purchases).toHaveLength(2);
});
it("creates a new stock_item when newStockItem is supplied, then adds the line", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 10,
costPrice: 5.5,
newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const items = await pbMock.collection("stock_items").getFullList();
expect(items).toHaveLength(1);
expect(items[0].name).toBe("Brand-new Widget");
expect(items[0].sku).toBe("STK-BNW-1");
expect(items[0].is_active).toBe(true);
expect(items[0].current_qty).toBe(0);
expect(items[0].supplier).toBe(supplierId);
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe(items[0].id);
expect(lines[0].description).toBe("Brand-new Widget");
});
it("rejects non-positive quantities", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 0,
costPrice: 10,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("rejects negative cost", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 1,
costPrice: -5,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("rejects when both stockItemId and newStockItem are missing", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("returns supplier_not_found when the supplier id doesn't exist", async () => {
const result = await addQuickDraftLine(pbMock as never, {
supplierId: "does-not-exist",
stockItemId: "item-1",
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("supplier_not_found");
});
it("returns stock_item_not_found when the stock_item id doesn't exist", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "does-not-exist",
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("stock_item_not_found");
});
});
describe("removeQuickDraftLine", () => {
it("deletes the line and returns ok", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "draft",
purchase_number: "PO26070101STK",
});
const line = await pbMock.collection("stock_purchase_items").create({
purchase: purchase.id,
stock_item: "item-1",
description: "115mm Cutting Disc",
quantity: 10,
});
const result = await removeQuickDraftLine(pbMock as never, line.id);
expect(result).toEqual({ ok: true });
const remaining = await pbMock.collection("stock_purchase_items").getFullList();
expect(remaining).toEqual([]);
});
it("returns line_not_found when the line id doesn't exist", async () => {
const result = await removeQuickDraftLine(pbMock as never, "does-not-exist");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("line_not_found");
});
});
describe("listOpenDrafts", () => {
it("returns an empty array when there are no draft stock_purchases", async () => {
const drafts = await listOpenDrafts(pbMock as never);
expect(drafts).toEqual([]);
});
it("returns draft purchases with supplier name + lines denormalised, sorted by created desc", async () => {
const supplierA = (
await pbMock.collection("suppliers").create({ name: "Steel & Pipe Co" })
).id;
const supplierB = (
await pbMock.collection("suppliers").create({ name: "Hardware Hub" })
).id;
// First (older) draft.
const purchaseA = await pbMock.collection("stock_purchases").create({
supplier: supplierA,
status: "draft",
purchase_number: "PO26072801STK",
created: "2026-07-28T08:00:00.000Z",
});
// Second (newer) draft.
const purchaseB = await pbMock.collection("stock_purchases").create({
supplier: supplierB,
status: "draft",
purchase_number: "PO26073001STK",
created: "2026-07-30T09:00:00.000Z",
});
// Two lines on purchaseA, one line on purchaseB.
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseA.id,
description: "115mm Cutting Disc",
quantity: 50,
cost_price: 18.5,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseA.id,
description: "Wire rope 6mm",
quantity: 20,
cost_price: 145,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseB.id,
description: "Angle grinder",
quantity: 1,
cost_price: 1500,
});
const drafts = await listOpenDrafts(pbMock as never);
// Newest first.
expect(drafts).toHaveLength(2);
expect(drafts[0]).toMatchObject({
id: purchaseB.id,
purchase_number: "PO26073001STK",
supplier: supplierB,
supplier_name: "Hardware Hub",
created: "2026-07-30T09:00:00.000Z",
});
expect(drafts[0].lines).toHaveLength(1);
expect(drafts[0].lines[0]).toMatchObject({
description: "Angle grinder",
quantity: 1,
cost_price: 1500,
});
expect(drafts[1]).toMatchObject({
id: purchaseA.id,
purchase_number: "PO26072801STK",
supplier: supplierA,
supplier_name: "Steel & Pipe Co",
created: "2026-07-28T08:00:00.000Z",
});
expect(drafts[1].lines).toHaveLength(2);
expect(
drafts[1].lines.map((l) => l.description).sort(),
).toEqual(["115mm Cutting Disc", "Wire rope 6mm"]);
});
it("ignores non-draft stock_purchases in the result", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ name: "S" })
).id;
// Sent and received rows must NOT appear in the drafts list.
await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "sent",
purchase_number: "PO26072801STK",
created: "2026-07-28T08:00:00.000Z",
});
await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26072901STK",
created: "2026-07-29T08:00:00.000Z",
});
const drafts = await listOpenDrafts(pbMock as never);
expect(drafts).toEqual([]);
});
it("returns an empty lines array for a draft with no child items", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ name: "Hardware Hub" })
).id;
// A brand-new draft with no lines yet (possible if the row was
// created by the agent, not by the modal — or after a mass delete).
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "draft",
purchase_number: "PO26073001STK",
created: "2026-07-30T09:00:00.000Z",
});
const drafts = await listOpenDrafts(pbMock as never);
expect(drafts).toHaveLength(1);
expect(drafts[0].id).toBe(purchase.id);
expect(drafts[0].supplier_name).toBe("Hardware Hub");
expect(drafts[0].lines).toEqual([]);
});
});