feat(collection-confirm): implement updateOrderItemCost service
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
8b6b83583d
commit
2997b8dcba
2 changed files with 169 additions and 8 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue