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")
|
.collection("purchase_order_items")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
filter: `purchase_order='${poId}'`,
|
filter: `purchase_order='${poId}'`,
|
||||||
fields: "id,description,qty_outstanding",
|
fields: "id,description,qty",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[dn-ocr-result] po_items fetch failed:", err);
|
console.warn("[dn-ocr-result] po_items fetch failed:", err);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { pbAdmin } from "@/lib/pb";
|
import { pbAdmin } from "@/lib/pb";
|
||||||
import { findBestPoMatch } from "@/lib/po-matching";
|
import { findBestPoMatch } from "@/lib/po-matching";
|
||||||
|
import { computeOutstanding } from "@/lib/po-fulfilment";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/purchase-orders/by-number/[poNumber]/items
|
* GET /api/purchase-orders/by-number/[poNumber]/items
|
||||||
|
|
@ -16,6 +17,8 @@ import { findBestPoMatch } from "@/lib/po-matching";
|
||||||
*
|
*
|
||||||
* Response:
|
* Response:
|
||||||
* 200 { po: { id, purchase_order_number, supplier }, items: [{id, description, qty, qty_outstanding, unit}] }
|
* 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
|
* 400 invalid_po_number
|
||||||
* 200 { po: null, items: [], note: "po_not_found" } when PO isn't matched
|
* 200 { po: null, items: [], note: "po_not_found" } when PO isn't matched
|
||||||
* 502 pb_lookup_failed
|
* 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<{
|
let items: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -124,14 +129,26 @@ export async function GET(
|
||||||
try {
|
try {
|
||||||
const rows = await pb.collection("purchase_order_items").getFullList({
|
const rows = await pb.collection("purchase_order_items").getFullList({
|
||||||
filter: `purchase_order='${po.id}'`,
|
filter: `purchase_order='${po.id}'`,
|
||||||
fields: "id,description,qty,qty_outstanding,unit",
|
fields: "id,description,qty,unit",
|
||||||
perPage: 200,
|
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) => ({
|
items = rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
description: r.description ?? "",
|
description: r.description ?? "",
|
||||||
qty: r.qty ?? null,
|
qty: r.qty ?? null,
|
||||||
qty_outstanding: r.qty_outstanding ?? null,
|
qty_outstanding: outstanding.get(r.id)?.outstanding ?? r.qty ?? null,
|
||||||
unit: r.unit ?? null,
|
unit: r.unit ?? null,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ export async function POST(request: Request) {
|
||||||
cost_code: item.cost_code ?? "",
|
cost_code: item.cost_code ?? "",
|
||||||
description: item.description,
|
description: item.description,
|
||||||
qty: item.qty,
|
qty: item.qty,
|
||||||
qty_outstanding: item.qty,
|
|
||||||
unit: item.unit ?? "",
|
unit: item.unit ?? "",
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
disc_percent: item.disc_percent ?? 0,
|
disc_percent: item.disc_percent ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,6 @@ export async function POST(request: Request) {
|
||||||
cost_code: item.cost_code ?? "",
|
cost_code: item.cost_code ?? "",
|
||||||
description: item.description,
|
description: item.description,
|
||||||
qty: item.qty,
|
qty: item.qty,
|
||||||
qty_outstanding: item.qty,
|
|
||||||
unit: item.unit ?? "",
|
unit: item.unit ?? "",
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
disc_percent: item.disc_percent ?? 0,
|
disc_percent: item.disc_percent ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { pbAdmin } from "@/lib/pb";
|
||||||
import { Topbar } from "@/components/layout/sidebar";
|
import { Topbar } from "@/components/layout/sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { computeOutstanding } from "@/lib/po-fulfilment";
|
||||||
import {
|
import {
|
||||||
PoDetailView,
|
PoDetailView,
|
||||||
type PoDetail,
|
type PoDetail,
|
||||||
|
|
@ -40,19 +41,22 @@ export default async function PurchaseOrderDetailPage({
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Per-item received totals
|
// Outstanding is computed from the delivery lines, never stored — see
|
||||||
const receivedByItem = new Map<string, typeof received>();
|
// src/lib/po-fulfilment.ts. Over-delivery shows as negative rather than
|
||||||
for (const r of received) {
|
// being clamped to zero.
|
||||||
const list = receivedByItem.get(r.purchase_order_item) ?? [];
|
const fulfilment = computeOutstanding(
|
||||||
list.push(r);
|
items.map((it) => ({ id: it.id, qty: it.qty || 0 })),
|
||||||
receivedByItem.set(r.purchase_order_item, list);
|
received.map((r) => ({
|
||||||
}
|
purchase_order_item: r.purchase_order_item ?? "",
|
||||||
|
qty_received: r.qty_received ?? 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
let totalOrdered = 0;
|
let totalOrdered = 0;
|
||||||
let totalReceived = 0;
|
let totalReceived = 0;
|
||||||
const itemRows = items.map((it) => {
|
const itemRows = items.map((it) => {
|
||||||
const recList = receivedByItem.get(it.id) ?? [];
|
const f = fulfilment.get(it.id);
|
||||||
const receivedQty = recList.reduce((s, r) => s + (r.qty_received || 0), 0);
|
const receivedQty = f?.delivered ?? 0;
|
||||||
totalOrdered += it.qty || 0;
|
totalOrdered += it.qty || 0;
|
||||||
totalReceived += receivedQty;
|
totalReceived += receivedQty;
|
||||||
return {
|
return {
|
||||||
|
|
@ -62,7 +66,7 @@ export default async function PurchaseOrderDetailPage({
|
||||||
description: it.description,
|
description: it.description,
|
||||||
qtyOrdered: it.qty,
|
qtyOrdered: it.qty,
|
||||||
qtyReceived: receivedQty,
|
qtyReceived: receivedQty,
|
||||||
qtyOutstanding: Math.max(0, (it.qty || 0) - receivedQty),
|
qtyOutstanding: f?.outstanding ?? (it.qty || 0),
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
rate: it.rate,
|
rate: it.rate,
|
||||||
amount: it.amount,
|
amount: it.amount,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
*/
|
*/
|
||||||
import { pbAdmin } from "@/lib/pb";
|
import { pbAdmin } from "@/lib/pb";
|
||||||
import { loadFuelPrices } from "@/lib/fuel-prices";
|
import { loadFuelPrices } from "@/lib/fuel-prices";
|
||||||
|
import { computeOutstanding, type Fulfilment } from "@/lib/po-fulfilment";
|
||||||
import type { SiteRef, VehicleRef } from "./resolve";
|
import type { SiteRef, VehicleRef } from "./resolve";
|
||||||
|
|
||||||
export async function loadSiteRefs(): Promise<SiteRef[]> {
|
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({
|
const items = await pb.collection("purchase_order_items").getFullList({
|
||||||
filter: `purchase_order='${po.id}'`,
|
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: {
|
export async function loadPurchaseOrders(args: {
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,7 @@ export const tools: AgentTool[] = [
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return { error: `No purchase order numbered "${String(args.poNumber)}".` };
|
return { error: `No purchase order numbered "${String(args.poNumber)}".` };
|
||||||
}
|
}
|
||||||
const { po, items } = found;
|
const { po, items, fulfilment } = found;
|
||||||
const refs = await loadSiteRefs();
|
const refs = await loadSiteRefs();
|
||||||
const siteName = refs.find((s) => s.id === po.site)?.name ?? po.site ?? "";
|
const siteName = refs.find((s) => s.id === po.site)?.name ?? po.site ?? "";
|
||||||
|
|
||||||
|
|
@ -397,7 +397,7 @@ export const tools: AgentTool[] = [
|
||||||
materialCode: i.material_code ?? "",
|
materialCode: i.material_code ?? "",
|
||||||
description: i.description ?? "",
|
description: i.description ?? "",
|
||||||
qty: i.qty ?? 0,
|
qty: i.qty ?? 0,
|
||||||
qtyOutstanding: i.qty_outstanding ?? 0,
|
qtyOutstanding: fulfilment.get(i.id)?.outstanding ?? i.qty ?? 0,
|
||||||
unit: i.unit ?? "",
|
unit: i.unit ?? "",
|
||||||
rate: i.rate ?? 0,
|
rate: i.rate ?? 0,
|
||||||
amount: i.amount ?? 0,
|
amount: i.amount ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,6 @@ export interface PurchaseOrderItemRecord extends RecordModel {
|
||||||
cost_code: string;
|
cost_code: string;
|
||||||
description: string;
|
description: string;
|
||||||
qty: number;
|
qty: number;
|
||||||
qty_outstanding: number;
|
|
||||||
unit: string;
|
unit: string;
|
||||||
rate: number;
|
rate: number;
|
||||||
disc_percent: 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