feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox (#873)
* feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox When supplier resolution failed (no match on id, org_number, or exact name), the tool threw an opaque error — a dead end for the inbox pipeline on small ad hoc vendors, which are most of the backlog (agent.feedback). The error prose did mention supplier_id_override, but gave the agent no candidate ids and no machine-readable next step. Resolution failure now returns the staged-operation envelope with staged:false: preview carries the extracted supplier identity and near-miss candidates (normalized-name + org-digit matching — catches punctuation/legal-suffix OCR variants like "Polarn o Pyret" vs "Polarn O. Pyret AB", and formatted org numbers '556677-8899' vs '5566778899'); next hints either retry-with-override on the best candidate or a prefilled gnubok_create_supplier. Fuzzy scores never auto-resolve — the agent confirms against the underlag. Part of dev_docs/mcp_optimization_plan.md (P1-4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): review fixes — EF org-number forms, candidate-pool truncation flag, override tenancy validation Addresses the three substantive review-bot findings on #873: - orgNumberKey(): canonical 10-digit key. Swedish orgnr is exactly 10 digits; enskild firma org numbers are personnummer that appear in both 10- and 12-digit forms — exact digit-equality missed that legitimate match, and >= 10 accepted non-orgnr garbage lengths. - No silent caps: preview.candidate_pool_truncated + message note when the 500-supplier candidate pool is hit. - The defaults fetch now validates supplier existence in THIS company on every resolution path and rejects a bad supplier_id_override with a clear error — the unresolved next-hint actively promotes overrides, so a bogus id must fail at staging, not opaquely at commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
28827c2613
commit
001de3c844
@@ -57,6 +57,10 @@ function makeMock(opts: {
|
||||
}
|
||||
/** When provided, every pending_operations .insert(payload) is recorded here. */
|
||||
inserts?: Array<Record<string, unknown>>
|
||||
/** Served by the awaited (non-maybeSingle) suppliers list query — candidate search. */
|
||||
supplierList?: Array<Record<string, unknown>>
|
||||
/** Served by the .single() defaults/tenancy fetch. Pass explicit null to simulate a supplier missing from the company. */
|
||||
supplierRecord?: Record<string, unknown> | null
|
||||
}) {
|
||||
const inboxResult = { data: opts.inbox ?? null, error: opts.inbox ? null : { message: 'not found' } }
|
||||
const supplierByOrgResult = { data: opts.supplierByOrg ?? null, error: null }
|
||||
@@ -79,8 +83,17 @@ function makeMock(opts: {
|
||||
return Promise.resolve(supplierLookupCall === 1 ? supplierByOrgResult : supplierByNameResult)
|
||||
}
|
||||
}
|
||||
if (prop === 'single') {
|
||||
return () =>
|
||||
Promise.resolve(
|
||||
'supplierRecord' in opts
|
||||
? { data: opts.supplierRecord, error: opts.supplierRecord ? null : { message: 'not found' } }
|
||||
: { data: { id: 'resolved-supplier', default_expense_account: null }, error: null },
|
||||
)
|
||||
}
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(supplierByOrgResult)
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(opts.supplierList ? { data: opts.supplierList, error: null } : supplierByOrgResult)
|
||||
}
|
||||
return () => supplierChain()
|
||||
},
|
||||
@@ -252,7 +265,7 @@ describe('gnubok_create_supplier_invoice_from_inbox — execute', () => {
|
||||
).rejects.toThrow(/already converted/)
|
||||
})
|
||||
|
||||
it('throws when supplier cannot be resolved', async () => {
|
||||
it('unresolved supplier with no similar suppliers returns staged:false + create-supplier next hint', async () => {
|
||||
const supabase = makeMock({
|
||||
inbox: {
|
||||
id: 'inbox-4',
|
||||
@@ -266,9 +279,90 @@ describe('gnubok_create_supplier_invoice_from_inbox — execute', () => {
|
||||
supplierByName: null,
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
const result = (await tool.execute(
|
||||
{ inbox_item_id: 'inbox-4' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as {
|
||||
staged: boolean
|
||||
risk_level: string
|
||||
preview: { supplier_resolution: string; candidates: unknown[]; unresolved_supplier: Record<string, unknown> }
|
||||
next: { tool: string; args: Record<string, unknown> }
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.supplier_resolution).toBe('unresolved')
|
||||
expect(result.preview.candidates).toEqual([])
|
||||
expect(result.preview.unresolved_supplier).toEqual({
|
||||
extracted_name: 'Acme AB',
|
||||
extracted_org_number: '5566778899',
|
||||
})
|
||||
// Next hint prefills gnubok_create_supplier from the extraction.
|
||||
expect(result.next.tool).toBe('gnubok_create_supplier')
|
||||
expect(result.next.args).toEqual({ name: 'Acme AB', org_number: '5566778899' })
|
||||
})
|
||||
|
||||
it('unresolved supplier with a near-miss candidate returns it with a retry-with-override next hint', async () => {
|
||||
const supabase = makeMock({
|
||||
inbox: {
|
||||
id: 'inbox-8',
|
||||
status: 'received',
|
||||
extracted_data: {
|
||||
...baseExtracted,
|
||||
supplier: { name: 'Polarn o Pyret' }, // OCR variant: no punctuation, no AB, no org number
|
||||
},
|
||||
matched_supplier_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
document_id: 'doc-8',
|
||||
},
|
||||
supplierByOrg: null,
|
||||
supplierByName: null, // exact ilike on the full name misses the punctuation/suffix variant
|
||||
supplierList: [
|
||||
{ id: 'sup-dnb', name: 'DNB Bank AB', org_number: '5169077454' },
|
||||
{ id: 'sup-polarn', name: 'Polarn O. Pyret AB', org_number: '556235-8797' },
|
||||
],
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
const result = (await tool.execute(
|
||||
{ inbox_item_id: 'inbox-8' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as {
|
||||
staged: boolean
|
||||
preview: { candidates: Array<{ supplier_id: string; score: number; matched_on: string }> }
|
||||
next: { tool: string; args: Record<string, unknown> }
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.preview.candidates[0]).toMatchObject({
|
||||
supplier_id: 'sup-polarn',
|
||||
score: 1,
|
||||
matched_on: 'name',
|
||||
})
|
||||
// Next hint is the retry with the best candidate as override — the agent
|
||||
// confirms against the underlag; fuzzy never auto-resolves.
|
||||
expect(result.next.tool).toBe('gnubok_create_supplier_invoice_from_inbox')
|
||||
expect(result.next.args).toEqual({ inbox_item_id: 'inbox-8', supplier_id_override: 'sup-polarn' })
|
||||
})
|
||||
|
||||
it('rejects a supplier_id_override that does not exist in the company', async () => {
|
||||
const supabase = makeMock({
|
||||
inbox: {
|
||||
id: 'inbox-9',
|
||||
status: 'received',
|
||||
extracted_data: baseExtracted,
|
||||
matched_supplier_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
document_id: 'doc-9',
|
||||
},
|
||||
supplierRecord: null, // tenancy fetch finds nothing for the override id
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
await expect(
|
||||
tool.execute({ inbox_item_id: 'inbox-4', dry_run: true }, 'company-1', 'user-1', supabase),
|
||||
).rejects.toThrow(/Cannot resolve supplier/)
|
||||
tool.execute(
|
||||
{ inbox_item_id: 'inbox-9', supplier_id_override: 'sup-foreign' },
|
||||
'company-1', 'user-1', supabase,
|
||||
),
|
||||
).rejects.toThrow(/supplier_id_override sup-foreign does not match any supplier in this company/)
|
||||
})
|
||||
|
||||
it('applies line_overrides — overridden account wins over extracted accountSuggestion', async () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findSupplierCandidates,
|
||||
normalizeSupplierName,
|
||||
orgNumberKey,
|
||||
scoreSupplierName,
|
||||
} from '../supplier-candidates'
|
||||
|
||||
describe('orgNumberKey', () => {
|
||||
it('canonicalizes 10- and 12-digit forms to the same key', () => {
|
||||
// Enskild firma: org number IS the personnummer; extraction often yields
|
||||
// the 12-digit form while the register stores the 10-digit form.
|
||||
expect(orgNumberKey('19660101-1234')).toBe('6601011234')
|
||||
expect(orgNumberKey('660101-1234')).toBe('6601011234')
|
||||
expect(orgNumberKey('556677-8899')).toBe('5566778899')
|
||||
})
|
||||
|
||||
it('rejects lengths that are not Swedish org numbers', () => {
|
||||
expect(orgNumberKey('12345')).toBeNull()
|
||||
expect(orgNumberKey('12345678901')).toBeNull() // 11 digits
|
||||
expect(orgNumberKey('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSupplierName', () => {
|
||||
it('lowercases, strips punctuation and legal-form suffixes', () => {
|
||||
expect(normalizeSupplierName('Polarn O. Pyret AB')).toBe('polarn o pyret')
|
||||
expect(normalizeSupplierName('polarn o pyret')).toBe('polarn o pyret')
|
||||
expect(normalizeSupplierName('ECAB Bygg & VVS AB')).toBe('ecab bygg & vvs')
|
||||
expect(normalizeSupplierName('Städarna i Uppsala Aktiebolag')).toBe('städarna i uppsala')
|
||||
})
|
||||
|
||||
it('only strips the suffix at the end, not inside the name', () => {
|
||||
expect(normalizeSupplierName('AB Volvo')).toBe('ab volvo')
|
||||
expect(normalizeSupplierName('Habo Snickeri')).toBe('habo snickeri')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoreSupplierName', () => {
|
||||
it('scores the Polarn OCR-variant case as an exact normalized match', () => {
|
||||
expect(scoreSupplierName('Polarn o Pyret', 'Polarn O. Pyret AB')).toBe(1)
|
||||
})
|
||||
|
||||
it('scores containment at 0.9', () => {
|
||||
expect(scoreSupplierName('Polarn o Pyret', 'Polarn O Pyret Sverige AB')).toBe(0.9)
|
||||
})
|
||||
|
||||
it('scores partial token overlap below containment', () => {
|
||||
const s = scoreSupplierName('Bygg & VVS Norr', 'ECAB Bygg & VVS AB')
|
||||
expect(s).toBeGreaterThan(0)
|
||||
expect(s).toBeLessThan(0.9)
|
||||
})
|
||||
|
||||
it('scores unrelated names near zero', () => {
|
||||
expect(scoreSupplierName('Polarn o Pyret', 'DNB Bank AB')).toBeLessThan(0.4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSupplierCandidates', () => {
|
||||
const suppliers = [
|
||||
{ id: 'sup-polarn', name: 'Polarn O. Pyret AB', org_number: '556235-8797' },
|
||||
{ id: 'sup-dnb', name: 'DNB Bank AB', org_number: '5169077454' },
|
||||
{ id: 'sup-ecab', name: 'ECAB Bygg & VVS AB', org_number: null },
|
||||
]
|
||||
|
||||
it('matches org numbers across formatting variants at score 1', () => {
|
||||
const c = findSupplierCandidates(suppliers, null, '5562358797')
|
||||
expect(c).toHaveLength(1)
|
||||
expect(c[0]).toMatchObject({ supplier_id: 'sup-polarn', score: 1, matched_on: 'org_number' })
|
||||
})
|
||||
|
||||
it('matches a 12-digit personnummer extraction against a 10-digit stored EF org number', () => {
|
||||
const withEf = [...suppliers, { id: 'sup-ef', name: 'Eriks Snickeri', org_number: '660101-1234' }]
|
||||
const c = findSupplierCandidates(withEf, null, '19660101-1234')
|
||||
expect(c).toHaveLength(1)
|
||||
expect(c[0]).toMatchObject({ supplier_id: 'sup-ef', score: 1, matched_on: 'org_number' })
|
||||
})
|
||||
|
||||
it('surfaces the near-miss name candidate for the Polarn case', () => {
|
||||
const c = findSupplierCandidates(suppliers, 'Polarn o Pyret', null)
|
||||
expect(c[0]).toMatchObject({ supplier_id: 'sup-polarn', score: 1, matched_on: 'name' })
|
||||
})
|
||||
|
||||
it('applies the minScore threshold', () => {
|
||||
const c = findSupplierCandidates(suppliers, 'Helt Annat Företagsnamn', null)
|
||||
expect(c).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the candidate list and sorts by score descending', () => {
|
||||
const many = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `sup-${i}`,
|
||||
name: `Polarn o Pyret ${i}`,
|
||||
org_number: null,
|
||||
}))
|
||||
const c = findSupplierCandidates(many, 'Polarn o Pyret', null)
|
||||
expect(c).toHaveLength(5)
|
||||
expect(c.every((x, i, arr) => i === 0 || arr[i - 1].score >= x.score)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty for no extracted signal', () => {
|
||||
expect(findSupplierCandidates(suppliers, null, null)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import { findSupplierCandidates } from './supplier-candidates'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
@@ -7463,7 +7464,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_create_supplier_invoice_from_inbox',
|
||||
title: 'Create Supplier Invoice from Inbox',
|
||||
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run.",
|
||||
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run. Unresolved supplier → staged:false + candidates + next.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -7571,20 +7572,87 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
|
||||
if (!supplierId) {
|
||||
throw new Error(
|
||||
`Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).`
|
||||
// Structured resolution failure instead of a dead end (P1-4,
|
||||
// dev_docs/mcp_optimization_plan.md): a thrown error here stops the
|
||||
// whole inbox pipeline for small ad hoc vendors. Return staged:false
|
||||
// with near-miss candidates the agent can pass as supplier_id_override,
|
||||
// or a create-supplier next hint when nothing is close. Fuzzy scores
|
||||
// never auto-resolve — the agent/human confirms against the underlag.
|
||||
const extractedName = (supplierExt?.name as string | undefined) ?? null
|
||||
const extractedOrg = (supplierExt?.organizationNumber as string | undefined) ?? null
|
||||
|
||||
const CANDIDATE_POOL_CAP = 500
|
||||
const { data: companySuppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id, name, org_number')
|
||||
.eq('company_id', companyId)
|
||||
.limit(CANDIDATE_POOL_CAP)
|
||||
|
||||
const candidates = findSupplierCandidates(
|
||||
(companySuppliers ?? []) as { id: string; name: string; org_number: string | null }[],
|
||||
extractedName,
|
||||
extractedOrg,
|
||||
)
|
||||
const best = candidates[0]
|
||||
// No silent caps: past the pool cap the right supplier may exist yet
|
||||
// be absent from candidates — say so instead of implying full coverage.
|
||||
const poolTruncated = (companySuppliers?.length ?? 0) >= CANDIDATE_POOL_CAP
|
||||
|
||||
return {
|
||||
staged: false,
|
||||
risk_level: getRiskLevel('create_supplier_invoice_from_inbox'),
|
||||
actor: actor ?? { type: 'user' },
|
||||
message: (best
|
||||
? `Could not resolve supplier "${extractedName ?? 'unknown'}" exactly — ${candidates.length} near-miss candidate(s) in preview.candidates. Verify against the underlag, then retry with supplier_id_override; or create the supplier first.`
|
||||
: `Could not resolve supplier "${extractedName ?? 'unknown'}" (org: ${extractedOrg ?? 'unknown'}) and no similar supplier exists. Create it with gnubok_create_supplier, then retry with supplier_id_override.`)
|
||||
+ (poolTruncated ? ` Note: candidate search covered only the first ${CANDIDATE_POOL_CAP} suppliers — the pool was truncated.` : ''),
|
||||
preview: {
|
||||
supplier_resolution: 'unresolved',
|
||||
unresolved_supplier: {
|
||||
extracted_name: extractedName,
|
||||
extracted_org_number: extractedOrg,
|
||||
},
|
||||
candidates,
|
||||
candidate_pool_truncated: poolTruncated,
|
||||
},
|
||||
next: best
|
||||
? {
|
||||
description: `Closest existing supplier: "${best.name}" (score ${best.score}). If it matches the underlag, retry with this supplier_id_override.`,
|
||||
tool: 'gnubok_create_supplier_invoice_from_inbox',
|
||||
args: { inbox_item_id: inboxItemId, supplier_id_override: best.supplier_id },
|
||||
}
|
||||
: {
|
||||
description:
|
||||
'Create the supplier, approve it, then retry this tool with supplier_id_override set to the new supplier id.',
|
||||
tool: 'gnubok_create_supplier',
|
||||
args: {
|
||||
...(extractedName ? { name: extractedName } : {}),
|
||||
...(extractedOrg ? { org_number: extractedOrg } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch supplier defaults so line items can inherit default_expense_account
|
||||
// when neither the extraction nor the agent provided an accountSuggestion.
|
||||
// Doubles as existence/tenancy validation: every resolution path — and
|
||||
// especially supplier_id_override, which the unresolved next-hint now
|
||||
// actively promotes — must point at a supplier in THIS company, or the
|
||||
// staged operation would fail opaquely at commit time instead.
|
||||
const { data: resolvedSupplier } = await supabase
|
||||
.from('suppliers')
|
||||
.select('default_expense_account')
|
||||
.select('id, default_expense_account')
|
||||
.eq('id', supplierId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const supplierDefaultExpenseAccount = resolvedSupplier?.default_expense_account ?? null
|
||||
if (!resolvedSupplier) {
|
||||
throw new Error(
|
||||
supplierResolution === 'override'
|
||||
? `supplier_id_override ${supplierId} does not match any supplier in this company. Use a supplier_id from preview.candidates or gnubok_list_suppliers.`
|
||||
: `Resolved supplier ${supplierId} no longer exists in this company — re-run extraction or pass supplier_id_override.`,
|
||||
)
|
||||
}
|
||||
const supplierDefaultExpenseAccount = resolvedSupplier.default_expense_account ?? null
|
||||
|
||||
// Assemble core invoice fields
|
||||
const currency = (invoiceExt?.currency as string) || 'SEK'
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Fuzzy supplier-candidate matching for gnubok_create_supplier_invoice_from_inbox.
|
||||
*
|
||||
* Exact resolution (matched id → org_number → full-name ilike) misses the
|
||||
* common OCR variants: punctuation ("Polarn O. Pyret" vs "Polarn o Pyret"),
|
||||
* legal-form suffixes ("… AB"), and formatted org numbers ("556677-8899" vs
|
||||
* "5566778899"). When resolution fails, the tool surfaces near-miss candidates
|
||||
* from these matchers so the agent can retry with supplier_id_override instead
|
||||
* of dead-ending — the agent (or the approving human) makes the final call,
|
||||
* fuzzy scores never auto-resolve.
|
||||
*/
|
||||
|
||||
export type SupplierRow = {
|
||||
id: string
|
||||
name: string
|
||||
org_number: string | null
|
||||
}
|
||||
|
||||
export type SupplierCandidate = {
|
||||
supplier_id: string
|
||||
name: string
|
||||
org_number: string | null
|
||||
score: number
|
||||
matched_on: 'org_number' | 'name'
|
||||
}
|
||||
|
||||
// Legal-form suffixes carry no identity signal and OCR/extraction includes
|
||||
// them inconsistently. Longest-first so 'aktiebolag' is stripped before 'ab'.
|
||||
const LEGAL_SUFFIXES = [
|
||||
'ekonomisk förening',
|
||||
'kommanditbolag',
|
||||
'handelsbolag',
|
||||
'aktiebolag',
|
||||
'ek för',
|
||||
'filial',
|
||||
'ekf',
|
||||
'ab',
|
||||
'hb',
|
||||
'kb',
|
||||
]
|
||||
|
||||
export function normalizeSupplierName(raw: string): string {
|
||||
let s = raw
|
||||
.toLowerCase()
|
||||
// Punctuation → space; keep letters (incl. åäöé), digits, ampersand.
|
||||
.replace(/[^a-z0-9åäöéü&]+/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
for (const suffix of LEGAL_SUFFIXES) {
|
||||
if (s.endsWith(` ${suffix}`)) {
|
||||
s = s.slice(0, -suffix.length - 1).trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function digitsOnly(s: string): string {
|
||||
return s.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical 10-digit key for a Swedish org number. Orgnr is exactly 10
|
||||
* significant digits; enskild firma uses the owner's personnummer, which
|
||||
* appears in both 10-digit (YYMMDDXXXX) and 12-digit (YYYYMMDDXXXX) forms —
|
||||
* the last 10 digits are the same identifier. Anything else is not a Swedish
|
||||
* org number and must not fuzzy-match.
|
||||
*/
|
||||
export function orgNumberKey(raw: string): string | null {
|
||||
const d = digitsOnly(raw)
|
||||
if (d.length === 10) return d
|
||||
if (d.length === 12) return d.slice(-10)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Similarity in [0, 1]. Exact normalized match = 1; containment
|
||||
* ("polarn o pyret" ⊂ "polarn o pyret sverige") = 0.9; otherwise token
|
||||
* Jaccard scaled to max 0.8 so partial overlaps never outrank containment.
|
||||
*/
|
||||
export function scoreSupplierName(a: string, b: string): number {
|
||||
const na = normalizeSupplierName(a)
|
||||
const nb = normalizeSupplierName(b)
|
||||
if (!na || !nb) return 0
|
||||
if (na === nb) return 1
|
||||
if (na.includes(nb) || nb.includes(na)) return 0.9
|
||||
const ta = new Set(na.split(' '))
|
||||
const tb = new Set(nb.split(' '))
|
||||
let intersection = 0
|
||||
for (const t of ta) if (tb.has(t)) intersection++
|
||||
const union = new Set([...ta, ...tb]).size
|
||||
return union === 0 ? 0 : Math.round((intersection / union) * 0.8 * 100) / 100
|
||||
}
|
||||
|
||||
export function findSupplierCandidates(
|
||||
suppliers: SupplierRow[],
|
||||
extractedName: string | null,
|
||||
extractedOrgNumber: string | null,
|
||||
options: { limit?: number; minScore?: number } = {},
|
||||
): SupplierCandidate[] {
|
||||
const limit = options.limit ?? 5
|
||||
const minScore = options.minScore ?? 0.4
|
||||
const extractedOrgKey = extractedOrgNumber ? orgNumberKey(extractedOrgNumber) : null
|
||||
|
||||
const scored: SupplierCandidate[] = []
|
||||
for (const s of suppliers) {
|
||||
// Canonical-key equality catches formatting variants ('556677-8899' vs
|
||||
// '5566778899') and the 10- vs 12-digit personnummer forms of enskild
|
||||
// firma org numbers — none of which the exact .eq() lookup upstream can.
|
||||
if (extractedOrgKey && s.org_number && orgNumberKey(s.org_number) === extractedOrgKey) {
|
||||
scored.push({
|
||||
supplier_id: s.id,
|
||||
name: s.name,
|
||||
org_number: s.org_number,
|
||||
score: 1,
|
||||
matched_on: 'org_number',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (extractedName) {
|
||||
const score = scoreSupplierName(extractedName, s.name)
|
||||
if (score >= minScore) {
|
||||
scored.push({
|
||||
supplier_id: s.id,
|
||||
name: s.name,
|
||||
org_number: s.org_number,
|
||||
score,
|
||||
matched_on: 'name',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
Reference in New Issue
Block a user