fix(dn): resolve supplier and PO within batch site
This commit is contained in:
parent
59c158ce37
commit
75a1d85444
5 changed files with 642 additions and 62 deletions
120
src/lib/dn-pipeline/resolve-entities.test.ts
Normal file
120
src/lib/dn-pipeline/resolve-entities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
383
src/lib/dn-pipeline/resolve-entities.ts
Normal file
383
src/lib/dn-pipeline/resolve-entities.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue