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:
parent
f007b5e8fb
commit
29ee2ccd7d
10 changed files with 156 additions and 20 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
src/lib/po-fulfilment.test.ts
Normal file
49
src/lib/po-fulfilment.test.ts
Normal 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
57
src/lib/po-fulfilment.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue