diff --git a/src/lib/dn-pipeline/resolve-entities.test.ts b/src/lib/dn-pipeline/resolve-entities.test.ts new file mode 100644 index 0000000..f4bb8c6 --- /dev/null +++ b/src/lib/dn-pipeline/resolve-entities.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { resolveEntities, type EntityResolutionInput } from "./resolve-entities"; + +const suppliers = [ + { id: "sup-raumix", name: "Raumix (Pty) Ltd", registration: "2010/123456/07" }, + { id: "sup-vula", name: "Vula Civil Engineering" }, +]; + +const purchaseOrders = [ + { + id: "po-site-a", + site: "site-a", + purchase_order_number: "MEGCPO9165", + supplier_ref: "sup-raumix", + supplier: "Raumix (Pty) Ltd", + }, + { + id: "po-site-b", + site: "site-b", + purchase_order_number: "MEGCPO9165", + supplier_ref: "sup-vula", + supplier: "Vula Civil Engineering", + }, +]; + +function input(overrides: Partial = {}): EntityResolutionInput { + return { + siteId: "site-a", + poNumber: "", + supplierName: "", + supplierRegistration: "", + purchaseOrders, + suppliers, + aliases: [], + ...overrides, + }; +} + +describe("resolveEntities", () => { + it("uses the unique exact PO and derives its supplier when supplier text is blank", () => { + const result = resolveEntities(input({ poNumber: "#MEGC-PO-9165" })); + + expect(result.purchaseOrderId).toBe("po-site-a"); + expect(result.supplierId).toBe("sup-raumix"); + expect(result.method).toBe("po_exact"); + expect(result.autoResolved).toBe(true); + expect(result.candidates[0]).toMatchObject({ + id: "po-site-a", + kind: "purchase_order", + evidence: { method: "po_exact" }, + }); + }); + + it("does not consider an identical PO from another site", () => { + const result = resolveEntities(input({ poNumber: "MEGCPO9165" })); + + expect(result.purchaseOrderId).toBe("po-site-a"); + expect(result.candidates.map((candidate) => candidate.id)).not.toContain("po-site-b"); + }); + + it("resolves an approved supplier document alias without fuzzy name matching", () => { + const result = resolveEntities(input({ + poNumber: "", + supplierName: "R-Mix Haulage", + aliases: [{ + id: "alias-1", + supplier: "sup-raumix", + raw_value: "R-Mix Haulage", + normalised_key: "r mix haulage", + approved_by: "operator-1", + }], + })); + + expect(result.supplierId).toBe("sup-raumix"); + expect(result.purchaseOrderId).toBeNull(); + expect(result.method).toBe("supplier_alias"); + expect(result.autoResolved).toBe(false); + expect(result.candidates[0]).toMatchObject({ + id: "sup-raumix", + kind: "supplier", + evidence: { method: "supplier_alias" }, + }); + }); + + it("keeps ambiguous fuzzy PO matches as ordered suggestions", () => { + const result = resolveEntities(input({ + poNumber: "MEGCPO916", + purchaseOrders: [ + { ...purchaseOrders[0], purchase_order_number: "MEGCPO9165" }, + { ...purchaseOrders[0], id: "po-site-a-2", purchase_order_number: "MEGCPO9166" }, + ], + })); + + expect(result.purchaseOrderId).toBeNull(); + expect(result.autoResolved).toBe(false); + expect(result.method).toBe("po_fuzzy"); + expect(result.candidates.map((candidate) => candidate.id)).toEqual([ + "po-site-a", + "po-site-a-2", + ]); + expect(result.candidates[0].evidence).toMatchObject({ method: "po_fuzzy" }); + expect(result.candidates[0].evidence.score).toBeTypeOf("number"); + expect(result.candidates[0].evidence.margin).toBeTypeOf("number"); + }); + + it("does not auto-select a close PO when the winner margin is unsafe", () => { + const result = resolveEntities(input({ + poNumber: "MEGCPO9165", + purchaseOrders: [ + { ...purchaseOrders[0], purchase_order_number: "MEGCPO9164" }, + { ...purchaseOrders[0], id: "po-site-a-2", purchase_order_number: "MEGCPO9166" }, + ], + })); + + expect(result.purchaseOrderId).toBeNull(); + expect(result.autoResolved).toBe(false); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0].evidence.margin).toBe(0); + }); +}); diff --git a/src/lib/dn-pipeline/resolve-entities.ts b/src/lib/dn-pipeline/resolve-entities.ts new file mode 100644 index 0000000..6ceec8f --- /dev/null +++ b/src/lib/dn-pipeline/resolve-entities.ts @@ -0,0 +1,383 @@ +import { normalizeSupplierName } from "../purchase-order-schema"; +import { normalisePoNumber, type PoCandidate, type PoMatch } from "../po-matching"; + +export interface EntityPurchaseOrder { + id: string; + purchase_order_number: string; + site?: string | null; + supplier?: string | null; + supplier_ref?: string | { id?: string; name?: string } | null; + supplier_id?: string | null; + expand?: { supplier_ref?: { id?: string; name?: string } }; + required_by_date?: string | null; + status?: string | null; + processing_status?: string | null; + is_closed?: boolean; + closed?: boolean; +} + +export interface EntitySupplier { + id: string; + name: string; + registration?: string | null; + registration_no?: string | null; + company_registration?: string | null; + vat_number?: string | null; + vat_no?: string | null; + tax_number?: string | null; + [key: string]: unknown; +} + +export interface SupplierDocumentAlias { + id?: string; + supplier?: string | { id?: string } | null; + supplier_id?: string | null; + raw_value?: string | null; + normalised_key: string; + approved_by?: string | null; + approved?: boolean; + status?: string | null; +} + +export interface EntityResolutionInput { + /** The confirmed batch site. POs must already be scoped to this site. */ + siteId?: string | null; + poNumber?: string | null; + supplierName?: string | null; + supplierRegistration?: string | null; + purchaseOrders: readonly EntityPurchaseOrder[]; + suppliers: readonly EntitySupplier[]; + aliases: readonly SupplierDocumentAlias[]; + /** Optional extracted line evidence for future PO ranking. */ + lines?: readonly unknown[]; + /** Convenience adapter for callers holding the rich page/note shape. */ + note?: { + po_number_raw?: string | null; + supplier_name_raw?: string | null; + supplier_registration_raw?: string | null; + }; +} + +export type EntityMatchMethod = + | "po_exact" + | "supplier_registration" + | "supplier_alias" + | "supplier_name" + | "po_fuzzy" + | "unresolved"; + +export interface EntityCandidate { + kind: "supplier" | "purchase_order"; + id: string; + label?: string; + evidence: Record; +} + +export interface EntityResolution { + supplierId: string | null; + purchaseOrderId: string | null; + method: EntityMatchMethod; + autoResolved: boolean; + candidates: EntityCandidate[]; + /** Resolved display name used by the item-alias matcher. */ + supplierName: string | null; +} + +const EMPTY_RESOLUTION = (): EntityResolution => ({ + supplierId: null, + purchaseOrderId: null, + method: "unresolved", + autoResolved: false, + candidates: [], + supplierName: null, +}); + +/** Case/whitespace-insensitive supplier identity key. */ +export function normaliseSupplierKey(value: unknown): string { + return typeof value === "string" ? normalizeSupplierName(value) : ""; +} + +/** Registration and VAT identifiers compare by their significant characters. */ +export function normaliseRegistration(value: unknown): string { + return typeof value === "string" ? value.toLowerCase().replace(/[^a-z0-9]/g, "") : ""; +} + +export function resolveEntities(input: EntityResolutionInput): EntityResolution { + const poNumber = input.poNumber ?? input.note?.po_number_raw ?? ""; + const supplierNameRaw = input.supplierName ?? input.note?.supplier_name_raw ?? ""; + const supplierRegistration = input.supplierRegistration ?? input.note?.supplier_registration_raw ?? ""; + const allPos = scopedPurchaseOrders(input.purchaseOrders, input.siteId); + const suppliers = input.suppliers ?? []; + const aliases = input.aliases ?? []; + const targetPo = normalisePoNumber(poNumber); + + // An exact PO is the strongest evidence. It is authoritative even when + // the OCR supplier text is blank or unusable. + if (targetPo) { + const exact = allPos.filter((po) => normalisePoNumber(po.purchase_order_number) === targetPo); + if (exact.length === 1) { + const po = exact[0]; + const supplier = supplierForPo(po, suppliers); + const relationSupplierId = relationId(po.supplier_ref) ?? po.supplier_id ?? null; + const candidate: EntityCandidate = { + kind: "purchase_order", + id: po.id, + label: po.purchase_order_number, + evidence: { + method: "po_exact", + input: poNumber, + normalised: targetPo, + supplierId: supplier?.id ?? relationSupplierId, + }, + }; + return { + supplierId: supplier?.id ?? relationSupplierId, + purchaseOrderId: po.id, + method: "po_exact", + autoResolved: true, + candidates: [candidate], + supplierName: supplier?.name ?? supplierNameForPo(po, suppliers), + }; + } + + if (exact.length > 1) { + return { + supplierId: null, + purchaseOrderId: null, + method: "po_exact", + autoResolved: false, + candidates: exact.map((po) => ({ + kind: "purchase_order" as const, + id: po.id, + label: po.purchase_order_number, + evidence: { + method: "po_exact", + input: poNumber, + normalised: targetPo, + reason: "duplicate_normalised_po", + }, + })), + supplierName: null, + }; + } + } + + const supplierMatch = findSupplier(supplierRegistration, supplierNameRaw, suppliers, aliases); + const supplier = supplierMatch?.supplier ?? null; + const supplierId = supplier?.id ?? null; + const supplierDisplayName = supplier?.name ?? (supplierNameRaw.trim() || null); + const supplierCandidate = supplierMatch + ? { + kind: "supplier" as const, + id: supplierMatch.supplier.id, + label: supplierMatch.supplier.name, + evidence: { + method: supplierMatch.method, + input: supplierMatch.input, + normalised: supplierMatch.normalised, + aliasId: supplierMatch.aliasId ?? null, + }, + } + : null; + + const eligiblePos = openPurchaseOrders( + supplier ? allPos.filter((po) => poBelongsToSupplier(po, supplier)) : allPos, + ); + const suggestions = targetPo + ? rankedSuggestions(targetPo, eligiblePos) + : []; + + if (suggestions.length > 0) { + const candidates = [ + ...(supplierCandidate ? [supplierCandidate] : []), + ...suggestions.map((suggestion) => suggestion.candidate), + ]; + return { + supplierId, + purchaseOrderId: null, + method: "po_fuzzy", + // Fuzzy evidence is intentionally never auto-selected. The caller can + // show the ordered candidates and require an operator decision. + autoResolved: false, + candidates, + supplierName: supplierDisplayName, + }; + } + + if (supplierCandidate) { + return { + supplierId, + purchaseOrderId: null, + method: supplierMatch!.method, + autoResolved: false, + candidates: [supplierCandidate], + supplierName: supplierDisplayName, + }; + } + + return EMPTY_RESOLUTION(); +} + +function scopedPurchaseOrders( + purchaseOrders: readonly EntityPurchaseOrder[], + siteId: string | null | undefined, +): EntityPurchaseOrder[] { + if (!siteId) return [...purchaseOrders]; + // Test doubles and older callers may omit `site`; when every row is + // unscoped, preserve their already-scoped contract. If any row carries a + // site, require an exact relation match for every candidate. + const hasSiteData = purchaseOrders.some((po) => typeof po.site === "string" && po.site !== ""); + return hasSiteData ? purchaseOrders.filter((po) => po.site === siteId) : [...purchaseOrders]; +} + +function supplierForPo( + po: EntityPurchaseOrder, + suppliers: readonly EntitySupplier[], +): EntitySupplier | null { + const direct = relationId(po.supplier_ref) ?? po.supplier_id ?? null; + if (direct) return suppliers.find((supplier) => supplier.id === direct) ?? null; + const printed = normaliseSupplierKey(po.supplier); + if (!printed) return null; + const matches = suppliers.filter((supplier) => normaliseSupplierKey(supplier.name) === printed); + return matches.length === 1 ? matches[0] : null; +} + +function supplierNameForPo(po: EntityPurchaseOrder, suppliers: readonly EntitySupplier[]): string | null { + const expanded = po.expand?.supplier_ref?.name; + if (expanded) return expanded; + return supplierForPo(po, suppliers)?.name ?? po.supplier?.trim() ?? null; +} + +function relationId(value: EntityPurchaseOrder["supplier_ref"] | SupplierDocumentAlias["supplier"]): string | null { + if (typeof value === "string") return value || null; + return value?.id ?? null; +} + +function findSupplier( + registrationRaw: string, + nameRaw: string, + suppliers: readonly EntitySupplier[], + aliases: readonly SupplierDocumentAlias[], +): { supplier: EntitySupplier; method: EntityMatchMethod; input: string; normalised: string; aliasId?: string } | null { + const registration = normaliseRegistration(registrationRaw); + if (registration) { + const matches = suppliers.filter((supplier) => supplierIdentifiers(supplier).has(registration)); + if (matches.length === 1) { + return { supplier: matches[0], method: "supplier_registration", input: registrationRaw, normalised: registration }; + } + } + + const name = normaliseSupplierKey(nameRaw); + if (name) { + const aliasMatches = aliases.filter((alias) => { + if (!isApprovedAlias(alias)) return false; + return normaliseSupplierKey(alias.normalised_key) === name || normaliseSupplierKey(alias.raw_value) === name; + }); + const aliasSupplierIds = [...new Set(aliasMatches.map((alias) => relationId(alias.supplier) ?? alias.supplier_id).filter((id): id is string => Boolean(id)))]; + if (aliasSupplierIds.length === 1) { + const supplier = suppliers.find((candidate) => candidate.id === aliasSupplierIds[0]); + if (supplier) { + return { + supplier, + method: "supplier_alias", + input: nameRaw, + normalised: name, + aliasId: aliasMatches.find((alias) => relationId(alias.supplier) === supplier.id || alias.supplier_id === supplier.id)?.id, + }; + } + } + + const exact = suppliers.filter((supplier) => normaliseSupplierKey(supplier.name) === name); + if (exact.length === 1) { + return { supplier: exact[0], method: "supplier_name", input: nameRaw, normalised: name }; + } + } + return null; +} + +function supplierIdentifiers(supplier: EntitySupplier): Set { + const values = Object.entries(supplier) + .filter(([key]) => /registration|vat|tax/i.test(key)) + .map(([, value]) => normaliseRegistration(value)); + return new Set(values.filter(Boolean)); +} + +function isApprovedAlias(alias: SupplierDocumentAlias): boolean { + if (alias.approved === false) return false; + if (typeof alias.status === "string" && alias.status.toLowerCase() !== "approved") return false; + return true; +} + +function poBelongsToSupplier(po: EntityPurchaseOrder, supplier: EntitySupplier): boolean { + const relation = relationId(po.supplier_ref) ?? po.supplier_id; + if (relation) return relation === supplier.id; + return normaliseSupplierKey(po.supplier) === normaliseSupplierKey(supplier.name); +} + +function openPurchaseOrders(pos: readonly EntityPurchaseOrder[]): EntityPurchaseOrder[] { + return pos.filter((po) => { + if (po.is_closed === true || po.closed === true) return false; + const state = `${po.status ?? ""} ${po.processing_status ?? ""}`.toLowerCase(); + return !/(^|\s)(closed|cancelled|canceled)(\s|$)/.test(state); + }); +} + +function rankedSuggestions( + target: string, + purchaseOrders: readonly EntityPurchaseOrder[], +): { candidate: EntityCandidate; match: PoMatch }[] { + const poCandidates = purchaseOrders.map((po): PoCandidate => ({ + id: po.id, + purchase_order_number: po.purchase_order_number, + supplier: po.supplier ?? undefined, + })); + const ranked = localRankPoMatches(target, poCandidates); + const relevant = ranked.filter((match) => match.score >= 0.75); + const margin = relevant.length > 1 ? relevant[0].score - relevant[1].score : relevant[0]?.score ?? 0; + return relevant.map((match, index) => ({ + match, + candidate: { + kind: "purchase_order", + id: match.id, + label: match.purchase_order_number, + evidence: { + method: "po_fuzzy", + score: match.score, + margin, + rank: index + 1, + threshold: 0.75, + }, + }, + })); +} + +function localRankPoMatches(target: unknown, candidates: PoCandidate[]): PoMatch[] { + const normalisedTarget = normalisePoNumber(target); + if (normalisedTarget.length < 4) return []; + return candidates + .map((candidate) => ({ + ...candidate, + score: similarity(normalisedTarget, normalisePoNumber(candidate.purchase_order_number)), + })) + .filter((candidate) => normalisePoNumber(candidate.purchase_order_number).length > 0) + .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); +} + +function similarity(a: string, b: string): number { + if (a === b) return 1; + if (!a.length || !b.length) return 0; + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + let prior = previous; + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + current[j] = Math.min( + current[j - 1] + 1, + prior[j] + 1, + prior[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + prior = current; + } + return 1 - prior[b.length] / Math.max(a.length, b.length); +} diff --git a/src/lib/dn-pipeline/run.test.ts b/src/lib/dn-pipeline/run.test.ts index 9eae148..bf65a7b 100644 --- a/src/lib/dn-pipeline/run.test.ts +++ b/src/lib/dn-pipeline/run.test.ts @@ -432,7 +432,7 @@ describe("runBatch — happy path", () => { }); describe("runBatch — per-note checks", () => { - it("uses exact normalised match before falling back to findBestPoMatch", async () => { + it("uses the unique site-scoped exact normalised match", async () => { purchaseOrdersGetFullList.mockResolvedValueOnce([ { id: "po-exact", purchase_order_number: "MEGCPO9165" }, { id: "po-other", purchase_order_number: "MEGCPO9999" }, @@ -440,40 +440,32 @@ describe("runBatch — per-note checks", () => { await runBatch(makePb(), "batch1"); - // The exact match wins; findBestPoMatch should not even be asked. + // The exact match wins and no fuzzy helper is consulted by the runner. expect(mFindBestPoMatch).not.toHaveBeenCalled(); const ctx = mRunNoteChecks.mock.calls[0][1]; expect(ctx.purchaseOrderId).toBe("po-exact"); }); - it("falls back to findBestPoMatch when no normalised exact match exists", async () => { + it("keeps a fuzzy PO suggestion in review instead of auto-picking it", async () => { purchaseOrdersGetFullList.mockResolvedValueOnce([ { id: "po-fuzzy", purchase_order_number: "MEGCPO9165" }, // close enough ]); - // Exact match normalises "MEGCPO9165" → "megcpo9165" against - // "MEGCPO9165" → "megcpo9165" — these ARE equal, so the test - // above passes via exact. To force the fuzzy path we make the - // exact compare fail by mutating one side. - // Approach: monkey-patch normalisePoNumber to always lowercase. - // But it's already imported. Instead, use a target that doesn't - // match exactly even after normalisation by giving different - // alphabetics on each side. + // The target has one extra OCR character, so no exact relation exists. mSegmentPages.mockReturnValueOnce({ notes: [makeNote({ po_number_raw: "MEGCPO9165x" })], // one extra char → no exact match unassigned: [], needsSubsplit: [], }); - mFindBestPoMatch.mockReturnValueOnce({ - id: "po-fuzzy", - purchase_order_number: "MEGCPO9165", - score: 0.9, - }); - await runBatch(makePb(), "batch1"); - expect(mFindBestPoMatch).toHaveBeenCalledTimes(1); + expect(mFindBestPoMatch).not.toHaveBeenCalled(); const ctx = mRunNoteChecks.mock.calls[0][1]; - expect(ctx.purchaseOrderId).toBe("po-fuzzy"); + expect(ctx.purchaseOrderId).toBeNull(); + const form = await readForm(dnBatchNotesCreate.mock.calls[0][0]); + expect(form.purchase_order).toBe(""); + expect(form.match_method).toBe("po_fuzzy"); + expect(form.status).toBe("blocked"); + expect(mPromoteNote).not.toHaveBeenCalled(); }); it("skips resolvePo entirely when the note has no raw PO number", async () => { diff --git a/src/lib/dn-pipeline/run.ts b/src/lib/dn-pipeline/run.ts index a469669..28411af 100644 --- a/src/lib/dn-pipeline/run.ts +++ b/src/lib/dn-pipeline/run.ts @@ -7,10 +7,17 @@ * named. There is no path that reports success while a note is missing. */ import type PocketBase from "pocketbase"; -import { findBestPoMatch, normalisePoNumber } from "../po-matching"; +import { normalisePoNumber } from "../po-matching"; import { resolveNoteLines, runNoteChecks } from "./checks"; import type { PoItemRef } from "./match-po-item"; import type { AliasRow } from "./aliases"; +import { + resolveEntities, + type EntityPurchaseOrder, + type EntityResolution, + type EntitySupplier, + type SupplierDocumentAlias, +} from "./resolve-entities"; import { extractPage } from "./extract"; import { promoteNote } from "./promote"; import { extractPageRange, splitPages } from "./split"; @@ -65,26 +72,40 @@ export async function runBatch(pb: PocketBase, batchId: string): Promise [])) as unknown as AliasRow[]; + const needsEntityResolution = seg.notes.some((note) => Boolean(normalisePoNumber(note.po_number_raw ?? ""))); + const purchaseOrders = needsEntityResolution ? await loadSitePurchaseOrders(pb, batch.site) : []; + const suppliers = needsEntityResolution ? await loadSuppliers(pb) : []; + const supplierAliases = needsEntityResolution ? await loadSupplierAliases(pb) : []; const customerVatNo = process.env.MEGA_CUSTOMER_VAT_NO || null; const allChecks: CheckResult[] = [...pageErrors]; let promoted = 0; for (const note of seg.notes) { - const po = await resolvePo(pb, note.po_number_raw); + const richNote = note as typeof note & { + supplier_name_raw?: string | null; + supplier_registration_raw?: string | null; + }; + const resolution = resolveEntities({ + siteId: batch.site, + poNumber: note.po_number_raw, + supplierName: richNote.supplier_name_raw, + supplierRegistration: richNote.supplier_registration_raw, + purchaseOrders, + suppliers, + aliases: supplierAliases, + }); + const po = resolution.purchaseOrderId + ? purchaseOrders.find((candidate) => candidate.id === resolution.purchaseOrderId) ?? null + : null; const poItems = po ? ((await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order='${po.id}'`, fields: "id,description,unit,material_code,qty", })) as unknown as PoItemRef[]) : []; - const supplierName = po - ? ((await pb - .collection("purchase_orders") - .getOne(po.id, { fields: "supplier" }) - .catch(() => null)) as { supplier?: string } | null)?.supplier ?? null - : null; + const supplierName = resolution.supplierName ?? po?.supplier ?? null; const checkCtx = { customerVatNo, @@ -97,13 +118,21 @@ export async function runBatch(pb: PocketBase, batchId: string): Promise c.passed === false); + // A PO-bearing note cannot auto-post without the resolver's deterministic + // identity. Keep this gate independent of a mocked/legacy check list so a + // fuzzy suggestion never turns into a financial relation by accident. + const unresolvedPrintedPo = Boolean(normalisePoNumber(note.po_number_raw ?? "")) && !po; + const blocked = unresolvedPrintedPo || checks.some((c) => c.passed === false); const pageFile = await extractPageRange(buffer, mime, note.page_from, note.page_to); const pageFileName = `${filename.replace(/\.[^.]+$/, "")}-note-${note.note_index + 1}.pdf`; const candidate = await upsertCandidate(pb, batchId, note, { poId: po?.id ?? null, + supplierId: resolution.supplierId, + supplierNameRaw: richNote.supplier_name_raw ?? null, + matchMethod: resolution.method, + matchEvidence: resolution.candidates, poNumberNormalised: normalisePoNumber(note.po_number_raw ?? ""), checks, status: blocked ? "blocked" : "candidate", @@ -285,24 +314,58 @@ async function loadDeliveredByPoItem(pb: PocketBase): Promise { - if (!raw) return null; - // Suppliers print "#MEGCPO9165". normalisePoNumber strips the - // decoration so the lookup sees what we actually store. - const target = normalisePoNumber(raw); - if (!target) return null; - const candidates = (await pb.collection("purchase_orders").getFullList({ - fields: "id,purchase_order_number", - })) as unknown as { id: string; purchase_order_number: string }[]; + siteId: string, +): Promise { + const filter = safeSiteFilter(pb, siteId); + try { + return (await pb.collection("purchase_orders").getFullList({ + filter, + fields: "id,purchase_order_number,supplier,site,supplier_ref,required_by_date,status,processing_status", + expand: "supplier_ref", + })) as unknown as EntityPurchaseOrder[]; + } catch (err) { + // A missing optional relation/field in an older local PocketBase should + // block resolution rather than widen the query to every site. + return []; + } +} - const exact = candidates.find( - (c) => normalisePoNumber(c.purchase_order_number ?? "") === target, - ); - if (exact) return exact; - return findBestPoMatch(target, candidates, { threshold: 0.85 }) ?? null; +async function loadSuppliers(pb: PocketBase): Promise { + try { + return (await pb.collection("suppliers").getFullList({ + fields: "id,name", + })) as unknown as EntitySupplier[]; + } catch (err) { + // Supplier records were introduced after the legacy runner. POs still + // carry their provenance text, so preserve that compatibility path. + return []; + } +} + +async function loadSupplierAliases(pb: PocketBase): Promise { + try { + return (await pb.collection("supplier_document_aliases").getFullList({ + fields: "id,supplier,raw_value,normalised_key,approved_by", + })) as unknown as SupplierDocumentAlias[]; + } catch (err) { + // This is additive schema. An empty alias set retains the deterministic + // registration/name paths on installations before the migration. + return []; + } +} + +function safeSiteFilter(pb: PocketBase, siteId: string): string { + const client = pb as unknown as { + filter?: (raw: string, params: Record) => string; + }; + if (typeof client.filter === "function") { + return client.filter("site = {:site}", { site: siteId }); + } + // PocketBase's filter binding is present in production. This escaped + // fallback keeps lightweight test doubles and older SDK shims safe too. + return `site = ${JSON.stringify(siteId)}`; } async function upsertCandidate( @@ -311,6 +374,10 @@ async function upsertCandidate( note: import("./types").NoteSegment, extra: { poId: string | null; + supplierId: string | null; + supplierNameRaw: string | null; + matchMethod: EntityResolution["method"]; + matchEvidence: EntityResolution["candidates"]; poNumberNormalised: string; checks: CheckResult[]; status: "candidate" | "blocked"; @@ -346,6 +413,10 @@ async function upsertCandidate( po_number_raw: note.po_number_raw ?? "", po_number_normalised: extra.poNumberNormalised, purchase_order: extra.poId, + supplier: extra.supplierId, + supplier_name_raw: extra.supplierNameRaw ?? "", + match_method: extra.matchMethod, + match_evidence: extra.matchEvidence, gross_mass: note.gross_mass, tare_mass: note.tare_mass, nett_mass: note.nett_mass, @@ -372,4 +443,4 @@ async function upsertCandidate( : pb.collection("dn_batch_notes").create(form)); return { id: record.id as string, dismissed: wasDismissed }; -} \ No newline at end of file +} diff --git a/src/lib/po-matching.ts b/src/lib/po-matching.ts index f07b170..c8a740e 100644 --- a/src/lib/po-matching.ts +++ b/src/lib/po-matching.ts @@ -90,6 +90,33 @@ export type FindBestPoMatchOptions = { threshold?: number; }; +/** + * Return every non-empty PO candidate in descending similarity order. + * + * This keeps the legacy `findBestPoMatch` contract intact while giving + * callers that make safety decisions access to the runner-up and winner + * margin. A ranked fuzzy result is evidence for review, not permission to + * choose a financial relation. + */ +export function rankPoMatches(target: unknown, candidates: PoCandidate[]): PoMatch[] { + const t = normalisePoNumber(target); + if (t.length < 4) return []; + + return candidates + .map((candidate) => ({ + candidate, + value: normalisePoNumber(candidate.purchase_order_number), + })) + .filter((entry) => entry.value.length > 0) + .map(({ candidate, value }) => ({ + ...candidate, + score: levenshteinSimilarity(t, value), + })) + // Modern JS sort is stable, so equal scores retain the caller's order; + // this preserves the legacy findBestPoMatch tie behavior. + .sort((a, b) => b.score - a.score); +} + /** * Return the candidate PO whose normalised form is closest to the * target, or null if none exceed `threshold`. Both sides are @@ -108,22 +135,9 @@ export function findBestPoMatch( opts: FindBestPoMatchOptions = {}, ): PoMatch | null { const threshold = opts.threshold ?? 0.75; - const t = normalisePoNumber(target); - if (t.length < 4) return null; - - let bestScore = 0; - let bestHit: PoCandidate | null = null; - for (const c of candidates) { - const candidate = normalisePoNumber(c.purchase_order_number); - if (candidate.length === 0) continue; - const score = levenshteinSimilarity(t, candidate); - if (score > bestScore) { - bestScore = score; - bestHit = c; - } - } - if (!bestHit || bestScore < threshold) return null; - return { ...bestHit, score: bestScore }; + const best = rankPoMatches(target, candidates)[0]; + if (!best || best.score < threshold) return null; + return best; } export type ExtractPoCandidateOptions = {