2026-08-02 22:08:57 +00:00
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
|
|
|
|
getAdminPocketBase: vi.fn(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-08-02 22:17:56 +00:00
|
|
|
|
vi.mock("@/lib/server/orders-service", () => ({
|
|
|
|
|
|
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-08-02 22:08:57 +00:00
|
|
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
2026-08-02 22:17:56 +00:00
|
|
|
|
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
|
2026-08-02 22:08:57 +00:00
|
|
|
|
import {
|
2026-08-06 21:01:15 +00:00
|
|
|
|
confirmStockRows,
|
2026-08-02 22:08:57 +00:00
|
|
|
|
listCollectedItems,
|
|
|
|
|
|
updateOrderItemCost,
|
|
|
|
|
|
confirmOrderItemsForInvoicing,
|
|
|
|
|
|
} 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) => {
|
2026-08-02 22:17:56 +00:00
|
|
|
|
if (name === "order_items_v2") {
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-02 22:17:56 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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" },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const { admin, orderItemsGetFullList, stockItemsGetFullList } = makeAdmin({
|
|
|
|
|
|
orderItems: [orderItem],
|
|
|
|
|
|
});
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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");
|
2026-08-03 16:52:30 +00:00
|
|
|
|
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe(
|
|
|
|
|
|
"Acme",
|
|
|
|
|
|
);
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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
|
2026-08-03 16:52:30 +00:00
|
|
|
|
{ filter?: string; expand?: string } | undefined;
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const { admin, orderItemsGetFullList, stockItemsGetFullList } = makeAdmin({
|
|
|
|
|
|
stockItems: [stockItem],
|
|
|
|
|
|
});
|
2026-08-02 22:08:57 +00:00
|
|
|
|
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");
|
2026-08-02 22:17:56 +00:00
|
|
|
|
expect(row.supplier).toBe("s-2");
|
2026-08-02 22:08:57 +00:00
|
|
|
|
// 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");
|
2026-08-03 16:52:30 +00:00
|
|
|
|
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe(
|
|
|
|
|
|
"CableCo",
|
|
|
|
|
|
);
|
2026-08-02 22:08:57 +00:00
|
|
|
|
// Stock rows have no customer side.
|
|
|
|
|
|
expect(row.expand?.order).toBeUndefined();
|
|
|
|
|
|
|
2026-08-17 06:27:36 +00:00
|
|
|
|
// 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".
|
2026-08-02 22:08:57 +00:00
|
|
|
|
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as
|
2026-08-03 16:52:30 +00:00
|
|
|
|
{ filter?: string; expand?: string } | undefined;
|
2026-08-17 06:27:36 +00:00
|
|
|
|
expect(stockOpts?.filter).toBe(
|
|
|
|
|
|
'status = "collected" && quantity_received < quantity',
|
|
|
|
|
|
);
|
2026-08-14 16:25:05 +00:00
|
|
|
|
expect(stockOpts?.expand).toBe(
|
|
|
|
|
|
"purchase.supplier,purchase.stock_item,purchase.additional_attachments",
|
|
|
|
|
|
);
|
2026-08-02 22:08:57 +00:00
|
|
|
|
// 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"]);
|
|
|
|
|
|
});
|
2026-08-02 22:12:02 +00:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-08-02 22:17:56 +00:00
|
|
|
|
if (name === "order_items_v2") {
|
2026-08-02 22:12:02 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-02 22:08:57 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-02 22:23:37 +00:00
|
|
|
|
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);
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const update = vi
|
|
|
|
|
|
.fn()
|
|
|
|
|
|
.mockImplementation((_id: string, patch: Record<string, unknown>) =>
|
|
|
|
|
|
Promise.resolve({
|
|
|
|
|
|
...opts.existing,
|
|
|
|
|
|
...(opts.updated ?? {}),
|
|
|
|
|
|
...patch,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-08-02 22:23:37 +00:00
|
|
|
|
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
|
2026-08-02 22:29:56 +00:00
|
|
|
|
// the patch into the existing row).
|
2026-08-02 22:23:37 +00:00
|
|
|
|
expect(result.id).toBe("row-1");
|
2026-08-02 22:29:56 +00:00
|
|
|
|
expect(result.cost_price).toBe(50);
|
2026-08-02 22:23:37 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-02 22:08:57 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-02 22:23:37 +00:00
|
|
|
|
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);
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const patch = update.mock.calls[0]?.[1] as {
|
|
|
|
|
|
cost_price: number;
|
|
|
|
|
|
notes: string;
|
|
|
|
|
|
};
|
2026-08-02 22:23:37 +00:00
|
|
|
|
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 });
|
|
|
|
|
|
});
|
2026-08-02 22:29:56 +00:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-08-02 22:46:50 +00:00
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-03 00:35:58 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-02 22:23:37 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-02 22:40:29 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-03 16:52:30 +00:00
|
|
|
|
for (const [id, patch] of update.mock.calls as Array<
|
|
|
|
|
|
[string, Record<string, unknown>]
|
|
|
|
|
|
>) {
|
2026-08-02 22:40:29 +00:00
|
|
|
|
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");
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const notes = JSON.parse(patch.notes as string) as Array<
|
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
|
>;
|
2026-08-02 22:40:29 +00:00
|
|
|
|
expect(notes.map((entry) => entry.category)).toEqual(["confirm", "cost"]);
|
2026-08-03 16:52:30 +00:00
|
|
|
|
expect(notes[0]?.body).toBe(
|
|
|
|
|
|
"Confirmed for invoicing — INV-1 (2026-08-03)",
|
|
|
|
|
|
);
|
2026-08-02 22:40:29 +00:00
|
|
|
|
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");
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const notes = JSON.parse(patch.notes as string) as Array<
|
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
|
>;
|
2026-08-02 22:40:29 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-02 22:08:57 +00:00
|
|
|
|
await expect(
|
2026-08-02 22:40:29 +00:00
|
|
|
|
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");
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const notes = JSON.parse(patch.notes as string) as Array<
|
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
|
>;
|
2026-08-02 22:40:29 +00:00
|
|
|
|
expect(notes.map((entry) => entry.category)).toEqual(["confirm"]);
|
2026-08-02 22:08:57 +00:00
|
|
|
|
});
|
2026-08-02 22:46:50 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-03 16:52:30 +00:00
|
|
|
|
const notes = JSON.parse(patch.notes as string) as Array<
|
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
|
>;
|
2026-08-02 22:46:50 +00:00
|
|
|
|
// 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 });
|
|
|
|
|
|
});
|
2026-08-02 22:08:57 +00:00
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
describe("confirmStockRows", () => {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-06 21:01:15 +00:00
|
|
|
|
* 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.
|
2026-08-03 00:29:20 +00:00
|
|
|
|
*/
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const makeConfirmAdmin = (opts: {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
lineById: Record<string, Record<string, unknown>>;
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const getOne = vi.fn().mockImplementation((id: string) => {
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const line = opts.lineById[id] ?? null;
|
2026-08-03 00:29:20 +00:00
|
|
|
|
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") {
|
2026-08-06 21:01:15 +00:00
|
|
|
|
return { getOne, update: lineUpdate };
|
2026-08-03 00:29:20 +00:00
|
|
|
|
}
|
2026-08-06 21:01:15 +00:00
|
|
|
|
// 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`,
|
|
|
|
|
|
);
|
2026-08-03 00:29:20 +00:00
|
|
|
|
}),
|
|
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
return { admin, getOne, lineUpdate };
|
2026-08-03 00:29:20 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
it("returns an empty array when given no ids", async () => {
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin } = makeConfirmAdmin({ lineById: {} });
|
2026-08-03 00:29:20 +00:00
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const result = await confirmStockRows([], {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
actor: { id: "u-1", name: "sj" },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual([]);
|
|
|
|
|
|
// No admin calls expected — the empty-input branch bails early.
|
|
|
|
|
|
expect(admin.collection).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
it("snaps quantity_received to the full quantity and returns updated ids", async () => {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
const line = {
|
|
|
|
|
|
id: "spi-1",
|
|
|
|
|
|
stock_item: "st-1",
|
|
|
|
|
|
quantity: 10,
|
|
|
|
|
|
quantity_received: 0,
|
|
|
|
|
|
cost_price: 5,
|
|
|
|
|
|
purchase: "sp-1",
|
|
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
|
2026-08-03 00:29:20 +00:00
|
|
|
|
lineById: { "spi-1": line },
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const result = await confirmStockRows(["spi-1"], {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
actor: { id: "u-1", name: "sj" },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(["spi-1"]);
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
// The line is read first (so we have quantity + parent purchase id).
|
2026-08-03 00:29:20 +00:00
|
|
|
|
expect(getOne).toHaveBeenCalledWith("spi-1");
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
// 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.
|
2026-08-03 00:29:20 +00:00
|
|
|
|
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
|
|
|
|
|
|
quantity_received: 10,
|
|
|
|
|
|
});
|
2026-08-06 21:01:15 +00:00
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
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 },
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
2026-08-06 21:01:15 +00:00
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
|
|
|
|
|
await confirmStockRows(["spi-1"], {
|
|
|
|
|
|
actor: { id: "u-1", name: "sj" },
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
2026-08-06 21:01:15 +00:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin, lineUpdate } = makeConfirmAdmin({
|
|
|
|
|
|
lineById: { "spi-1": line },
|
|
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
2026-08-06 21:01:15 +00:00
|
|
|
|
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
|
2026-08-03 00:29:20 +00:00
|
|
|
|
).rejects.toThrow(/has zero quantity/i);
|
|
|
|
|
|
|
|
|
|
|
|
expect(lineUpdate).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-17 06:19:38 +00:00
|
|
|
|
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.
|
2026-08-03 00:29:20 +00:00
|
|
|
|
const line = {
|
|
|
|
|
|
id: "spi-1",
|
|
|
|
|
|
stock_item: "st-1",
|
|
|
|
|
|
quantity: 10,
|
|
|
|
|
|
quantity_received: 10,
|
|
|
|
|
|
purchase: "sp-1",
|
|
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin, lineUpdate } = makeConfirmAdmin({
|
|
|
|
|
|
lineById: { "spi-1": line },
|
|
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
2026-08-17 06:19:38 +00:00
|
|
|
|
const result = await confirmStockRows(["spi-1"], {
|
|
|
|
|
|
actor: { id: "u-1", name: "sj" },
|
|
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
|
2026-08-17 06:19:38 +00:00
|
|
|
|
expect(result).toEqual([]);
|
2026-08-03 00:29:20 +00:00
|
|
|
|
expect(lineUpdate).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-17 06:19:38 +00:00
|
|
|
|
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 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
it("throws and skips the row when the parent purchase id is missing", async () => {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
const line = {
|
|
|
|
|
|
id: "spi-1",
|
|
|
|
|
|
stock_item: "st-1",
|
|
|
|
|
|
quantity: 10,
|
|
|
|
|
|
quantity_received: 0,
|
2026-08-06 21:01:15 +00:00
|
|
|
|
// No `purchase` field — orphan row.
|
2026-08-03 00:29:20 +00:00
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin, lineUpdate } = makeConfirmAdmin({
|
|
|
|
|
|
lineById: { "spi-1": line },
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
await expect(
|
|
|
|
|
|
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
|
|
|
|
|
|
).rejects.toThrow(/missing the parent purchase id/i);
|
2026-08-03 00:29:20 +00:00
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
expect(lineUpdate).not.toHaveBeenCalled();
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-03 00:29:20 +00:00
|
|
|
|
};
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
|
|
|
|
|
|
lineById: lines,
|
|
|
|
|
|
});
|
2026-08-03 00:29:20 +00:00
|
|
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
const result = await confirmStockRows(["spi-1", "spi-2"], {
|
2026-08-03 00:29:20 +00:00
|
|
|
|
actor: { id: "u-1", name: "sj" },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 21:01:15 +00:00
|
|
|
|
expect(result).toEqual(["spi-1", "spi-2"]);
|
|
|
|
|
|
expect(getOne).toHaveBeenCalledWith("spi-1");
|
|
|
|
|
|
expect(getOne).toHaveBeenCalledWith("spi-2");
|
2026-08-03 00:29:20 +00:00
|
|
|
|
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
|
|
|
|
|
|
quantity_received: 10,
|
2026-08-06 21:01:15 +00:00
|
|
|
|
});
|
|
|
|
|
|
expect(lineUpdate).toHaveBeenCalledWith("spi-2", {
|
|
|
|
|
|
quantity_received: 4,
|
2026-08-03 00:29:20 +00:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|