feat(collection-confirm): implement updateOrderItemCost service

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 00:23:37 +02:00
parent 8b6b83583d
commit 2997b8dcba
2 changed files with 169 additions and 8 deletions

View file

@ -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<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). `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(

View file

@ -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<ConfirmRow[]> {
* 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<ConfirmRow> {
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;
}
/**