fix(dn): resolve supplier and PO within batch site

This commit is contained in:
DEls 2026-09-19 07:39:33 +02:00
parent 59c158ce37
commit 75a1d85444
5 changed files with 642 additions and 62 deletions

View file

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

View file

@ -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<string, unknown>;
}
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<string> {
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);
}

View file

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

View file

@ -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<RunBatc
const deliveredByPoItem = await loadDeliveredByPoItem(pb);
const aliases = (await pb
.collection("dn_item_aliases")
.getFullList({ fields: "id,supplier_key,slip_description_key,material_code" })
.getFullList({ fields: "id,supplier_key,slip_description_key,slip_product_code,material_code,po_description_key" })
.catch(() => [])) 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<RunBatc
};
const checks = runNoteChecks(note, checkCtx);
const lineMatches = resolveNoteLines(note, checkCtx);
const blocked = checks.some((c) => 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<Map<string, number
return out;
}
async function resolvePo(
async function loadSitePurchaseOrders(
pb: PocketBase,
raw: string | null,
): Promise<{ id: string; purchase_order_number: string } | null> {
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<EntityPurchaseOrder[]> {
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<EntitySupplier[]> {
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<SupplierDocumentAlias[]> {
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, unknown>) => 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 };
}
}

View file

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