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:
parent
77d2c32df4
commit
47eff9a6fe
2 changed files with 230 additions and 23 deletions
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue