From 92febfb51c46586b22ab12161bfa6bebb1d1b2fd Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 14:24:30 +0200 Subject: [PATCH] feat(stock-purchases): aggregate ordered/received value on list rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ordered_value and received_value to StockPurchaseListRow. Each is Σ line.{quantity|quantity_received} × line.cost_price, rounded to 2dp. Replaces countItemsForPurchase with summarizeItemsForPurchase so the per-row N+1 returns all three numbers in one fetch (no extra round trip). Co-authored-by: Orca --- .../server/stock-purchases-service.test.ts | 94 +++++++++++++++++++ src/lib/server/stock-purchases-service.ts | 57 +++++++++-- 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/lib/server/stock-purchases-service.test.ts b/src/lib/server/stock-purchases-service.test.ts index 445db7c..f7ef072 100644 --- a/src/lib/server/stock-purchases-service.test.ts +++ b/src/lib/server/stock-purchases-service.test.ts @@ -718,3 +718,97 @@ describe("receiveStockPurchase", () => { } }); }); + +// ============================================================================ +// listStockPurchases — total value aggregation +// ============================================================================ + +describe("listStockPurchases — total value", () => { + async function seedPurchaseWithItems(pb: typeof pbMock, id: string, items: Array<{ + quantity: number; + quantity_received: number; + cost_price: number; + }>) { + await pb.collection("stock_purchases").create({ + id, + purchase_number: `PO26000100STK-${id}`, + supplier: "sup-1", + status: "draft", + notes: "", + email_error: "", + email_message_id: "", + }); + for (const item of items) { + await pb.collection("stock_purchase_items").create({ + purchase: id, + stock_item: "si-1", + description: "Widget", + ...item, + notes: "", + }); + } + } + + it("returns ordered_value = Σ quantity × cost_price across lines", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 10, quantity_received: 0, cost_price: 12.5 }, + { quantity: 4, quantity_received: 0, cost_price: 99 }, + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.ordered_value).toBe(521); // 125 + 396 + }); + + it("returns received_value = Σ quantity_received × cost_price across lines", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 10, quantity_received: 6, cost_price: 12.5 }, + { quantity: 4, quantity_received: 4, cost_price: 99 }, + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.received_value).toBe(471); // 75 + 396 + }); + + it("rounds ordered_value and received_value to 2dp", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 3, quantity_received: 1, cost_price: 0.1 + 0.2 }, // 0.30000000000000004 + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.ordered_value).toBe(0.9); + expect(row.received_value).toBe(0.3); + }); + + it("returns { ordered_value: 0, received_value: 0 } when a purchase has no lines", async () => { + await pbMock.collection("stock_purchases").create({ + id: "sp-empty", + purchase_number: "PO26000200STK", + supplier: "sup-1", + status: "draft", + notes: "", + email_error: "", + email_message_id: "", + }); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-empty")!; + expect(row.ordered_value).toBe(0); + expect(row.received_value).toBe(0); + expect(row.item_count).toBe(0); + }); + + it("computes each row's totals from its own items only (no cross-row leakage)", async () => { + await seedPurchaseWithItems(pbMock, "sp-x", [ + { quantity: 1, quantity_received: 1, cost_price: 100 }, + ]); + await seedPurchaseWithItems(pbMock, "sp-y", [ + { quantity: 2, quantity_received: 2, cost_price: 50 }, + ]); + const { items } = await listStockPurchases(pbMock); + const x = items.find((r) => r.id === "sp-x")!; + const y = items.find((r) => r.id === "sp-y")!; + expect(x.ordered_value).toBe(100); + expect(x.received_value).toBe(100); + expect(y.ordered_value).toBe(100); + expect(y.received_value).toBe(100); + }); +}); diff --git a/src/lib/server/stock-purchases-service.ts b/src/lib/server/stock-purchases-service.ts index 6500564..ce110e4 100644 --- a/src/lib/server/stock-purchases-service.ts +++ b/src/lib/server/stock-purchases-service.ts @@ -161,6 +161,19 @@ export interface ListStockPurchasesOpts { export interface StockPurchaseListRow extends StockPurchase { item_count: number; + /** + * Sum of `line.quantity * line.cost_price` across every line on + * this purchase. Reflects the value committed to the supplier — + * visible from draft stage onwards. Rounded to 2dp (matches + * `calculateTotals` in `pb-helpers.ts`). + */ + ordered_value: number; + /** + * Sum of `line.quantity_received * line.cost_price`. Zero for + * drafts; grows as goods are booked into stock via collection-confirm. + * Lets the operator spot short-shipped POs at a glance. + */ + received_value: number; } /** @@ -189,8 +202,13 @@ export async function listStockPurchases( // `line_count` on the header. const items: StockPurchaseListRow[] = []; for (const row of result.items) { - const lineCount = await countItemsForPurchase(pb, String(row.id)); - items.push({ ...(row as unknown as StockPurchase), item_count: lineCount }); + const summary = await summarizeItemsForPurchase(pb, String(row.id)); + items.push({ + ...(row as unknown as StockPurchase), + item_count: summary.count, + ordered_value: summary.ordered, + received_value: summary.received, + }); } return { @@ -202,14 +220,39 @@ export async function listStockPurchases( }; } -async function countItemsForPurchase( +/** + * Fetch all `stock_purchase_items` rows for a single purchase and + * return the line count plus the two value aggregates the list + * table needs. Pulls `quantity`, `quantity_received`, and + * `cost_price` in one round-trip; replacing the older id-only + * helper so the cost is the same. + */ +async function summarizeItemsForPurchase( pb: StockPurchasesPb, purchaseId: string -): Promise { - const items = await pb +): Promise<{ count: number; ordered: number; received: number }> { + type ItemShape = { + quantity?: number; + quantity_received?: number; + cost_price?: number; + }; + const items = (await pb .collection(STOCK_PURCHASE_ITEMS) - .getFullList({ filter: `purchase = "${purchaseId}"` }); - return items.length; + .getFullList({ filter: `purchase = "${purchaseId}"` })) as unknown as ItemShape[]; + let ordered = 0; + let received = 0; + for (const item of items) { + const qty = Number(item.quantity ?? 0); + const recv = Number(item.quantity_received ?? 0); + const cost = Number(item.cost_price ?? 0); + ordered += qty * cost; + received += recv * cost; + } + return { + count: items.length, + ordered: Number(ordered.toFixed(2)), + received: Number(received.toFixed(2)), + }; } /**