From 2997b8dcbabe73060e6907ace35154d8d0f4ea0f Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 00:23:37 +0200 Subject: [PATCH] feat(collection-confirm): implement updateOrderItemCost service Co-authored-by: Orca --- .../server/collection-confirm-service.test.ts | 139 +++++++++++++++++- src/lib/server/collection-confirm-service.ts | 38 ++++- 2 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts index 39ebf55..1691168 100644 --- a/src/lib/server/collection-confirm-service.test.ts +++ b/src/lib/server/collection-confirm-service.test.ts @@ -279,13 +279,142 @@ describe("listCollectedItems", () => { }); }); -describe("stubs for plan tasks 4 and 5", () => { - it("updateOrderItemCost throws 'not implemented'", async () => { - await expect( - updateOrderItemCost("oi-1", { cost_price: 10, actor: { id: "u1", name: "U" } }), - ).rejects.toThrow(/not implemented/i); +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; + updated?: Record; + }) => { + const getOne = vi.fn().mockResolvedValue(opts.existing); + const update = vi.fn().mockImplementation( + (_id: string, patch: Record) => + 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). `ConfirmRow` is the consumer- + // facing shape and omits `cost_price` (the raw PB record has it), + // so cast to read the field the test is asserting on. + expect(result.id).toBe("row-1"); + expect((result as { cost_price?: number }).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 }); + }); +}); + +describe("stubs for plan task 5", () => { it("confirmOrderItemsForInvoicing throws 'not implemented'", async () => { await expect( confirmOrderItemsForInvoicing( diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index 3709f7e..4c3f22b 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -1,6 +1,9 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; -import type { ConfirmActor } from "@/app/(app)/collection-confirm/view-model"; +import { + appendCostNote, + type ConfirmActor, +} from "@/app/(app)/collection-confirm/view-model"; /** * Where the row came from. The collection-confirm / collection-pool @@ -207,13 +210,42 @@ export async function listCollectedItems(): Promise { * Update the cost price on a single collected order_item and stamp a * `cost`-category note onto the row so the audit trail is preserved. * - * Stubbed — implementation lands in plan task 4. + * No-op when the new cost equals the existing cost — `prevCost === newCost` + * returns the existing row unchanged (no write, no note append). For a + * genuine change, the patch writes both the new `cost_price` and the + * newly-appended notes feed (preserving any prior entries) in a single + * round-trip. + * + * `prevCost` is read from the existing row's `cost_price`. When the row + * has no prior cost (null), `appendCostNote` receives `0` as the + * previous cost — the early-return check still treats `null → 0` as a + * change (since `null === 0` is `false`), and the note records the + * transition as `prevCost: 0`. For `null → X > 0` the note correctly + * captures the move; for `null → 0` the `appendCostNote` internal + * short-circuit returns the existing notes unchanged, so the only + * side-effect is the `cost_price: 0` write. */ export async function updateOrderItemCost( id: string, body: { cost_price: number; actor: ConfirmActor }, ): Promise { - throw new Error("not implemented"); + const pb = await getAdminPocketBase(); + const collectionName = getOrderItemsCollectionName(); + const existing = await pb.collection(collectionName).getOne(id); + const prevCost = (existing as { cost_price?: number | null }).cost_price ?? null; + if (prevCost === body.cost_price) { + return existing as ConfirmRow; + } + const newNotes = appendCostNote( + (existing as { notes?: string | null }).notes ?? null, + body.actor, + prevCost ?? 0, + body.cost_price, + ); + const updated = await pb + .collection(collectionName) + .update(id, { cost_price: body.cost_price, notes: newNotes }); + return updated as ConfirmRow; } /**