diff --git a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts index cc7316bc..85763b3e 100644 --- a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -57,6 +57,10 @@ function makeMock(opts: { } /** When provided, every pending_operations .insert(payload) is recorded here. */ inserts?: Array> + /** Served by the awaited (non-maybeSingle) suppliers list query — candidate search. */ + supplierList?: Array> + /** Served by the .single() defaults/tenancy fetch. Pass explicit null to simulate a supplier missing from the company. */ + supplierRecord?: Record | 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 } + next: { tool: string; args: Record } + } + + 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 } + } + + 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 () => { diff --git a/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts b/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts new file mode 100644 index 00000000..0cfffbab --- /dev/null +++ b/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts @@ -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([]) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 11a0bf91..7f1a604a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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' diff --git a/extensions/general/mcp-server/supplier-candidates.ts b/extensions/general/mcp-server/supplier-candidates.ts new file mode 100644 index 00000000..6aa038e4 --- /dev/null +++ b/extensions/general/mcp-server/supplier-candidates.ts @@ -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) +}