fix(orders): review-marker review fixes (collection helper, empty patch, markup_percent)

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

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 14:32:41 +02:00
parent 91f7bf860b
commit cfc974c080
2 changed files with 120 additions and 9 deletions

View file

@ -294,4 +294,104 @@ describe("updateOrderItemsPrices", () => {
expect(result.updated).toBe(0);
expect(result.failed).toEqual([]);
});
it("skips pb.update when the resolved patch is empty (no field changes)", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-noop",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-does",
order: order.id,
description: "Bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
// Spy on update to confirm the no-op row doesn't trigger a write.
// `pbMock.collection(name)` returns a fresh object each call, so we
// wrap the `collection` factory itself and intercept at every call.
const originalCollection = pbMock.collection;
const updateCalls: Array<{ id: string; data: Record<string, unknown> }> = [];
pbMock.collection = (name: string) => {
const col = originalCollection(name);
if (name === "order_items") {
const originalUpdate = col.update;
col.update = vi.fn(async (id: string, data: Record<string, unknown>) => {
updateCalls.push({ id, data });
return originalUpdate(id, data);
});
}
return col;
};
try {
const result = await updateOrderItemsPrices(order.id, [
// No field changes — caller's patch has no quantity/price/markup.
{ id: "item-noop" },
// Real change — should issue an update.
{ id: "item-does", quantity: 7 },
]);
// Only the row with an actual change counts as updated.
expect(result.updated).toBe(1);
expect(result.failed).toEqual([]);
// The no-op row did NOT receive a pb.update call.
const noopCalls = updateCalls.filter((c) => c.id === "item-noop");
expect(noopCalls).toEqual([]);
// The real row did.
const doesCalls = updateCalls.filter((c) => c.id === "item-does");
expect(doesCalls).toHaveLength(1);
expect(doesCalls[0].data).toEqual({ quantity: 7 });
// The row's quantity was actually written.
const item = await pbMock.collection("order_items").getOne("item-does");
expect(item.quantity).toBe(7);
} finally {
pbMock.collection = originalCollection;
}
});
it("writes markup_percent alongside markup when selling_price is supplied", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-mp",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
// Start with stale markup_percent to confirm it gets overwritten.
markup: 100,
markup_percent: 0,
});
const result = await updateOrderItemsPrices(order.id, [
// selling_price 60, cost_price unchanged at 25 → markup = (60-25)/25*100 = 140
{ id: "item-mp", selling_price: 60 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([]);
const item = await pbMock.collection("order_items").getOne("item-mp");
expect(item.selling_price).toBe(60);
expect(item.markup).toBe(140);
// markup_percent must mirror markup — the two fields stay in lock-step.
expect(item.markup_percent).toBe(140);
});
});

View file

@ -1,7 +1,6 @@
import { getAdminPocketBase } from "./pocketbase-admin";
const ORDER_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
const QUOTE_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
const ORDER_COUNTERS_COLLECTION = "order_counters";
@ -363,6 +362,7 @@ export async function updateOrderItemsPrices(
failed: Array<{ id: string; error: string }>;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (items.length === 0) return { updated: 0, failed: [] };
@ -373,7 +373,7 @@ export async function updateOrderItemsPrices(
try {
// Read the current row so we can compute markup when only
// one of selling_price/cost_price is supplied.
const current = await pb.collection(ORDER_ITEMS_COLLECTION).getOne(item.id);
const current = await pb.collection(orderItemsCollection).getOne(item.id);
const patch: Record<string, unknown> = {};
if (item.quantity !== undefined) patch.quantity = item.quantity;
@ -386,23 +386,34 @@ export async function updateOrderItemsPrices(
// - cost_price supplied, selling_price not → recompute from current-selling/cost
// - both supplied → recompute from the new pair
// - neither supplied, no explicit markup → leave current markup alone
//
// `markup_percent` mirrors `markup` (the two fields are kept in
// lock-step by `createOrderFromQuote`); write both together so
// the row never goes stale after a price edit.
if (item.markup !== undefined) {
patch.markup = item.markup;
patch.markup_percent = item.markup;
} else if (
item.selling_price !== undefined ||
item.cost_price !== undefined
) {
const newSelling = item.selling_price ?? current.selling_price;
const newCost = item.cost_price ?? current.cost_price;
if (Number(newCost) > 0) {
patch.markup =
Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100;
} else {
patch.markup = 0;
}
const newMarkup =
Number(newCost) > 0
? Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100
: 0;
patch.markup = newMarkup;
patch.markup_percent = newMarkup;
}
await pb.collection(ORDER_ITEMS_COLLECTION).update(item.id, patch);
// Empty patch: nothing to write. Skip the PB round-trip so we
// don't stamp `updated` and don't bump the row's `updated` ts.
if (Object.keys(patch).length === 0) {
continue;
}
await pb.collection(orderItemsCollection).update(item.id, patch);
updated.push(item.id);
} catch (err) {
failed.push({