From 29ee2ccd7d26f5ade0c6bf24737d58f5d4332dc9 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 11 Sep 2026 15:37:49 +0200 Subject: [PATCH] fix(purchasing): compute outstanding from delivery lines, drop the stored column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qty_outstanding was written once at ingest as qty_outstanding: item.qty and never decremented on receipt, so it permanently reported nothing delivered. Replace every read/write of it with computeOutstanding() (src/lib/po-fulfilment.ts), a pure sum over delivery_note_items that cannot drift the way a stored counter can — it stays correct even after OCR re-runs delete and recreate a note's items. Also fixes src/app/dashboard/purchasing/[poId]/page.tsx, which computed its own outstanding figure locally and clamped over-delivery to zero; it now shares computeOutstanding and shows over-delivery as negative, matching the rest of the app. Co-Authored-By: Claude Sonnet 5 --- .../delivery-notes/[id]/ocr-result/route.ts | 2 +- .../by-number/[poNumber]/items/route.ts | 23 +++++++- src/app/api/purchase-orders/ingest/route.ts | 1 - .../purchase-orders/process-batch/route.ts | 1 - src/app/dashboard/purchasing/[poId]/page.tsx | 24 ++++---- src/lib/agent/load.ts | 14 ++++- src/lib/agent/tools.ts | 4 +- src/lib/pb-types.ts | 1 - src/lib/po-fulfilment.test.ts | 49 ++++++++++++++++ src/lib/po-fulfilment.ts | 57 +++++++++++++++++++ 10 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 src/lib/po-fulfilment.test.ts create mode 100644 src/lib/po-fulfilment.ts diff --git a/src/app/api/delivery-notes/[id]/ocr-result/route.ts b/src/app/api/delivery-notes/[id]/ocr-result/route.ts index 674e961..1ccbd80 100644 --- a/src/app/api/delivery-notes/[id]/ocr-result/route.ts +++ b/src/app/api/delivery-notes/[id]/ocr-result/route.ts @@ -207,7 +207,7 @@ export async function POST( .collection("purchase_order_items") .getFullList({ filter: `purchase_order='${poId}'`, - fields: "id,description,qty_outstanding", + fields: "id,description,qty", }); } catch (err) { console.warn("[dn-ocr-result] po_items fetch failed:", err); diff --git a/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts b/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts index 6a6c7f2..29c1cca 100644 --- a/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts +++ b/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; import { findBestPoMatch } from "@/lib/po-matching"; +import { computeOutstanding } from "@/lib/po-fulfilment"; /** * GET /api/purchase-orders/by-number/[poNumber]/items @@ -16,6 +17,8 @@ import { findBestPoMatch } from "@/lib/po-matching"; * * Response: * 200 { po: { id, purchase_order_number, supplier }, items: [{id, description, qty, qty_outstanding, unit}] } + * (qty_outstanding is computed from delivery_note_items at request time — + * see src/lib/po-fulfilment.ts — not a stored field) * 400 invalid_po_number * 200 { po: null, items: [], note: "po_not_found" } when PO isn't matched * 502 pb_lookup_failed @@ -113,7 +116,9 @@ export async function GET( }); } - // 3. Fetch the line items for this PO. + // 3. Fetch the line items for this PO, plus the delivery lines matched + // against them, so qty_outstanding can be computed rather than read + // from the (removed) stored column. let items: Array<{ id: string; description: string; @@ -124,14 +129,26 @@ export async function GET( try { const rows = await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order='${po.id}'`, - fields: "id,description,qty,qty_outstanding,unit", + fields: "id,description,qty,unit", perPage: 200, }); + const deliveryLines = await pb.collection("delivery_note_items").getFullList({ + filter: `delivery_note.purchase_order='${po.id}'`, + fields: "purchase_order_item,qty_received", + perPage: 500, + }); + const outstanding = computeOutstanding( + rows.map((r) => ({ id: r.id, qty: r.qty ?? 0 })), + deliveryLines.map((d) => ({ + purchase_order_item: d.purchase_order_item ?? "", + qty_received: d.qty_received ?? 0, + })), + ); items = rows.map((r) => ({ id: r.id, description: r.description ?? "", qty: r.qty ?? null, - qty_outstanding: r.qty_outstanding ?? null, + qty_outstanding: outstanding.get(r.id)?.outstanding ?? r.qty ?? null, unit: r.unit ?? null, })); } catch (err) { diff --git a/src/app/api/purchase-orders/ingest/route.ts b/src/app/api/purchase-orders/ingest/route.ts index 4e915f5..74e8611 100644 --- a/src/app/api/purchase-orders/ingest/route.ts +++ b/src/app/api/purchase-orders/ingest/route.ts @@ -133,7 +133,6 @@ export async function POST(request: Request) { cost_code: item.cost_code ?? "", description: item.description, qty: item.qty, - qty_outstanding: item.qty, unit: item.unit ?? "", rate: item.rate, disc_percent: item.disc_percent ?? 0, diff --git a/src/app/api/purchase-orders/process-batch/route.ts b/src/app/api/purchase-orders/process-batch/route.ts index 3c058ce..e2a31f0 100644 --- a/src/app/api/purchase-orders/process-batch/route.ts +++ b/src/app/api/purchase-orders/process-batch/route.ts @@ -255,7 +255,6 @@ export async function POST(request: Request) { cost_code: item.cost_code ?? "", description: item.description, qty: item.qty, - qty_outstanding: item.qty, unit: item.unit ?? "", rate: item.rate, disc_percent: item.disc_percent ?? 0, diff --git a/src/app/dashboard/purchasing/[poId]/page.tsx b/src/app/dashboard/purchasing/[poId]/page.tsx index 3da8f32..04f01c1 100644 --- a/src/app/dashboard/purchasing/[poId]/page.tsx +++ b/src/app/dashboard/purchasing/[poId]/page.tsx @@ -5,6 +5,7 @@ import { pbAdmin } from "@/lib/pb"; import { Topbar } from "@/components/layout/sidebar"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { computeOutstanding } from "@/lib/po-fulfilment"; import { PoDetailView, type PoDetail, @@ -40,19 +41,22 @@ export default async function PurchaseOrderDetailPage({ }), ]); - // Per-item received totals - const receivedByItem = new Map(); - for (const r of received) { - const list = receivedByItem.get(r.purchase_order_item) ?? []; - list.push(r); - receivedByItem.set(r.purchase_order_item, list); - } + // Outstanding is computed from the delivery lines, never stored — see + // src/lib/po-fulfilment.ts. Over-delivery shows as negative rather than + // being clamped to zero. + const fulfilment = computeOutstanding( + items.map((it) => ({ id: it.id, qty: it.qty || 0 })), + received.map((r) => ({ + purchase_order_item: r.purchase_order_item ?? "", + qty_received: r.qty_received ?? 0, + })), + ); let totalOrdered = 0; let totalReceived = 0; const itemRows = items.map((it) => { - const recList = receivedByItem.get(it.id) ?? []; - const receivedQty = recList.reduce((s, r) => s + (r.qty_received || 0), 0); + const f = fulfilment.get(it.id); + const receivedQty = f?.delivered ?? 0; totalOrdered += it.qty || 0; totalReceived += receivedQty; return { @@ -62,7 +66,7 @@ export default async function PurchaseOrderDetailPage({ description: it.description, qtyOrdered: it.qty, qtyReceived: receivedQty, - qtyOutstanding: Math.max(0, (it.qty || 0) - receivedQty), + qtyOutstanding: f?.outstanding ?? (it.qty || 0), unit: it.unit, rate: it.rate, amount: it.amount, diff --git a/src/lib/agent/load.ts b/src/lib/agent/load.ts index 0ba7512..085b452 100644 --- a/src/lib/agent/load.ts +++ b/src/lib/agent/load.ts @@ -9,6 +9,7 @@ */ import { pbAdmin } from "@/lib/pb"; import { loadFuelPrices } from "@/lib/fuel-prices"; +import { computeOutstanding, type Fulfilment } from "@/lib/po-fulfilment"; import type { SiteRef, VehicleRef } from "./resolve"; export async function loadSiteRefs(): Promise { @@ -157,7 +158,18 @@ export async function loadPurchaseOrderByNumber(poNumber: string) { const items = await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order='${po.id}'`, }); - return { po, items }; + const deliveryLines = await pb.collection("delivery_note_items").getFullList({ + filter: `delivery_note.purchase_order='${po.id}'`, + fields: "purchase_order_item,qty_received", + }); + const fulfilment: Map = computeOutstanding( + items.map((i) => ({ id: i.id, qty: i.qty ?? 0 })), + deliveryLines.map((d) => ({ + purchase_order_item: d.purchase_order_item ?? "", + qty_received: d.qty_received ?? 0, + })), + ); + return { po, items, fulfilment }; } export async function loadPurchaseOrders(args: { diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index 5108077..7740f44 100644 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -379,7 +379,7 @@ export const tools: AgentTool[] = [ if (!found) { return { error: `No purchase order numbered "${String(args.poNumber)}".` }; } - const { po, items } = found; + const { po, items, fulfilment } = found; const refs = await loadSiteRefs(); const siteName = refs.find((s) => s.id === po.site)?.name ?? po.site ?? ""; @@ -397,7 +397,7 @@ export const tools: AgentTool[] = [ materialCode: i.material_code ?? "", description: i.description ?? "", qty: i.qty ?? 0, - qtyOutstanding: i.qty_outstanding ?? 0, + qtyOutstanding: fulfilment.get(i.id)?.outstanding ?? i.qty ?? 0, unit: i.unit ?? "", rate: i.rate ?? 0, amount: i.amount ?? 0, diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index 942655c..b1116fa 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -202,7 +202,6 @@ export interface PurchaseOrderItemRecord extends RecordModel { cost_code: string; description: string; qty: number; - qty_outstanding: number; unit: string; rate: number; disc_percent: number; diff --git a/src/lib/po-fulfilment.test.ts b/src/lib/po-fulfilment.test.ts new file mode 100644 index 0000000..68b7ac3 --- /dev/null +++ b/src/lib/po-fulfilment.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { computeOutstanding } from "./po-fulfilment"; + +describe("computeOutstanding", () => { + it("subtracts a single partial delivery", () => { + const r = computeOutstanding( + [{ id: "i1", qty: 10 }], + [{ purchase_order_item: "i1", qty_received: 4 }], + ); + expect(r.get("i1")).toEqual({ ordered: 10, delivered: 4, outstanding: 6 }); + }); + + it("sums multiple deliveries against the same line", () => { + const r = computeOutstanding( + [{ id: "i1", qty: 10 }], + [ + { purchase_order_item: "i1", qty_received: 4 }, + { purchase_order_item: "i1", qty_received: 6 }, + ], + ); + expect(r.get("i1")).toEqual({ ordered: 10, delivered: 10, outstanding: 0 }); + }); + + it("reports over-delivery rather than clamping it", () => { + const r = computeOutstanding( + [{ id: "i1", qty: 10 }], + [{ purchase_order_item: "i1", qty_received: 12 }], + ); + expect(r.get("i1")).toEqual({ + ordered: 10, delivered: 12, outstanding: -2, + }); + }); + + it("ignores unmatched delivery lines instead of guessing a home", () => { + const r = computeOutstanding( + [{ id: "i1", qty: 10 }], + [ + { purchase_order_item: "", qty_received: 5 }, + { purchase_order_item: "i1", qty_received: 4 }, + ], + ); + expect(r.get("i1")).toEqual({ ordered: 10, delivered: 4, outstanding: 6 }); + }); + + it("reports a line with no deliveries as fully outstanding", () => { + const r = computeOutstanding([{ id: "i1", qty: 10 }], []); + expect(r.get("i1")).toEqual({ ordered: 10, delivered: 0, outstanding: 10 }); + }); +}); diff --git a/src/lib/po-fulfilment.ts b/src/lib/po-fulfilment.ts new file mode 100644 index 0000000..6453c42 --- /dev/null +++ b/src/lib/po-fulfilment.ts @@ -0,0 +1,57 @@ +/** + * Outstanding quantity per purchase order line. + * + * Computed, never stored. `purchase_order_items.qty_outstanding` used to + * hold this and was written once at ingest as `qty_outstanding: item.qty` + * and never decremented — so it permanently reported that nothing had + * been delivered. + * + * A stored counter would have to be decremented correctly on every + * receipt, OCR re-run and correction, and PocketBase has no transactions + * to guarantee that. A sum over the delivery lines cannot drift: re-running + * OCR on a note deletes and recreates its items, and the total comes out + * right on its own. + * + * See spec section 5.4. + */ + +export type PoItemLike = { id: string; qty: number }; + +export type DeliveryLineLike = { + /** Empty when OCR could not match the line to a PO item. */ + purchase_order_item: string; + qty_received: number; +}; + +export type Fulfilment = { + ordered: number; + delivered: number; + /** Negative means over-delivered. Deliberately not clamped. */ + outstanding: number; +}; + +export function computeOutstanding( + items: PoItemLike[], + deliveryLines: DeliveryLineLike[], +): Map { + const delivered = new Map(); + for (const line of deliveryLines) { + const key = line.purchase_order_item; + // An unmatched line counts toward nothing. Attaching it to the + // nearest item would corrupt a line nobody touched — it belongs in + // the review queue instead. + if (!key) continue; + delivered.set(key, (delivered.get(key) ?? 0) + (line.qty_received ?? 0)); + } + + const out = new Map(); + for (const item of items) { + const got = delivered.get(item.id) ?? 0; + out.set(item.id, { + ordered: item.qty, + delivered: got, + outstanding: item.qty - got, + }); + } + return out; +}