feat(stock-purchases): aggregate ordered/received value on list rows

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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 14:24:30 +02:00
parent 94931524e9
commit 92febfb51c
2 changed files with 144 additions and 7 deletions

View file

@ -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);
});
});

View file

@ -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<number> {
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)),
};
}
/**