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:
parent
aa7db0d801
commit
91f7bf860b
3 changed files with 2503 additions and 0 deletions
2311
docs/superpowers/plans/2026-07-29-orders-review-modal.md
Normal file
2311
docs/superpowers/plans/2026-07-29-orders-review-modal.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue