fix(purchasing): compute outstanding from delivery lines, drop the stored column

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 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-11 15:37:49 +02:00
parent f007b5e8fb
commit 29ee2ccd7d
10 changed files with 156 additions and 20 deletions

View file

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

View file

@ -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) {

View file

@ -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,

View file

@ -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,

View file

@ -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<string, typeof received>();
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,

View file

@ -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<SiteRef[]> {
@ -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<string, Fulfilment> = 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: {

View file

@ -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,

View file

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

View file

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

57
src/lib/po-fulfilment.ts Normal file
View file

@ -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<string, Fulfilment> {
const delivered = new Map<string, number>();
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<string, Fulfilment>();
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;
}