1514 lines
52 KiB
TypeScript
1514 lines
52 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
|
||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||
getAdminPocketBase: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@/lib/server/orders-service", () => ({
|
||
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
|
||
}));
|
||
|
||
vi.mock("@/lib/server/stock-items-service", () => ({
|
||
createStockItem: vi.fn(),
|
||
}));
|
||
|
||
import { createStockItem } from "@/lib/server/stock-items-service";
|
||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
|
||
import {
|
||
confirmStockRows,
|
||
listCollectedItems,
|
||
updateOrderItemCost, confirmOrderItemsForInvoicing,
|
||
clearCollectedRows,
|
||
addRowsToPos,
|
||
} from "./collection-confirm-service";
|
||
|
||
/**
|
||
* Mock admin client factory.
|
||
*
|
||
* Mirrors the page's two-leg union query: one `getFullList` call against
|
||
* `order_items` (with the `collected` filter) and one against
|
||
* `stock_purchase_items` (with `status = "collected"`). The mock lets
|
||
* each test supply the items each leg should return.
|
||
*/
|
||
const makeAdmin = (opts: {
|
||
orderItems?: Record<string, unknown>[];
|
||
stockItems?: Record<string, unknown>[];
|
||
}) => {
|
||
const orderItemsGetFullList = vi
|
||
.fn()
|
||
.mockResolvedValue(opts.orderItems ?? []);
|
||
const stockItemsGetFullList = vi
|
||
.fn()
|
||
.mockResolvedValue(opts.stockItems ?? []);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") {
|
||
return { getFullList: orderItemsGetFullList };
|
||
}
|
||
if (name === "stock_purchase_items") {
|
||
return { getFullList: stockItemsGetFullList };
|
||
}
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
};
|
||
return { admin, orderItemsGetFullList, stockItemsGetFullList };
|
||
};
|
||
|
||
describe("listCollectedItems", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it("uses getOrderItemsCollectionName to resolve the order-items collection", async () => {
|
||
const { admin } = makeAdmin({});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await listCollectedItems();
|
||
|
||
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
|
||
expect(admin.collection).toHaveBeenCalledWith("order_items_v2");
|
||
});
|
||
|
||
it("returns order_items rows expanded with purchase_order, supplier, order, company, customer", async () => {
|
||
const orderItem = {
|
||
id: "oi-1",
|
||
item_status: "collected",
|
||
description: "M20 × 40 hex nuts",
|
||
order_qty: 100,
|
||
qty_collected: 100,
|
||
quantity: 100,
|
||
cost_price: 8,
|
||
order_item_attachements: ["invoice.pdf"],
|
||
expand: {
|
||
purchase_order: {
|
||
id: "po-1",
|
||
po_number: "PO-1",
|
||
expand: { supplier: { id: "s-1", suppliername: "Acme" } },
|
||
},
|
||
order: {
|
||
id: "o-1",
|
||
order_number: "ORD-1",
|
||
expand: {
|
||
company: { id: "co-1", company: "Acme Co" },
|
||
customer: { id: "cu-1", customer: "J. Smith" },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
const { admin, orderItemsGetFullList, stockItemsGetFullList } = makeAdmin({
|
||
orderItems: [orderItem],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toHaveLength(1);
|
||
const row = rows[0]!;
|
||
expect(row.id).toBe("oi-1");
|
||
expect(row.source).toBe("customer");
|
||
expect(row.description).toBe("M20 × 40 hex nuts");
|
||
expect(row.qty_collected).toBe(100);
|
||
expect(row.order_qty).toBe(100);
|
||
expect(row.item_status).toBe("collected");
|
||
expect(row.expand?.purchase_order?.po_number).toBe("PO-1");
|
||
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe(
|
||
"Acme",
|
||
);
|
||
expect(row.expand?.order?.order_number).toBe("ORD-1");
|
||
expect(row.expand?.order?.expand?.customer?.customer).toBe("J. Smith");
|
||
expect(row.expand?.order?.expand?.company?.company).toBe("Acme Co");
|
||
|
||
// Order-items leg must use the `collected` filter and request the
|
||
// expansion the page relies on (PO supplier + order customer/company).
|
||
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
|
||
const orderOpts = orderItemsGetFullList.mock.calls[0]?.[0] as
|
||
{ filter?: string; expand?: string } | undefined;
|
||
expect(orderOpts?.filter).toBe('item_status = "collected"');
|
||
expect(orderOpts?.expand).toBe(
|
||
"purchase_order.supplier,order.company,order.customer",
|
||
);
|
||
// Stock-leg is also called (the page runs both legs in parallel);
|
||
// it just returned an empty list for this test, so its items
|
||
// contribute nothing to the merged output.
|
||
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("returns stock_purchase_items rows normalised to ConfirmRow with source=stock", async () => {
|
||
const stockItem = {
|
||
id: "spi-1",
|
||
description: "Stock cable ties",
|
||
stock_item: "st-1",
|
||
quantity: 50,
|
||
quantity_received: 50,
|
||
cost_price: 1.5,
|
||
status: "collected",
|
||
assigned_at: "2026-08-01T10:00:00.000Z",
|
||
expand: {
|
||
purchase: {
|
||
id: "sp-1",
|
||
purchase_number: "SP-001",
|
||
expand: { supplier: { id: "s-2", suppliername: "CableCo" } },
|
||
},
|
||
stock_item: {
|
||
id: "st-1",
|
||
sku: "CT-200",
|
||
name: "Cable tie 200mm",
|
||
description: "Black UV-stable cable tie",
|
||
},
|
||
},
|
||
};
|
||
const { admin, orderItemsGetFullList, stockItemsGetFullList } = makeAdmin({
|
||
stockItems: [stockItem],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toHaveLength(1);
|
||
const row = rows[0]!;
|
||
expect(row.id).toBe("spi-1");
|
||
expect(row.source).toBe("stock");
|
||
expect(row.description).toBe("Stock cable ties");
|
||
expect(row.qty_collected).toBe(50);
|
||
expect(row.order_qty).toBe(50);
|
||
expect(row.quantity).toBe(50);
|
||
expect(row.item_status).toBe("collected");
|
||
expect(row.stock_line_status).toBe("collected");
|
||
expect(row.assigned_at).toBe("2026-08-01T10:00:00.000Z");
|
||
expect(row.stock_purchase_id).toBe("sp-1");
|
||
expect(row.poNumber).toBe("SP-001");
|
||
expect(row.sku).toBe("CT-200");
|
||
expect(row.supplier).toBe("s-2");
|
||
// The purchase data is mapped onto the shared `expand.purchase_order`
|
||
// shape so the page's supplier link works without a branch.
|
||
expect(row.expand?.purchase_order?.id).toBe("sp-1");
|
||
expect(row.expand?.purchase_order?.po_number).toBe("SP-001");
|
||
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe(
|
||
"CableCo",
|
||
);
|
||
// Stock rows have no customer side.
|
||
expect(row.expand?.order).toBeUndefined();
|
||
|
||
// Stock leg filter excludes lines the warehouse has already
|
||
// fully received (`quantity_received >= quantity`). The
|
||
// confirm-stock endpoint and the qty pencil both land the line
|
||
// at full received; the row should drop off the list either
|
||
// way. The status field stays at "collected" — only the
|
||
// accountant's booking flow flips it to "booked".
|
||
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
|
||
const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as
|
||
{ filter?: string; expand?: string } | undefined;
|
||
expect(stockOpts?.filter).toBe(
|
||
'status = "collected" && quantity_received < quantity',
|
||
);
|
||
expect(stockOpts?.expand).toBe(
|
||
"purchase.supplier,purchase.stock_item,purchase.additional_attachments",
|
||
);
|
||
// Order-items leg must NOT be called when we only returned stock rows.
|
||
// (In the real code it IS called — but it returned empty.)
|
||
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
|
||
expect(orderItemsGetFullList.mock.calls[0]?.[0]).toMatchObject({
|
||
filter: 'item_status = "collected"',
|
||
});
|
||
});
|
||
|
||
it("returns an empty array when both legs return zero rows", async () => {
|
||
const { admin } = makeAdmin({ orderItems: [], stockItems: [] });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toEqual([]);
|
||
});
|
||
|
||
it("merges order_items and stock_purchase_items into one ConfirmRow[]", async () => {
|
||
const orderItem = {
|
||
id: "oi-1",
|
||
item_status: "collected",
|
||
description: "M20 × 40 hex nuts",
|
||
order_qty: 100,
|
||
qty_collected: 100,
|
||
expand: {
|
||
purchase_order: { id: "po-1", po_number: "PO-1" },
|
||
order: { id: "o-1" },
|
||
},
|
||
};
|
||
const stockItem = {
|
||
id: "spi-1",
|
||
description: "Cable ties",
|
||
quantity: 50,
|
||
quantity_received: 50,
|
||
status: "collected",
|
||
expand: {
|
||
purchase: { id: "sp-1", purchase_number: "SP-001" },
|
||
},
|
||
};
|
||
const { admin } = makeAdmin({
|
||
orderItems: [orderItem],
|
||
stockItems: [stockItem],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toHaveLength(2);
|
||
const sources = rows.map((r) => r.source).sort();
|
||
expect(sources).toEqual(["customer", "stock"]);
|
||
});
|
||
|
||
it("returns order_items rows when stock_purchase_items leg throws", async () => {
|
||
// Mirrors the page's `load()` fail-soft behaviour at page.tsx:294:
|
||
// the stock leg is wrapped in `.catch(() => [])` so a stock-side
|
||
// outage doesn't kill the customer-leg results.
|
||
const orderItem = {
|
||
id: "oi-1",
|
||
item_status: "collected",
|
||
description: "M20 × 40 hex nuts",
|
||
order_qty: 100,
|
||
qty_collected: 100,
|
||
expand: {
|
||
purchase_order: { id: "po-1", po_number: "PO-1" },
|
||
order: { id: "o-1" },
|
||
},
|
||
};
|
||
const orderItemsGetFullList = vi.fn().mockResolvedValue([orderItem]);
|
||
const stockItemsGetFullList = vi
|
||
.fn()
|
||
.mockRejectedValue(new Error("stock side down"));
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") {
|
||
return { getFullList: orderItemsGetFullList };
|
||
}
|
||
if (name === "stock_purchase_items") {
|
||
return { getFullList: stockItemsGetFullList };
|
||
}
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
};
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toHaveLength(1);
|
||
const row = rows[0]!;
|
||
expect(row.id).toBe("oi-1");
|
||
expect(row.source).toBe("customer");
|
||
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe("updateOrderItemCost", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Build an admin mock that exposes `getOne` and `update` for the
|
||
* order-items collection only. `getOne` returns the row that the
|
||
* service reads to capture the previous cost; `update` returns the
|
||
* fresh row that the service writes back.
|
||
*
|
||
* The default `update` resolution is the existing row overlaid with
|
||
* the patch the service passed (so `cost_price` and `notes` are
|
||
* visible on the returned object even if the test doesn't supply a
|
||
* custom `updated` row).
|
||
*/
|
||
const makeAdminForUpdate = (opts: {
|
||
existing: Record<string, unknown>;
|
||
updated?: Record<string, unknown>;
|
||
}) => {
|
||
const getOne = vi.fn().mockResolvedValue(opts.existing);
|
||
const update = vi
|
||
.fn()
|
||
.mockImplementation((_id: string, patch: Record<string, unknown>) =>
|
||
Promise.resolve({
|
||
...opts.existing,
|
||
...(opts.updated ?? {}),
|
||
...patch,
|
||
}),
|
||
);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") {
|
||
return { getOne, update };
|
||
}
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
};
|
||
return { admin, getOne, update };
|
||
};
|
||
|
||
it("writes cost_price and appends a category='cost' note when the price changes", async () => {
|
||
const existing = { id: "row-1", cost_price: 45, notes: null };
|
||
const { admin, getOne, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await updateOrderItemCost("row-1", {
|
||
cost_price: 50,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
// Read-then-write through the resolved collection name.
|
||
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
|
||
expect(getOne).toHaveBeenCalledTimes(1);
|
||
expect(getOne).toHaveBeenCalledWith("row-1");
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const [updatedId, patch] = update.mock.calls[0] as [
|
||
string,
|
||
{ cost_price: number; notes: string },
|
||
];
|
||
expect(updatedId).toBe("row-1");
|
||
expect(patch.cost_price).toBe(50);
|
||
expect(typeof patch.notes).toBe("string");
|
||
|
||
// The new notes feed is a JSON array containing a single cost entry.
|
||
const parsed = JSON.parse(patch.notes);
|
||
expect(Array.isArray(parsed)).toBe(true);
|
||
expect(parsed).toHaveLength(1);
|
||
const entry = parsed[0];
|
||
expect(entry.category).toBe("cost");
|
||
expect(entry.actorId).toBe("u-1");
|
||
expect(entry.actorName).toBe("sj");
|
||
expect(entry.body).toEqual({ prevCost: 45, newCost: 50 });
|
||
expect(typeof entry.ts).toBe("string");
|
||
|
||
// Returned row carries the new cost price (mock resolution merges
|
||
// the patch into the existing row).
|
||
expect(result.id).toBe("row-1");
|
||
expect(result.cost_price).toBe(50);
|
||
});
|
||
|
||
it("is a no-op when the new cost equals the current cost (no write, no note)", async () => {
|
||
const existing = { id: "row-1", cost_price: 45, notes: null };
|
||
const { admin, getOne, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await updateOrderItemCost("row-1", {
|
||
cost_price: 45,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
// The read still happens (we need prevCost to make the no-op
|
||
// decision), but the write is skipped entirely.
|
||
expect(getOne).toHaveBeenCalledTimes(1);
|
||
expect(update).not.toHaveBeenCalled();
|
||
// The unchanged existing row is returned as-is.
|
||
expect(result).toEqual(existing);
|
||
});
|
||
|
||
it("preserves existing notes when appending the cost entry", async () => {
|
||
const priorEntry = {
|
||
id: "note-1",
|
||
ts: "2026-08-01T00:00:00.000Z",
|
||
actorId: "u-0",
|
||
actorName: "previous-actor",
|
||
category: "note",
|
||
body: "A prior note",
|
||
};
|
||
const existing = {
|
||
id: "row-1",
|
||
cost_price: 45,
|
||
notes: JSON.stringify([priorEntry]),
|
||
};
|
||
const { admin, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await updateOrderItemCost("row-1", {
|
||
cost_price: 50,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const patch = update.mock.calls[0]?.[1] as {
|
||
cost_price: number;
|
||
notes: string;
|
||
};
|
||
const parsed = JSON.parse(patch.notes);
|
||
expect(Array.isArray(parsed)).toBe(true);
|
||
expect(parsed).toHaveLength(2);
|
||
// The prior entry is preserved in its original form (head of the
|
||
// array, since `appendCostNote` appends at the tail).
|
||
expect(parsed[0]).toEqual(priorEntry);
|
||
// The new cost entry is appended at the tail with the correct
|
||
// prev/new cost values.
|
||
expect(parsed[1].category).toBe("cost");
|
||
expect(parsed[1].actorId).toBe("u-1");
|
||
expect(parsed[1].actorName).toBe("sj");
|
||
expect(parsed[1].body).toEqual({ prevCost: 45, newCost: 50 });
|
||
});
|
||
|
||
it("propagates errors from pb.collection.getOne", async () => {
|
||
const failingGetOne = vi.fn().mockRejectedValue(new Error("not found"));
|
||
const dummyUpdate = vi.fn();
|
||
const admin = {
|
||
collection: vi.fn().mockReturnValue({
|
||
getOne: failingGetOne,
|
||
update: dummyUpdate,
|
||
}),
|
||
};
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
updateOrderItemCost("row-1", {
|
||
cost_price: 50,
|
||
actor: { id: "u-1", name: "sj" },
|
||
}),
|
||
).rejects.toThrow("not found");
|
||
|
||
expect(failingGetOne).toHaveBeenCalledWith("row-1");
|
||
expect(dummyUpdate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("writes cost_price and appends a cost note when going from null to 0", async () => {
|
||
// Regression: the lossy fallback `prevCost ?? 0` collapsed a true
|
||
// `null → 0` transition into `0 → 0` inside appendCostNote, which
|
||
// short-circuited and silently dropped the audit note. The fix
|
||
// threads the original `null` through; this test pins that
|
||
// behaviour.
|
||
const existing = { id: "row-1", cost_price: null, notes: null };
|
||
const { admin, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await updateOrderItemCost("row-1", {
|
||
cost_price: 0,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const [, patch] = update.mock.calls[0] as [
|
||
string,
|
||
{ cost_price: number; notes: string },
|
||
];
|
||
expect(patch.cost_price).toBe(0);
|
||
const parsed = JSON.parse(patch.notes) as Array<Record<string, unknown>>;
|
||
expect(parsed).toHaveLength(1);
|
||
expect(parsed[0]).toMatchObject({
|
||
category: "cost",
|
||
actorId: "u-1",
|
||
actorName: "sj",
|
||
body: { prevCost: null, newCost: 0 },
|
||
});
|
||
|
||
expect(result.cost_price).toBe(0);
|
||
});
|
||
|
||
it("writes cost_price and appends a cost note when going from null to a positive number", async () => {
|
||
const existing = { id: "row-1", cost_price: null, notes: null };
|
||
const { admin, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await updateOrderItemCost("row-1", {
|
||
cost_price: 50,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
const [, patch] = update.mock.calls[0] as [
|
||
string,
|
||
{ cost_price: number; notes: string },
|
||
];
|
||
expect(patch.cost_price).toBe(50);
|
||
const parsed = JSON.parse(patch.notes) as Array<Record<string, unknown>>;
|
||
expect(parsed).toHaveLength(1);
|
||
expect(parsed[0]).toMatchObject({
|
||
category: "cost",
|
||
body: { prevCost: null, newCost: 50 },
|
||
});
|
||
});
|
||
|
||
it("throws CollectionConfirmValidationError when cost_price is not a number", async () => {
|
||
// Defence-in-depth: the route validates the body and returns 400
|
||
// before this is ever called, but a non-numeric value reaching
|
||
// PocketBase silently coerces to NaN and the cost audit note gets
|
||
// a `prevCost: NaN` body. Pin the refusal.
|
||
const { admin, getOne, update } = makeAdminForUpdate({
|
||
existing: { id: "row-1", cost_price: 45, notes: null },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const promise = updateOrderItemCost("row-1", {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional misuse for the test
|
||
cost_price: "abc" as any,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
await expect(promise).rejects.toMatchObject({
|
||
name: "CollectionConfirmValidationError",
|
||
code: "invalid_cost_price",
|
||
status: 400,
|
||
message: expect.stringMatching(/finite number/i),
|
||
});
|
||
// Nothing read or written — the service refuses before touching PB.
|
||
expect(getOne).not.toHaveBeenCalled();
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("throws CollectionConfirmValidationError when cost_price is NaN", async () => {
|
||
const { admin, getOne, update } = makeAdminForUpdate({
|
||
existing: { id: "row-1", cost_price: 45, notes: null },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
updateOrderItemCost("row-1", {
|
||
cost_price: Number.NaN,
|
||
actor: { id: "u-1", name: "sj" },
|
||
}),
|
||
).rejects.toMatchObject({
|
||
name: "CollectionConfirmValidationError",
|
||
code: "invalid_cost_price",
|
||
status: 400,
|
||
});
|
||
expect(getOne).not.toHaveBeenCalled();
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("throws CollectionConfirmValidationError when cost_price is negative", async () => {
|
||
const { admin, getOne, update } = makeAdminForUpdate({
|
||
existing: { id: "row-1", cost_price: 45, notes: null },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
updateOrderItemCost("row-1", {
|
||
cost_price: -1,
|
||
actor: { id: "u-1", name: "sj" },
|
||
}),
|
||
).rejects.toMatchObject({
|
||
name: "CollectionConfirmValidationError",
|
||
code: "invalid_cost_price",
|
||
status: 400,
|
||
message: expect.stringMatching(/non-negative/i),
|
||
});
|
||
expect(getOne).not.toHaveBeenCalled();
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("accepts a cost_price of 0 (zero-cost line is a real case)", async () => {
|
||
const existing = { id: "row-1", cost_price: 45, notes: null };
|
||
const { admin, update } = makeAdminForUpdate({ existing });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await updateOrderItemCost("row-1", {
|
||
cost_price: 0,
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
expect(result.cost_price).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("confirmOrderItemsForInvoicing", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const makeAdminForConfirm = (opts: {
|
||
rows: Record<string, unknown>[];
|
||
send?: ReturnType<typeof vi.fn>;
|
||
}) => {
|
||
const getList = vi.fn().mockResolvedValue({ items: opts.rows });
|
||
const update = vi.fn().mockResolvedValue(undefined);
|
||
const batchCollection = vi.fn().mockReturnValue({ update });
|
||
const send = opts.send ?? vi.fn().mockResolvedValue([]);
|
||
const createBatch = vi.fn().mockReturnValue({
|
||
collection: batchCollection,
|
||
send,
|
||
});
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") return { getList };
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
createBatch,
|
||
};
|
||
return { admin, getList, createBatch, batchCollection, update, send };
|
||
};
|
||
|
||
it("writes cost_price, invoice details, status, and audit notes in one batch", async () => {
|
||
const rows = [
|
||
{
|
||
id: "row-1",
|
||
item_status: "collected",
|
||
cost_price: 45,
|
||
notes: null,
|
||
order_item_details: null,
|
||
},
|
||
{
|
||
id: "row-2",
|
||
item_status: "collected",
|
||
cost_price: 10,
|
||
notes: null,
|
||
order_item_details: null,
|
||
},
|
||
];
|
||
const { admin, getList, createBatch, batchCollection, update } =
|
||
makeAdminForConfirm({ rows });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsForInvoicing(["row-1", "row-2"], {
|
||
invoice: { date: "2026-08-03", number: "INV-1" },
|
||
costOverrides: { "row-1": 50, "row-2": 12 },
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual(["row-1", "row-2"]);
|
||
expect(getList).toHaveBeenCalledWith(1, 2, {
|
||
filter: 'id="row-1" || id="row-2"',
|
||
});
|
||
expect(createBatch).toHaveBeenCalledTimes(1);
|
||
expect(batchCollection).toHaveBeenCalledWith("order_items_v2");
|
||
expect(update).toHaveBeenCalledTimes(2);
|
||
|
||
for (const [id, patch] of update.mock.calls as Array<
|
||
[string, Record<string, unknown>]
|
||
>) {
|
||
expect(["row-1", "row-2"]).toContain(id);
|
||
expect(patch).toMatchObject({
|
||
item_status: "ready_invoice",
|
||
cost_price: id === "row-1" ? 50 : 12,
|
||
});
|
||
expect(typeof patch.notes).toBe("string");
|
||
const notes = JSON.parse(patch.notes as string) as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
expect(notes.map((entry) => entry.category)).toEqual(["confirm", "cost"]);
|
||
expect(notes[0]?.body).toBe(
|
||
"Confirmed for invoicing — INV-1 (2026-08-03)",
|
||
);
|
||
expect(notes[1]?.body).toEqual({
|
||
prevCost: id === "row-1" ? 45 : 10,
|
||
newCost: id === "row-1" ? 50 : 12,
|
||
});
|
||
expect(typeof patch.order_item_details).toBe("string");
|
||
const details = JSON.parse(patch.order_item_details as string) as {
|
||
invoice?: Record<string, unknown>;
|
||
};
|
||
expect(details.invoice).toMatchObject({
|
||
date: "2026-08-03",
|
||
number: "INV-1",
|
||
setBy: "u-1",
|
||
setByName: "sj",
|
||
});
|
||
expect(typeof details.invoice?.setAt).toBe("string");
|
||
}
|
||
});
|
||
|
||
it("behaves like buildConfirmPatch when no cost overrides are provided", async () => {
|
||
const rows = [
|
||
{
|
||
id: "row-1",
|
||
item_status: "collected",
|
||
cost_price: 45,
|
||
notes: null,
|
||
order_item_details: null,
|
||
},
|
||
];
|
||
const { admin, update } = makeAdminForConfirm({ rows });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsForInvoicing(["row-1"], {
|
||
invoice: { date: "2026-08-03", number: "INV-1" },
|
||
costOverrides: {},
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual(["row-1"]);
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||
expect(patch).toMatchObject({ item_status: "ready_invoice" });
|
||
expect(patch).toHaveProperty("notes");
|
||
expect(patch).toHaveProperty("order_item_details");
|
||
expect(patch).not.toHaveProperty("cost_price");
|
||
const notes = JSON.parse(patch.notes as string) as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
expect(notes).toHaveLength(1);
|
||
expect(notes[0]?.category).toBe("confirm");
|
||
});
|
||
|
||
it("aborts the batch and surfaces the error when send rejects", async () => {
|
||
const send = vi.fn().mockRejectedValue(new Error("batch failed"));
|
||
const rows = [
|
||
{ id: "row-1", item_status: "collected", cost_price: 45, notes: null },
|
||
{ id: "row-2", item_status: "collected", cost_price: 12, notes: null },
|
||
];
|
||
const { admin, createBatch, update } = makeAdminForConfirm({ rows, send });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
confirmOrderItemsForInvoicing(["row-1", "row-2"], {
|
||
invoice: { date: "2026-08-03", number: "INV-1" },
|
||
costOverrides: { "row-1": 50 },
|
||
actor: { id: "u-1", name: "sj" },
|
||
}),
|
||
).rejects.toThrow("batch failed");
|
||
|
||
expect(createBatch).toHaveBeenCalledTimes(1);
|
||
expect(send).toHaveBeenCalledTimes(1);
|
||
// The SDK only queues update operations before send; a rejected send
|
||
// means no individual collection update was issued outside the batch.
|
||
expect(update).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("does not write cost_price when an override equals the current cost", async () => {
|
||
const rows = [
|
||
{
|
||
id: "row-1",
|
||
item_status: "collected",
|
||
cost_price: 50,
|
||
notes: null,
|
||
order_item_details: null,
|
||
},
|
||
];
|
||
const { admin, update } = makeAdminForConfirm({ rows });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await confirmOrderItemsForInvoicing(["row-1"], {
|
||
invoice: { date: "2026-08-03", number: "INV-1" },
|
||
costOverrides: { "row-1": 50 },
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||
expect(patch).not.toHaveProperty("cost_price");
|
||
const notes = JSON.parse(patch.notes as string) as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
expect(notes.map((entry) => entry.category)).toEqual(["confirm"]);
|
||
});
|
||
|
||
it("writes cost_price and appends a cost note when overriding from null to 0", async () => {
|
||
// Regression for the lossy `prevCost ?? 0` fallback that
|
||
// collapsed a `null → 0` transition into `0 → 0` and skipped the
|
||
// cost note. The fix threads null through; this test pins it.
|
||
const rows = [
|
||
{
|
||
id: "row-1",
|
||
item_status: "collected",
|
||
cost_price: null,
|
||
notes: null,
|
||
order_item_details: null,
|
||
},
|
||
];
|
||
const { admin, update } = makeAdminForConfirm({ rows });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await confirmOrderItemsForInvoicing(["row-1"], {
|
||
invoice: { date: "2026-08-03", number: "INV-1" },
|
||
costOverrides: { "row-1": 0 },
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||
expect(patch.cost_price).toBe(0);
|
||
const notes = JSON.parse(patch.notes as string) as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
// The confirm note (from buildConfirmPatch) is appended first,
|
||
// then the cost note — same shape as any other override.
|
||
expect(notes.map((entry) => entry.category)).toEqual(["confirm", "cost"]);
|
||
expect(notes[1]?.body).toEqual({ prevCost: null, newCost: 0 });
|
||
});
|
||
});
|
||
|
||
describe("confirmStockRows", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Mock admin client factory for the confirm-collection flow. The
|
||
* factory exposes just the `stock_purchase_items` collection —
|
||
* the new warehouse-side flow does NOT touch ledger, stock_items,
|
||
* or the parent PO header (those are the accountant's job in
|
||
* `bookStockFromInvoice`). If a test accidentally invokes one of
|
||
* those collections, the factory throws a clear error so the
|
||
* assertion failure is unambiguous.
|
||
*/
|
||
const makeConfirmAdmin = (opts: {
|
||
lineById: Record<string, Record<string, unknown>>;
|
||
}) => {
|
||
const getOne = vi.fn().mockImplementation((id: string) => {
|
||
const line = opts.lineById[id] ?? null;
|
||
if (!line) {
|
||
return Promise.reject(
|
||
Object.assign(new Error("not found"), { status: 404 }),
|
||
);
|
||
}
|
||
return Promise.resolve(line);
|
||
});
|
||
const lineUpdate = vi.fn().mockResolvedValue(undefined);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "stock_purchase_items") {
|
||
return { getOne, update: lineUpdate };
|
||
}
|
||
// The inventory write has been moved to the accountant's
|
||
// `bookStockFromInvoice` flow. The warehouse-side
|
||
// confirm-collection flow must NOT touch these collections.
|
||
throw new Error(
|
||
`unexpected collection ${name} — confirmStockRows should not touch this collection`,
|
||
);
|
||
}),
|
||
};
|
||
return { admin, getOne, lineUpdate };
|
||
};
|
||
|
||
it("returns an empty array when given no ids", async () => {
|
||
const { admin } = makeConfirmAdmin({ lineById: {} });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmStockRows([], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual([]);
|
||
// No admin calls expected — the empty-input branch bails early.
|
||
expect(admin.collection).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("snaps quantity_received to the full quantity and returns updated ids", async () => {
|
||
const line = {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 0,
|
||
cost_price: 5,
|
||
purchase: "sp-1",
|
||
};
|
||
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
|
||
lineById: { "spi-1": line },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmStockRows(["spi-1"], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual(["spi-1"]);
|
||
|
||
// The line is read first (so we have quantity + parent purchase id).
|
||
expect(getOne).toHaveBeenCalledWith("spi-1");
|
||
|
||
// The line update snaps quantity_received to the full qty.
|
||
// Crucially, status is NOT touched — the driver app sets it to
|
||
// `collected`, and the accountant's `bookStockFromInvoice` is
|
||
// what flips it to `booked` once payment is committed.
|
||
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
|
||
quantity_received: 10,
|
||
});
|
||
});
|
||
|
||
it("does not touch stock_items, stock_ledger, or stock_purchases", async () => {
|
||
// The confirm-collection flow is intentionally narrow. If a
|
||
// future change accidentally reintroduces ledger writes or
|
||
// header auto-derive, this test fires.
|
||
const line = {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 0,
|
||
purchase: "sp-1",
|
||
};
|
||
const { admin, lineUpdate } = makeConfirmAdmin({
|
||
lineById: { "spi-1": line },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await confirmStockRows(["spi-1"], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
// Only stock_purchase_items was ever accessed. There are
|
||
// multiple .collection() calls (one for the read, one for the
|
||
// update) — check the unique set, not the call list.
|
||
const accessedCollections = new Set(
|
||
admin.collection.mock.calls.map(([name]) => name),
|
||
);
|
||
expect(accessedCollections).toEqual(new Set(["stock_purchase_items"]));
|
||
expect(lineUpdate).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("throws and skips the row when quantity is zero", async () => {
|
||
const line = {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 0,
|
||
quantity_received: 0,
|
||
purchase: "sp-1",
|
||
};
|
||
const { admin, lineUpdate } = makeConfirmAdmin({
|
||
lineById: { "spi-1": line },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
|
||
).rejects.toThrow(/has zero quantity/i);
|
||
|
||
expect(lineUpdate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("is idempotent: a line already at full quantity_received is omitted from updatedIds (no throw)", async () => {
|
||
// Old behaviour threw a plain Error here → 500. Once the qty
|
||
// pencil landed, snapping quantity_received to the full qty
|
||
// could leave the line "stuck" — the confirm-stock button
|
||
// would 500 forever. Now it's a no-op success: the row is
|
||
// dropped from updatedIds so the route returns 200 with the
|
||
// remaining rows only.
|
||
const line = {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 10,
|
||
purchase: "sp-1",
|
||
};
|
||
const { admin, lineUpdate } = makeConfirmAdmin({
|
||
lineById: { "spi-1": line },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmStockRows(["spi-1"], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual([]);
|
||
expect(lineUpdate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("processes a mixed batch: full-qty rows are skipped, partial rows are advanced", async () => {
|
||
const lines = {
|
||
"spi-1": {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 10, // already full
|
||
purchase: "sp-1",
|
||
},
|
||
"spi-2": {
|
||
id: "spi-2",
|
||
stock_item: "st-2",
|
||
quantity: 4,
|
||
quantity_received: 2, // partial
|
||
purchase: "sp-1",
|
||
},
|
||
};
|
||
const { admin, lineUpdate } = makeConfirmAdmin({
|
||
lineById: lines,
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmStockRows(["spi-1", "spi-2"], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual(["spi-2"]);
|
||
expect(lineUpdate).toHaveBeenCalledTimes(1);
|
||
expect(lineUpdate).toHaveBeenCalledWith("spi-2", { quantity_received: 4 });
|
||
});
|
||
|
||
it("throws and skips the row when the parent purchase id is missing", async () => {
|
||
const line = {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 0,
|
||
// No `purchase` field — orphan row.
|
||
};
|
||
const { admin, lineUpdate } = makeConfirmAdmin({
|
||
lineById: { "spi-1": line },
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(
|
||
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
|
||
).rejects.toThrow(/missing the parent purchase id/i);
|
||
|
||
expect(lineUpdate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("confirms multiple rows independently", async () => {
|
||
const lines = {
|
||
"spi-1": {
|
||
id: "spi-1",
|
||
stock_item: "st-1",
|
||
quantity: 10,
|
||
quantity_received: 0,
|
||
purchase: "sp-1",
|
||
},
|
||
"spi-2": {
|
||
id: "spi-2",
|
||
stock_item: "st-2",
|
||
quantity: 4,
|
||
quantity_received: 2,
|
||
purchase: "sp-1",
|
||
},
|
||
};
|
||
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
|
||
lineById: lines,
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmStockRows(["spi-1", "spi-2"], {
|
||
actor: { id: "u-1", name: "sj" },
|
||
});
|
||
|
||
expect(result).toEqual(["spi-1", "spi-2"]);
|
||
expect(getOne).toHaveBeenCalledWith("spi-1");
|
||
expect(getOne).toHaveBeenCalledWith("spi-2");
|
||
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
|
||
quantity_received: 10,
|
||
});
|
||
expect(lineUpdate).toHaveBeenCalledWith("spi-2", {
|
||
quantity_received: 4,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("clearCollectedRows", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
/** Mock admin exposing getOne + update on the order-items collection. */
|
||
const makeClearAdmin = (
|
||
existing: Record<string, unknown> | null,
|
||
opts: { throws?: boolean } = {},
|
||
) => {
|
||
const getOne = opts.throws
|
||
? vi.fn().mockRejectedValue(new Error("404 not found"))
|
||
: vi.fn().mockResolvedValue(existing);
|
||
const update = vi.fn().mockImplementation((_id: string, patch: unknown) =>
|
||
Promise.resolve({ ...(existing ?? {}), ...(patch as object) }),
|
||
);
|
||
const admin = {
|
||
collection: vi.fn().mockReturnValue({ getOne, update }),
|
||
};
|
||
return { admin, getOne, update };
|
||
};
|
||
|
||
const actor = { id: "yse", name: "Ysterbaard" };
|
||
|
||
it("moves a collected row to the chosen status and stamps a status audit note", async () => {
|
||
const { admin, update } = makeClearAdmin({
|
||
id: "r1",
|
||
item_status: "collected",
|
||
notes: "",
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const res = await clearCollectedRows(["r1"], {
|
||
action: "delivered",
|
||
reason: "Customer already has these",
|
||
actor,
|
||
});
|
||
|
||
expect(res).toEqual({ cleared: ["r1"], skipped: [] });
|
||
const patch = update.mock.calls[0][1] as Record<string, unknown>;
|
||
expect(patch.item_status).toBe("delivered");
|
||
const notes = JSON.parse(patch.notes as string) as Array<{
|
||
category: string;
|
||
body: string;
|
||
actorName: string;
|
||
}>;
|
||
// The `status` category must survive the read-back filter, and the
|
||
// reason must be in the body or the audit trail says nothing.
|
||
expect(notes).toHaveLength(1);
|
||
expect(notes[0].category).toBe("status");
|
||
expect(notes[0].actorName).toBe("Ysterbaard");
|
||
expect(notes[0].body).toContain("Customer already has these");
|
||
});
|
||
|
||
it("refuses a row that is no longer collected and reports it as skipped", async () => {
|
||
const { admin, update } = makeClearAdmin({
|
||
id: "r1",
|
||
item_status: "delivered",
|
||
notes: "",
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const res = await clearCollectedRows(["r1"], {
|
||
action: "invoiced",
|
||
reason: "duplicate click",
|
||
actor,
|
||
});
|
||
|
||
expect(res.cleared).toEqual([]);
|
||
expect(res.skipped).toEqual([{ id: "r1", reason: "already delivered" }]);
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("skips an id that no longer exists instead of throwing", async () => {
|
||
const { admin } = makeClearAdmin(null, { throws: true });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const res = await clearCollectedRows(["gone"], {
|
||
action: "delivered",
|
||
reason: "x",
|
||
actor,
|
||
});
|
||
expect(res).toEqual({ cleared: [], skipped: [{ id: "gone", reason: "not found" }] });
|
||
});
|
||
|
||
it("writes the invoice number and date only when they are supplied", async () => {
|
||
const { admin, update } = makeClearAdmin({
|
||
id: "r1",
|
||
item_status: "collected",
|
||
notes: "",
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await clearCollectedRows(["r1"], {
|
||
action: "invoiced",
|
||
reason: "raised on the old sheet",
|
||
actor,
|
||
invoice: { number: "INV-1042", date: "2026-09-02" },
|
||
});
|
||
const withInvoice = update.mock.calls[0][1] as Record<string, unknown>;
|
||
expect(withInvoice.customer_invoice_number).toBe("INV-1042");
|
||
expect(withInvoice.invoiced_date).toBe("2026-09-02");
|
||
|
||
vi.clearAllMocks();
|
||
const second = makeClearAdmin({ id: "r2", item_status: "collected", notes: "" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(second.admin as never);
|
||
await clearCollectedRows(["r2"], {
|
||
action: "invoiced",
|
||
reason: "number not to hand",
|
||
actor,
|
||
});
|
||
const noInvoice = second.update.mock.calls[0][1] as Record<string, unknown>;
|
||
// Critically: must not write an EMPTY invoice number over whatever
|
||
// the accountant already put there.
|
||
expect(noInvoice).not.toHaveProperty("customer_invoice_number");
|
||
expect(noInvoice).not.toHaveProperty("invoiced_date");
|
||
});
|
||
|
||
it("rejects an invoice number with no date", async () => {
|
||
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected", notes: "" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
await expect(
|
||
clearCollectedRows(["r1"], {
|
||
action: "invoiced",
|
||
reason: "x",
|
||
actor,
|
||
invoice: { number: "INV-1", date: "" },
|
||
}),
|
||
).rejects.toMatchObject({ code: "invalid_invoice_date" });
|
||
});
|
||
|
||
it("requires a reason", async () => {
|
||
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
await expect(
|
||
clearCollectedRows(["r1"], { action: "delivered", reason: " ", actor }),
|
||
).rejects.toMatchObject({ code: "reason_required" });
|
||
});
|
||
|
||
it("rejects an unknown action", async () => {
|
||
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
await expect(
|
||
clearCollectedRows(["r1"], {
|
||
action: "deleted" as never,
|
||
reason: "x",
|
||
actor,
|
||
}),
|
||
).rejects.toMatchObject({ code: "invalid_action" });
|
||
});
|
||
});
|
||
|
||
describe("listCollectedItems — AI check passthrough", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const check = (read_id: string) => ({
|
||
read_id,
|
||
document_no: "5028256",
|
||
doc_date: "2026-10-16",
|
||
doc_type: "tax_invoice",
|
||
doc_line: null,
|
||
same_item: null,
|
||
reason: "",
|
||
sku_filled_value: "",
|
||
findings: [],
|
||
checked_at: "2026-10-17T08:00:00.000Z",
|
||
model: "MiniMax-M3",
|
||
});
|
||
|
||
const makeAiAdmin = (opts: {
|
||
orderItems?: Record<string, unknown>[];
|
||
stockItems?: Record<string, unknown>[];
|
||
docReads?: Record<string, unknown>[];
|
||
posItems?: Record<string, unknown>[];
|
||
}) => {
|
||
const docReadsGetFullList = vi.fn().mockResolvedValue(opts.docReads ?? []);
|
||
const posGetFullList = vi.fn().mockResolvedValue(opts.posItems ?? []);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") {
|
||
return { getFullList: vi.fn().mockResolvedValue(opts.orderItems ?? []) };
|
||
}
|
||
if (name === "stock_purchase_items") {
|
||
return { getFullList: vi.fn().mockResolvedValue(opts.stockItems ?? []) };
|
||
}
|
||
if (name === "collection_doc_reads") return { getFullList: docReadsGetFullList };
|
||
if (name === "stock_items") return { getFullList: posGetFullList };
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
};
|
||
return { admin, docReadsGetFullList, posGetFullList };
|
||
};
|
||
|
||
const DOC_LINE = {
|
||
line_no: 3,
|
||
code: "M8X25",
|
||
description: "Bolt M8x25",
|
||
quantity: 100,
|
||
unit: "ea",
|
||
unit_price: 0.33,
|
||
line_total: 33,
|
||
to_follow: null,
|
||
price_note: "",
|
||
};
|
||
|
||
it("attaches ai_doc (with findings and all document lines) from the read named by ai_check.read_id, loading reads in one query", async () => {
|
||
const { admin, docReadsGetFullList } = makeAiAdmin({
|
||
orderItems: [
|
||
{ id: "oi-1", item_status: "collected", sku: "", ai_check: check("r1") },
|
||
{ id: "oi-2", item_status: "collected", sku: "", ai_check: check("r1") },
|
||
{ id: "oi-3", item_status: "collected", sku: "", ai_check: null },
|
||
],
|
||
docReads: [
|
||
{
|
||
id: "r1",
|
||
doc_type: "tax_invoice",
|
||
document_no: "5028256",
|
||
doc_date: "2026-10-16",
|
||
supplier_name: "NSS Fasteners",
|
||
findings: ["Document 5028256 covers 2 POs: A, B."],
|
||
lines: [DOC_LINE],
|
||
},
|
||
],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||
|
||
expect(docReadsGetFullList).toHaveBeenCalledTimes(1);
|
||
expect(docReadsGetFullList.mock.calls[0]?.[0]).toMatchObject({ filter: 'id = "r1"' });
|
||
expect(byId.get("oi-1")?.ai_check?.read_id).toBe("r1");
|
||
expect(byId.get("oi-1")?.ai_doc).toEqual({
|
||
read_id: "r1",
|
||
doc_type: "tax_invoice",
|
||
document_no: "5028256",
|
||
doc_date: "2026-10-16",
|
||
supplier_name: "NSS Fasteners",
|
||
findings: ["Document 5028256 covers 2 POs: A, B."],
|
||
lines: [DOC_LINE],
|
||
});
|
||
expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1");
|
||
expect(byId.get("oi-3")?.ai_doc).toBeNull();
|
||
});
|
||
|
||
it("gives rows without ai_check ai_check: null and ai_doc: null", async () => {
|
||
const { admin, docReadsGetFullList } = makeAiAdmin({
|
||
orderItems: [{ id: "oi-1", item_status: "collected" }],
|
||
stockItems: [
|
||
{ id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" },
|
||
],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
|
||
expect(rows).toHaveLength(2);
|
||
for (const r of rows) {
|
||
expect(r.ai_check).toBeNull();
|
||
expect(r.ai_doc).toBeNull();
|
||
}
|
||
expect(docReadsGetFullList).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("passes a stock row's ai_check through", async () => {
|
||
const { admin } = makeAiAdmin({
|
||
stockItems: [
|
||
{
|
||
id: "spi-1",
|
||
description: "x",
|
||
quantity: 2,
|
||
quantity_received: 1,
|
||
status: "collected",
|
||
stock_item: "st-1",
|
||
ai_check: check("r9"),
|
||
},
|
||
],
|
||
docReads: [{ id: "r9", doc_type: "delivery_note", document_no: "DN1", doc_date: "", supplier_name: "", findings: [] }],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const [row] = await listCollectedItems();
|
||
|
||
expect(row?.ai_check?.read_id).toBe("r9");
|
||
expect(row?.ai_doc).toMatchObject({ read_id: "r9", doc_type: "delivery_note", document_no: "DN1" });
|
||
});
|
||
|
||
it("sets in_pos and pos_stock_item_id when a stock_items SKU normalises to the row's SKU", async () => {
|
||
const { admin, posGetFullList } = makeAiAdmin({
|
||
orderItems: [
|
||
{ id: "oi-1", item_status: "collected", sku: "sm293306030" },
|
||
{ id: "oi-2", item_status: "collected", sku: "NOPE-1" },
|
||
{ id: "oi-3", item_status: "collected", sku: "" },
|
||
],
|
||
posItems: [
|
||
{ id: "st-9", sku: "SM-293306030" },
|
||
{ id: "st-8", sku: "NOPE-12" },
|
||
],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const rows = await listCollectedItems();
|
||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||
|
||
expect(posGetFullList).toHaveBeenCalledTimes(1);
|
||
const filter = (posGetFullList.mock.calls[0]?.[0] as { filter?: string }).filter ?? "";
|
||
expect(filter).toContain('sku ~ "sm293306030"');
|
||
expect(filter).toContain('sku ~ "NOPE-1"');
|
||
expect(byId.get("oi-1")).toMatchObject({ in_pos: true, pos_stock_item_id: "st-9" });
|
||
expect(byId.get("oi-2")).toMatchObject({ in_pos: false, pos_stock_item_id: null });
|
||
expect(byId.get("oi-3")).toMatchObject({ in_pos: false, pos_stock_item_id: null });
|
||
});
|
||
|
||
it("marks stock rows in_pos with their catalogue item", async () => {
|
||
const { admin, posGetFullList } = makeAiAdmin({
|
||
stockItems: [
|
||
{ id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" },
|
||
],
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const [row] = await listCollectedItems();
|
||
|
||
expect(row).toMatchObject({ in_pos: true, pos_stock_item_id: "st-1" });
|
||
expect(posGetFullList).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("stays fail-soft when the reads or POS lookups fail", async () => {
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") {
|
||
return {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi-1", item_status: "collected", sku: "A1", ai_check: check("r1") },
|
||
]),
|
||
};
|
||
}
|
||
if (name === "stock_purchase_items") return { getFullList: vi.fn().mockResolvedValue([]) };
|
||
return { getFullList: vi.fn().mockRejectedValue(new Error("down")) };
|
||
}),
|
||
};
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const [row] = await listCollectedItems();
|
||
|
||
expect(row?.ai_check?.read_id).toBe("r1");
|
||
expect(row?.ai_doc).toMatchObject({ read_id: "r1", document_no: "5028256", findings: [] });
|
||
expect(row?.in_pos).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("addRowsToPos", () => {
|
||
const oi = (over: Record<string, unknown> = {}) => ({
|
||
id: "oi-1",
|
||
description: "M6 nylock nut",
|
||
po_description: "NUT NYLOCK M6 ZP",
|
||
sku: "SM2-NUT9B506",
|
||
cost_price: 0.2,
|
||
expand: { purchase_order: { id: "po-1", expand: { supplier: { id: "sup-1" } } } },
|
||
...over,
|
||
});
|
||
const makeAdmin = (opts: {
|
||
orderItems?: Record<string, unknown>[];
|
||
stockPurchaseItems?: Record<string, unknown>[];
|
||
posItems?: Record<string, unknown>[];
|
||
}) => ({
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items_v2") return { getFullList: vi.fn().mockResolvedValue(opts.orderItems ?? []) };
|
||
if (name === "stock_purchase_items") return { getFullList: vi.fn().mockResolvedValue(opts.stockPurchaseItems ?? []) };
|
||
if (name === "stock_items") return { getFullList: vi.fn().mockResolvedValue(opts.posItems ?? []) };
|
||
throw new Error(`unexpected collection ${name}`);
|
||
}),
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.mocked(createStockItem).mockResolvedValue({ id: "si-new" } as never);
|
||
});
|
||
|
||
it("creates a POS item for a customer row from its line fields", async () => {
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [oi()] }) as never);
|
||
|
||
const results = await addRowsToPos(["oi-1"]);
|
||
|
||
expect(results).toEqual([{ row_id: "oi-1", status: "created", stock_item_id: "si-new" }]);
|
||
expect(createStockItem).toHaveBeenCalledWith({
|
||
name: "M6 nylock nut",
|
||
description: "NUT NYLOCK M6 ZP",
|
||
sku: "SM2-NUT9B506",
|
||
supplier: "sup-1",
|
||
cost_price: 0.2,
|
||
});
|
||
});
|
||
|
||
it("takes the unit from the AI document line when present", async () => {
|
||
const row = oi({ po_description: "", ai_check: { doc_line: { unit: "EA" } } });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [row] }) as never);
|
||
|
||
await addRowsToPos(["oi-1"]);
|
||
|
||
expect(createStockItem).toHaveBeenCalledWith(
|
||
expect.objectContaining({ unit: "EA", description: "M6 nylock nut" }),
|
||
);
|
||
});
|
||
|
||
it("returns already_in_pos when a normalised SKU match exists", async () => {
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
||
makeAdmin({ orderItems: [oi()], posItems: [{ id: "si-9", sku: "sm2 nut9b506" }] }) as never,
|
||
);
|
||
|
||
const results = await addRowsToPos(["oi-1"]);
|
||
|
||
expect(results).toEqual([{ row_id: "oi-1", status: "already_in_pos", stock_item_id: "si-9" }]);
|
||
expect(createStockItem).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns already_in_pos for a stock-purchase row and not_found for an unknown id", async () => {
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
||
makeAdmin({ stockPurchaseItems: [{ id: "spi-1" }] }) as never,
|
||
);
|
||
|
||
const results = await addRowsToPos(["spi-1", "nope"]);
|
||
|
||
expect(results).toEqual([
|
||
{ row_id: "spi-1", status: "already_in_pos", stock_item_id: null },
|
||
{ row_id: "nope", status: "not_found", stock_item_id: null },
|
||
]);
|
||
expect(createStockItem).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("creates only once when two rows share a supplier code", async () => {
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
||
makeAdmin({ orderItems: [oi(), oi({ id: "oi-2" })] }) as never,
|
||
);
|
||
|
||
const results = await addRowsToPos(["oi-1", "oi-2"]);
|
||
|
||
expect(results.map((r) => r.status)).toEqual(["created", "already_in_pos"]);
|
||
expect(createStockItem).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|