Files
accounted/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts
T
Jakob WennbergandClaude Fable 5 001de3c844 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>
2026-07-03 10:14:16 +02:00

104 lines
4.0 KiB
TypeScript

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([])
})
})