feat(po-items): server-side Levenshtein fuzzy match against all POs
OCR is noisy. Instead of exact-then-fuzzy-fallback, always fetch all ~15 POs from PocketBase, normalise both sides (strip non- alphanumeric, lowercase), score each with Levenshtein similarity, and return the best match above 0.75 threshold. Single-char insert/delete (MEGCP09239 vs MEGCPO9239) scores ~0.9 — matches. Completely different POs (similarity <0.5) don't match. Plus the existing 'return 200 with empty items' fallback if no match — keeps the workflow loop alive even when OCR is bad.
This commit is contained in:
parent
901d7f2841
commit
2fcd7550de
1 changed files with 62 additions and 10 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue