From 47eff9a6fe7eda05c981cc7d1979f98bf1865e92 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 00:40:29 +0200 Subject: [PATCH] feat(collection-confirm): implement confirmOrderItemsForInvoicing with createBatch Co-Authored-By: Claude Co-authored-by: Orca --- .../server/collection-confirm-service.test.ts | 182 ++++++++++++++++-- src/lib/server/collection-confirm-service.ts | 71 +++++-- 2 files changed, 230 insertions(+), 23 deletions(-) diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts index c43580b..f1148ac 100644 --- a/src/lib/server/collection-confirm-service.test.ts +++ b/src/lib/server/collection-confirm-service.test.ts @@ -434,17 +434,177 @@ describe("updateOrderItemCost", () => { }); }); -describe("stubs for plan task 5", () => { - it("confirmOrderItemsForInvoicing throws 'not implemented'", async () => { +describe("confirmOrderItemsForInvoicing", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const makeAdminForConfirm = (opts: { + rows: Record[]; + send?: ReturnType; + }) => { + 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, + ]>) { + 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>; + 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; + }; + 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; + 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>; + 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( - ["oi-1"], - { - invoice: { date: "2026-08-01", number: "INV-1" }, - costOverrides: {}, - actor: { id: "u1", name: "U" }, - }, - ), - ).rejects.toThrow(/not implemented/i); + 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; + expect(patch).not.toHaveProperty("cost_price"); + const notes = JSON.parse(patch.notes as string) as Array>; + expect(notes.map((entry) => entry.category)).toEqual(["confirm"]); }); }); diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index c5fda16..56a7ece 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -2,6 +2,7 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; import { appendCostNote, + buildConfirmPatch, type ConfirmActor, } from "@/app/(app)/collection-confirm/view-model"; @@ -87,9 +88,8 @@ export type ConfirmRow = { * Mirrors the union query the page's `load()` performs client-side, but * runs through the admin PocketBase client so it can be exposed via * `/api/v1/collection-confirm` once the page is migrated. The write - * helpers (`updateOrderItemCost`, `confirmOrderItemsForInvoicing`) come - * in follow-up plan tasks — they are stubbed here so the file shape is - * committed and downstream tasks can build on top. + * helpers (`updateOrderItemCost`, `confirmOrderItemsForInvoicing`) use the + * same admin client and preserve the collection-confirm audit trail. */ /** @@ -250,21 +250,68 @@ export async function updateOrderItemCost( } /** - * Flip a set of order_items into `ready_invoice` (or whichever next - * status the view-model picks) and stamp the supplier-invoice - * details into `order_item_details` so invoicing can pick the rows up. + * Confirm collected order_items for invoicing in one transactional batch. * - * Stubbed — implementation lands in plan task 5. + * The rows are read first so each cost override can be compared with the + * current value and its audit note can be built from the correct previous + * cost. The SDK batch queues all updates and sends them together; if the + * batch request fails, PocketBase does not apply any individual update. */ export async function confirmOrderItemsForInvoicing( ids: string[], - body: { + args: { invoice: { date: string; number: string }; costOverrides: Record; actor: ConfirmActor; }, -): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> { - void ids; - void body; - throw new Error("not implemented"); +): Promise { + if (ids.length === 0) return []; + + const pb = await getAdminPocketBase(); + const collectionName = getOrderItemsCollectionName(); + const rows = await pb.collection(collectionName).getList(1, ids.length, { + filter: ids.map((id) => `id="${id}"`).join(" || "), + }); + + const batch = pb.createBatch(); + const batchCollection = batch.collection(collectionName); + + for (const row of rows.items) { + const currentCost = (row as { cost_price?: number | null }).cost_price ?? null; + const override = args.costOverrides[row.id]; + const costOverride = + typeof override === "number" && override !== currentCost + ? override + : undefined; + + // Build the standard confirmation patch first. Cost changes are appended + // below so the resulting notes feed contains both confirm and cost audit + // entries in that order. + const confirmPatch = buildConfirmPatch( + row as Parameters[0], + { actor: args.actor, invoice: args.invoice }, + ); + let notes = confirmPatch.notes; + if (typeof costOverride === "number") { + notes = appendCostNote( + confirmPatch.notes, + args.actor, + currentCost ?? 0, + costOverride, + ); + } + + const patch = { + item_status: confirmPatch.item_status, + notes, + order_item_details: confirmPatch.order_item_details, + ...(typeof costOverride === "number" + ? { cost_price: costOverride } + : {}), + }; + batchCollection.update(row.id, patch); + } + + await batch.send(); + return rows.items.map((row) => row.id); }