diff --git a/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts b/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts index 777775e..9f96318 100644 --- a/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts +++ b/src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts @@ -19,6 +19,40 @@ import { pbAdmin } from "@/lib/pb"; * 200 { po: null, items: [], note: "po_not_found" } when PO isn't matched * 502 pb_lookup_failed */ + +/// +// / Levenshtein distance and similarity for fuzzy PO matching. +/// +function levenshteinDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = new Array(b.length + 1); + let curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min( + curr[j - 1] + 1, // insertion + prev[j] + 1, // deletion + prev[j - 1] + cost, // substitution + ); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} + +function levenshteinSimilarity(a: string, b: string): number { + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + return 1 - levenshteinDistance(a, b) / maxLen; +} + export async function GET( request: Request, { params }: { params: Promise<{ poNumber: string }> }, @@ -64,9 +98,15 @@ export async function GET( } if (!po) { - // Fuzzy fallback: strip non-alphanumeric, lowercase, compare. - const normalised = sanitisedPoNumber.replace(/[^a-z0-9]/gi, "").toLowerCase(); - if (normalised.length >= 3) { + // OCR is fuzzy: it sometimes drops characters ("MEGCP09239" + // instead of "MEGCPO9239"), adds prefixes/slashes ("MEGCP09150 / + // SRU 11987"), or picks up the wrong number from a noisy slip. + // We have ~15 POs in PocketBase (one supplier per PO), so it's + // cheap to score the OCR'd value against all of them and pick the + // best. Levenshtein on the normalised (alphanumeric, lowercase) + // string handles single-character insertions/deletions perfectly. + const target = sanitisedPoNumber.replace(/[^a-z0-9]/gi, "").toLowerCase(); + if (target.length >= 4) { try { const candidates = await pb .collection("purchase_orders") @@ -74,13 +114,25 @@ export async function GET( fields: "id,purchase_order_number,supplier", perPage: 500, }); - const hit = candidates.find( - (c) => - (c.purchase_order_number ?? "") - .replace(/[^a-z0-9]/gi, "") - .toLowerCase() === normalised, - ); - if (hit) po = hit as unknown as { id: string; purchase_order_number: string; supplier: string }; + let bestScore = 0; + let bestHit: { id: string; purchase_order_number: string; supplier: string } | null = null; + for (const c of candidates) { + const candidate = (c.purchase_order_number ?? "") + .replace(/[^a-z0-9]/gi, "") + .toLowerCase(); + if (candidate.length === 0) continue; + const score = levenshteinSimilarity(target, candidate); + if (score > bestScore) { + bestScore = score; + bestHit = c as unknown as { id: string; purchase_order_number: string; supplier: string }; + } + } + // Threshold tuned so single-char insert/delete still matches + // (similarity 0.9 for 10-char strings), but completely different + // POs (similarity < 0.5) don't. + if (bestHit && bestScore >= 0.75) { + po = bestHit; + } } catch (err) { console.error("[po-items] fuzzy lookup failed:", err); }