feat(orders): add updateOrderItemsPrices with batch + per-row failure

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:15:35 +02:00
parent aa7db0d801
commit 91f7bf860b
3 changed files with 2503 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -93,6 +93,7 @@ vi.mock("./pocketbase-admin", () => ({
import {
OrderNumberCollisionError,
updateOrderItemsPrices,
updateOrderReviewFields,
} from "./orders-service";
@ -188,3 +189,109 @@ describe("updateOrderReviewFields", () => {
expect(result.customer).toBe("cust-1");
});
});
describe("updateOrderItemsPrices", () => {
it("updates quantity, selling_price, and cost_price for each item", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-2",
order: order.id,
description: "Bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-1", quantity: 5, selling_price: 60 },
{ id: "item-2", quantity: 3 },
]);
expect(result.updated).toBe(2);
expect(result.failed).toEqual([]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.quantity).toBe(5);
expect(item1.selling_price).toBe(60);
// markup should have been recomputed from cost/selling: (60-25)/25*100 = 140
expect(item1.markup).toBe(140);
const item2 = await pbMock.collection("order_items").getOne("item-2");
expect(item2.quantity).toBe(3);
expect(item2.selling_price).toBe(10); // unchanged
});
it("passes markup through when explicitly supplied", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await updateOrderItemsPrices(order.id, [
{ id: "item-1", markup: 75 },
]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.markup).toBe(75);
});
it("returns partial failure when one item update throws", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-good",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
// item-missing does NOT exist in the store.
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-good", quantity: 5 },
{ id: "item-missing", quantity: 1 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([
{ id: "item-missing", error: expect.stringMatching(/not found/) },
]);
const good = await pbMock.collection("order_items").getOne("item-good");
expect(good.quantity).toBe(5);
});
it("returns zero updated for empty array", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
const result = await updateOrderItemsPrices(order.id, []);
expect(result.updated).toBe(0);
expect(result.failed).toEqual([]);
});
});

View file

@ -1,6 +1,7 @@
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";
@ -334,3 +335,87 @@ export async function updateOrderReviewFields(
return pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch);
}
/**
* Patches price/qty fields on the order's line items in a non-
* transactional batch. Each item is PATCHed individually; per-row
* failures are accumulated into `failed` rather than aborting the
* batch, so the operator sees which lines couldn't save and can
* retry just those.
*
* Markup computation: if the caller supplies `selling_price` OR
* `cost_price` (but not both) and we know the other from the
* current row, we recompute `markup` from the supplied + known
* values. If the caller supplies `markup` explicitly, we pass it
* through verbatim.
*/
export async function updateOrderItemsPrices(
orderId: string,
items: Array<{
id: string;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
markup?: number | null;
}>,
): Promise<{
updated: number;
failed: Array<{ id: string; error: string }>;
}> {
const pb = await getAdminPocketBase();
if (items.length === 0) return { updated: 0, failed: [] };
const updated: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
for (const item of items) {
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 patch: Record<string, unknown> = {};
if (item.quantity !== undefined) patch.quantity = item.quantity;
if (item.selling_price !== undefined) patch.selling_price = item.selling_price;
if (item.cost_price !== undefined) patch.cost_price = item.cost_price;
// Markup computation:
// - explicit markup in patch → pass through
// - selling_price supplied, cost_price not → recompute from selling/current-cost
// - 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
if (item.markup !== undefined) {
patch.markup = 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;
}
}
await pb.collection(ORDER_ITEMS_COLLECTION).update(item.id, patch);
updated.push(item.id);
} catch (err) {
failed.push({
id: item.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
// Surface unused param so tsc doesn't complain if `orderId` is
// never referenced in the future; today it's documentation of the
// contract (caller must scope the items to a single order).
void orderId;
return { updated: updated.length, failed };
}