feat(collection-confirm): implement confirmOrderItemsForInvoicing with createBatch

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 00:40:29 +02:00
parent 77d2c32df4
commit 47eff9a6fe
2 changed files with 230 additions and 23 deletions

View file

@ -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<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(
["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<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"]);
});
});

View file

@ -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<string, number>;
actor: ConfirmActor;
},
): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> {
void ids;
void body;
throw new Error("not implemented");
): Promise<string[]> {
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<typeof buildConfirmPatch>[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);
}