feat(purchase-orders): check a PO's site against its line cost codes
A PO's site was whatever the uploader or email picked, and process-batch
never re-checked it. Every MEGA PO line starts its cost code with the
project code ("C26/09, MC-SEW" is The Hills), which agreed with the filed
site on every live PO whose code is a site in the app.
After OCR, process-batch now reads those codes:
- all lines name the filed site -> confirmed
- all lines name one other site, nothing booked yet -> PO moves there and
waiting deliveries are queued at the new site
- mixed projects, an unknown code, or receipts already booked -> flagged
in raw_ocr_data.site_check, filed site kept
The check is never fatal: if the site list can't be read the PO is still
filed on the uploaded site.
Nellmapius Ext 25's code was corrected from 001 to C26/27 in the data;
the intake runbook is updated to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c0b3f5fd13
commit
4a2e39be86
5 changed files with 226 additions and 9 deletions
|
|
@ -169,7 +169,15 @@ nodes and set the mailbox on each. No rebuild is needed — the workflow logic i
|
|||
|
||||
This is the one design decision worth understanding.
|
||||
|
||||
MEGA's finalisation step deliberately **never re-maps the site after OCR** —
|
||||
**Update 2026-11-02:** after OCR, `process-batch` now checks the site against the PO's own line
|
||||
cost codes (`src/lib/purchase-order-site.ts`). Every line starts with the project code
|
||||
(`C26/09, MC-SEW` is The Hills). If every line names one other site in the app and nothing is
|
||||
booked against the PO, it is moved there; lines naming two projects, or a code that is no site in
|
||||
the app, are recorded as `raw_ocr_data.site_check.result = "mismatch"` and the filed site stays.
|
||||
OCR's header project reference is still never used. Nellmapius Ext 25's code was corrected from
|
||||
`001` to `C26/27` (the code on its POs) the same day. The history below is kept for context.
|
||||
|
||||
Before that, MEGA's finalisation step deliberately **never re-mapped the site after OCR** —
|
||||
`src/app/api/purchase-orders/process-batch/route.ts` states it outright: *"`site` was set at
|
||||
upload by the person who uploaded the … and is NEVER touched here"*, because OCR only matches a
|
||||
real project code on roughly 8% of documents. The old
|
||||
|
|
@ -180,7 +188,7 @@ real project code on roughly 8% of documents. The old
|
|||
So the site has to be known when the row is created, and the workflow reads it out of the email
|
||||
itself, in this order:
|
||||
|
||||
1. exact project code — `C26/09`, `C26/13`, `C26/14`, `001`, `HEAD`
|
||||
1. exact project code — `C26/09`, `C26/13`, `C26/14`, `C26/27`, `HEAD`
|
||||
2. project code appearing anywhere in subject + body preview + sender
|
||||
3. site name — *The Hills*, *Cedar Gate*, *Munyaka 2*, *Nellmapius Ext 25*, *Mega Head Office*
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const createMock = vi.fn();
|
|||
const updateMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const supplierCreateMock = vi.fn();
|
||||
const sitesListMock = vi.fn();
|
||||
const deliveryNotesListMock = vi.fn();
|
||||
|
||||
const fakePb = {
|
||||
authStore: { token: "test-admin-token" },
|
||||
|
|
@ -26,6 +28,8 @@ const fakePb = {
|
|||
if (name === "suppliers") {
|
||||
return { getFullList: getFullListMock, create: supplierCreateMock };
|
||||
}
|
||||
if (name === "sites") return { getFullList: sitesListMock };
|
||||
if (name === "delivery_notes") return { getList: deliveryNotesListMock };
|
||||
throw new Error(`unexpected collection in test: ${name}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -111,6 +115,74 @@ describe("POST /api/purchase-orders/process-batch", () => {
|
|||
deleteMock.mockReset();
|
||||
supplierCreateMock.mockReset();
|
||||
supplierCreateMock.mockResolvedValue({ id: "sup0000000001" });
|
||||
sitesListMock.mockReset();
|
||||
sitesListMock.mockResolvedValue([]);
|
||||
deliveryNotesListMock.mockReset();
|
||||
deliveryNotesListMock.mockResolvedValue({ totalItems: 0, items: [] });
|
||||
});
|
||||
|
||||
describe("site from the PO's own cost codes", () => {
|
||||
const SITES = [
|
||||
{ id: "hills0000000001", project_code: "C26/09", name: "The Hills" },
|
||||
{ id: "cedar0000000001", project_code: "C26/13", name: "Cedar Gate" },
|
||||
];
|
||||
const coded = (...codes: string[]) => validExtraction("MEGCPO9482", {
|
||||
items: codes.map((cost_code, i) => ({ cost_code, description: `Line ${i}`, qty: 1, rate: 10 })),
|
||||
});
|
||||
beforeEach(() => {
|
||||
getFullListMock.mockResolvedValue([]);
|
||||
sitesListMock.mockResolvedValue(SITES);
|
||||
});
|
||||
|
||||
it("moves the PO to the site every line's cost code names, and schedules deliveries there", async () => {
|
||||
const previous = process.env.DN_STAGED_INGESTION_ENABLED;
|
||||
process.env.DN_STAGED_INGESTION_ENABLED = "true";
|
||||
try {
|
||||
getOneMock.mockResolvedValue({ id: ID, site: "hills0000000001", processing_status: "processing" });
|
||||
const res = await POST(postWithValidAuth({ purchase_order_id: ID, extracted: coded("C26/13, 0001", "C26/13, 0002") }));
|
||||
expect(res.status).toBe(200);
|
||||
const update = updateMock.mock.calls[0][1];
|
||||
expect(update.site).toBe("cedar0000000001");
|
||||
expect(update.raw_ocr_data.site_check).toEqual(expect.objectContaining({ result: "moved", from: "hills0000000001", site: "cedar0000000001", codes: ["C26/13"] }));
|
||||
expect(queueWaitingDeliveries).toHaveBeenCalledWith(fakePb, expect.objectContaining({ site: "cedar0000000001" }));
|
||||
} finally { if (previous === undefined) delete process.env.DN_STAGED_INGESTION_ENABLED; else process.env.DN_STAGED_INGESTION_ENABLED = previous; }
|
||||
});
|
||||
|
||||
it("records a confirmed site without touching it", async () => {
|
||||
getOneMock.mockResolvedValue({ id: ID, site: "hills0000000001", processing_status: "processing" });
|
||||
await POST(postWithValidAuth({ purchase_order_id: ID, extracted: coded("C26/09, MC-SEW") }));
|
||||
const update = updateMock.mock.calls[0][1];
|
||||
expect(update.site).toBeUndefined();
|
||||
expect(update.raw_ocr_data.site_check.result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("flags lines naming two projects and keeps the filed site", async () => {
|
||||
getOneMock.mockResolvedValue({ id: ID, site: "cedar0000000001", processing_status: "processing" });
|
||||
await POST(postWithValidAuth({ purchase_order_id: ID, extracted: coded("C26/01, 0001", "C26/09, 0001") }));
|
||||
const update = updateMock.mock.calls[0][1];
|
||||
expect(update.site).toBeUndefined();
|
||||
expect(update.raw_ocr_data.site_check).toEqual(expect.objectContaining({ result: "mismatch", codes: ["C26/01", "C26/09"] }));
|
||||
});
|
||||
|
||||
it("does not move a PO that already has delivery notes booked against it", async () => {
|
||||
getOneMock.mockResolvedValue({ id: ID, site: "hills0000000001", processing_status: "processing" });
|
||||
deliveryNotesListMock.mockResolvedValue({ totalItems: 2, items: [{ id: "dn0000000000001" }] });
|
||||
await POST(postWithValidAuth({ purchase_order_id: ID, extracted: coded("C26/13, 0001") }));
|
||||
const update = updateMock.mock.calls[0][1];
|
||||
expect(update.site).toBeUndefined();
|
||||
expect(update.raw_ocr_data.site_check).toEqual(expect.objectContaining({ result: "mismatch", reason: expect.stringContaining("delivery notes") }));
|
||||
});
|
||||
|
||||
it("still fills the PO in when the site list cannot be read", async () => {
|
||||
getOneMock.mockResolvedValue({ id: ID, site: "hills0000000001", processing_status: "processing" });
|
||||
sitesListMock.mockRejectedValue(new Error("pb down"));
|
||||
const res = await POST(postWithValidAuth({ purchase_order_id: ID, extracted: coded("C26/13, 0001") }));
|
||||
expect(res.status).toBe(200);
|
||||
const update = updateMock.mock.calls[0][1];
|
||||
expect(update.site).toBeUndefined();
|
||||
expect(update.processing_status).toBe("processed");
|
||||
expect(update.raw_ocr_data.site_check).toEqual(expect.objectContaining({ result: "unchecked", reason: "site list unavailable" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules waiting deliveries after saving all PO items, without failing the upload if scheduling is interrupted", async () => {
|
||||
|
|
@ -265,7 +337,7 @@ describe("POST /api/purchase-orders/process-batch", () => {
|
|||
expect(updateMock.mock.calls[0][0]).toBe("po0000000000001");
|
||||
expect(update.purchase_order_number).toBe("MEGCPO9199");
|
||||
expect(update.processing_status).toBe("processed");
|
||||
// Critical: the uploader's site choice must never be touched here.
|
||||
// The cost code "C-1" names no project, so the uploader's site stands.
|
||||
expect(update.site).toBeUndefined();
|
||||
expect(update.raw_ocr_data.extracted_project_ref).toBe("PRJ-A");
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { purchaseOrderImportRefusal } from "@/lib/purchase-order-document-kind";
|
|||
import { fetchPurchaseOrderDocument } from "@/lib/purchase-order-document";
|
||||
import { readPoOrderDate } from "@/lib/purchase-order-date";
|
||||
import { findPurchaseOrderNumberCandidates, findPurchaseOrderFileMatches, type PurchaseOrderNumberRecord } from "@/lib/purchase-order-duplicates";
|
||||
import { checkPurchaseOrderSite, type SiteCheckResult } from "@/lib/purchase-order-site";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -58,8 +59,9 @@ type ValidationResult =
|
|||
*
|
||||
* 1. Fill in: the extracted number is genuinely new. Update the
|
||||
* placeholder in place with the real number, supplier, dates, and
|
||||
* line items. `site` is NEVER touched here — the uploader's choice
|
||||
* is far more reliable than OCR's guess at a project reference.
|
||||
* line items. `site` is moved only when every line's cost code names
|
||||
* one other site in the app (src/lib/purchase-order-site.ts); OCR's
|
||||
* header project reference is never used for it.
|
||||
* 2. Merge on dedup: the extracted number already belongs to a
|
||||
* different (already-processed) purchase_orders row. This upload
|
||||
* was a genuine duplicate — delete the placeholder (leaving it
|
||||
|
|
@ -290,9 +292,14 @@ export async function POST(request: Request) {
|
|||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// 6. Fill in. `site` was set at upload by the person who uploaded the
|
||||
// file and is intentionally absent below — it is NOT overwritten from
|
||||
// the extraction.
|
||||
// 6. Fill in. `site` was set at upload; it changes only when the PO's own
|
||||
// line cost codes all name one other site and nothing is booked against it
|
||||
// yet. Never fatal: if the check cannot run, the uploader's site stands.
|
||||
const siteCheck = await checkSiteFromCostCodes(pb, purchaseOrderId, po.site ?? "", extracted.items);
|
||||
const site = siteCheck.result === "moved" ? siteCheck.site : po.site;
|
||||
if (siteCheck.result === "moved" || siteCheck.result === "mismatch") {
|
||||
console.warn("[po-process] site_check", { purchase_order_id: purchaseOrderId, ...siteCheck });
|
||||
}
|
||||
// Reuse an exact normalized supplier match, or create the named supplier
|
||||
// automatically. An unreadable supplier remains unset for review.
|
||||
let supplierMatch;
|
||||
|
|
@ -318,6 +325,7 @@ export async function POST(request: Request) {
|
|||
try {
|
||||
await pb.collection("purchase_orders").update(purchaseOrderId, {
|
||||
purchase_order_number: extracted.purchase_order_number,
|
||||
...(siteCheck.result === "moved" ? { site: siteCheck.site } : {}),
|
||||
...(orderDate ? { order_date: `${orderDate} 00:00:00.000Z` } : {}),
|
||||
supplier: extracted.supplier ?? "",
|
||||
...(supplierMatch.supplierId ? { supplier_ref: supplierMatch.supplierId } : {}),
|
||||
|
|
@ -334,6 +342,7 @@ export async function POST(request: Request) {
|
|||
// lives with the other extraction output, not in a schema column
|
||||
// named project_code.
|
||||
extracted_project_ref: extracted.project_code ?? "",
|
||||
site_check: { ...siteCheck, checked_at: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -379,7 +388,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
if (process.env.DN_STAGED_INGESTION_ENABLED === "true") {
|
||||
try { await queueWaitingDeliveries(pb, {id:purchaseOrderId,site:po.site,purchase_order_number:extracted.purchase_order_number,processing_status:"processed",items_count:extracted.items.length}); }
|
||||
try { await queueWaitingDeliveries(pb, {id:purchaseOrderId,site,purchase_order_number:extracted.purchase_order_number,processing_status:"processed",items_count:extracted.items.length}); }
|
||||
catch (error) { console.warn("[po-upload] delivery retry scheduling deferred to tick", {error:String(error)}); }
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +408,40 @@ export async function POST(request: Request) {
|
|||
});
|
||||
}
|
||||
|
||||
type SiteCheck = SiteCheckResult | { result: "unchecked"; reason: string };
|
||||
|
||||
async function checkSiteFromCostCodes(
|
||||
pb: Awaited<ReturnType<typeof pbAdmin>>,
|
||||
purchaseOrderId: string,
|
||||
filedSite: string,
|
||||
items: ExtractedItem[],
|
||||
): Promise<SiteCheck> {
|
||||
let sites: { id: string; project_code?: string }[];
|
||||
try {
|
||||
sites = await pb.collection("sites").getFullList({ fields: "id,project_code" });
|
||||
} catch (err) {
|
||||
console.warn("[po-process] site list unavailable", { purchase_order_id: purchaseOrderId, err: String(err) });
|
||||
return { result: "unchecked", reason: "site list unavailable" };
|
||||
}
|
||||
const check = checkPurchaseOrderSite(items, filedSite, sites);
|
||||
if (check.result !== "moved") return check;
|
||||
// A retried PO can already carry receipts on its current site; moving the
|
||||
// order would leave them on the wrong one. A person decides those.
|
||||
try {
|
||||
const booked = await pb.collection("delivery_notes").getList(1, 1, {
|
||||
filter: pb.filter("purchase_order = {:id}", { id: purchaseOrderId }),
|
||||
fields: "id",
|
||||
});
|
||||
if (booked.totalItems > 0) {
|
||||
return { result: "mismatch", codes: check.codes, site: filedSite, reason: `cost codes name another site but ${booked.totalItems} delivery notes are booked here` };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[po-process] booked-delivery lookup failed", { purchase_order_id: purchaseOrderId, err: String(err) });
|
||||
return { result: "mismatch", codes: check.codes, site: filedSite, reason: "cost codes name another site; booked deliveries could not be checked" };
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
function validateExtracted(input: unknown): ValidationResult {
|
||||
if (!input || typeof input !== "object") {
|
||||
return { ok: false, details: { _root: "must be an object" } };
|
||||
|
|
|
|||
46
src/lib/purchase-order-site.test.ts
Normal file
46
src/lib/purchase-order-site.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { checkPurchaseOrderSite, projectCodesInCostCodes } from "./purchase-order-site";
|
||||
|
||||
const SITES = [
|
||||
{ id: "hills0000000001", project_code: "C26/09", name: "The Hills" },
|
||||
{ id: "cedar0000000001", project_code: "C26/13", name: "Cedar Gate" },
|
||||
{ id: "nell00000000001", project_code: "C26/27", name: "Nellmapius Ext 25" },
|
||||
{ id: "head00000000001", project_code: "HEAD", name: "Mega Head Office" },
|
||||
];
|
||||
const items = (...codes: string[]) => codes.map((cost_code) => ({ cost_code }));
|
||||
|
||||
describe("projectCodesInCostCodes", () => {
|
||||
it("reads the project code at the front of each cost code, once each", () => {
|
||||
expect(projectCodesInCostCodes(items("C26/09, MC-SEW", "c26/09,0001", "C26/13 MC-ASP"))).toEqual(["C26/09", "C26/13"]);
|
||||
});
|
||||
it("ignores lines with no project code", () => {
|
||||
expect(projectCodesInCostCodes(items("", "19P900100D", "MC-SEW"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPurchaseOrderSite", () => {
|
||||
it("confirms the filed site when every line names it", () => {
|
||||
expect(checkPurchaseOrderSite(items("C26/09, MC-SEW", "C26/09, MC-ASP"), "hills0000000001", SITES))
|
||||
.toEqual({ result: "confirmed", codes: ["C26/09"], site: "hills0000000001" });
|
||||
});
|
||||
it("moves the PO when every line names one other known site", () => {
|
||||
expect(checkPurchaseOrderSite(items("C26/13, 0001", "C26/13, 0001"), "nell00000000001", SITES))
|
||||
.toEqual({ result: "moved", codes: ["C26/13"], site: "cedar0000000001", from: "nell00000000001" });
|
||||
});
|
||||
it("matches project codes regardless of case or spacing on the site", () => {
|
||||
const sites = [{ id: "cedar0000000001", project_code: " c26/13 ", name: "Cedar Gate" }];
|
||||
expect(checkPurchaseOrderSite(items("C26/13, 0001"), "x00000000000001", sites).result).toBe("moved");
|
||||
});
|
||||
it("flags, and never moves, when the lines name more than one project", () => {
|
||||
expect(checkPurchaseOrderSite(items("C26/01, 0001", "C26/09, 0001"), "cedar0000000001", SITES))
|
||||
.toEqual({ result: "mismatch", codes: ["C26/01", "C26/09"], site: "cedar0000000001", reason: "lines name more than one project: C26/01, C26/09" });
|
||||
});
|
||||
it("flags, and never moves, when a line's project code is not a site in the app", () => {
|
||||
expect(checkPurchaseOrderSite(items("C25/42, MC-ASP"), "hills0000000001", SITES))
|
||||
.toEqual({ result: "mismatch", codes: ["C25/42"], site: "hills0000000001", reason: "no site has project code C25/42" });
|
||||
});
|
||||
it("leaves the site alone when no line carries a project code", () => {
|
||||
expect(checkPurchaseOrderSite(items("", "19SALE750250"), "hills0000000001", SITES))
|
||||
.toEqual({ result: "no_codes", codes: [], site: "hills0000000001" });
|
||||
});
|
||||
});
|
||||
48
src/lib/purchase-order-site.ts
Normal file
48
src/lib/purchase-order-site.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Which site a purchase order belongs to, read from its own line cost codes.
|
||||
*
|
||||
* MEGA's POs carry the project code at the front of every line's cost code
|
||||
* ("C26/09, MC-SEW" is The Hills). Across the live POs that code agreed with
|
||||
* the filed site on every order whose code is a site in the app, which makes
|
||||
* it far stronger evidence than the email subject or the uploader's pick.
|
||||
*
|
||||
* Only an unambiguous answer moves a PO: every line names the same project and
|
||||
* that project is a site in the app. Anything else is flagged for a person and
|
||||
* the filed site stays.
|
||||
*/
|
||||
|
||||
export type SiteCheckResult =
|
||||
| { result: "confirmed"; codes: string[]; site: string }
|
||||
| { result: "moved"; codes: string[]; site: string; from: string }
|
||||
| { result: "mismatch"; codes: string[]; site: string; reason: string }
|
||||
| { result: "no_codes"; codes: []; site: string };
|
||||
|
||||
type SiteRef = { id: string; project_code?: string | null };
|
||||
|
||||
const PROJECT_CODE = /\b([A-Z]\d{2}\/\d{2})\b/i;
|
||||
const normalise = (code: string) => code.trim().toUpperCase();
|
||||
|
||||
export function projectCodesInCostCodes(items: ReadonlyArray<{ cost_code?: string | null }>): string[] {
|
||||
const codes = new Set<string>();
|
||||
for (const item of items) {
|
||||
const match = PROJECT_CODE.exec(item.cost_code ?? "");
|
||||
if (match) codes.add(normalise(match[1]));
|
||||
}
|
||||
return [...codes];
|
||||
}
|
||||
|
||||
export function checkPurchaseOrderSite(
|
||||
items: ReadonlyArray<{ cost_code?: string | null }>,
|
||||
filedSite: string,
|
||||
sites: ReadonlyArray<SiteRef>,
|
||||
): SiteCheckResult {
|
||||
const codes = projectCodesInCostCodes(items);
|
||||
if (codes.length === 0) return { result: "no_codes", codes: [], site: filedSite };
|
||||
if (codes.length > 1) {
|
||||
return { result: "mismatch", codes, site: filedSite, reason: `lines name more than one project: ${codes.join(", ")}` };
|
||||
}
|
||||
const named = sites.find((site) => normalise(site.project_code ?? "") === codes[0]);
|
||||
if (!named) return { result: "mismatch", codes, site: filedSite, reason: `no site has project code ${codes[0]}` };
|
||||
if (named.id === filedSite) return { result: "confirmed", codes, site: filedSite };
|
||||
return { result: "moved", codes, site: named.id, from: filedSite };
|
||||
}
|
||||
Loading…
Reference in a new issue