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:
parent
94931524e9
commit
92febfb51c
2 changed files with 144 additions and 7 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue