An extracted document auto-links to a supplier by org_number, then by an exact (case-insensitive) name match. The extractor deliberately leaves orgNumber null unless the document carries a real Swedish organisationsnummer, so for every foreign supplier the name was the only key left: "ADOBE SYSTEMS SOFTWARE IRELAND LTD" prints nothing but a momsregistreringsnummer (IE6364992H), which the suppliers table already stores in vat_number and which no code path looked at. Adds vat_number as a match key between org_number and name, and collapses the five inlined copies of the lookup into lib/suppliers/match-supplier.ts: - invoice-inbox upload (sync and deferred worker) - invoice-inbox PUT /items/:id/extracted-data - MCP createDocumentInboxItem (org-nr only until now: gains VAT and name) - MCP gnubok_set_inbox_extracted_data - MCP gnubok_create_supplier_invoice_from_inbox, which additionally read supplierExt.organizationNumber, a key the extraction schema never writes, so its org-nr lookup could not fire at all VAT numbers are compared on a canonical key (uppercased alphanumerics), so formatting variants match and a prefix-less "556012579001" still matches "SE556012579001"; two different country prefixes never do. Also escapes LIKE metacharacters in the name lookup, so a supplier named "100 % Solutions" is no longer a wildcard pattern. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
126 lines
4.7 KiB
TypeScript
126 lines
4.7 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('matches a foreign supplier on VAT number when no org number exists', () => {
|
|
const withAdobe = [
|
|
...suppliers,
|
|
{
|
|
id: 'sup-adobe',
|
|
name: 'ADOBE SYSTEMS SOFTWARE IRELAND LTD',
|
|
org_number: null,
|
|
vat_number: 'IE6364992H',
|
|
},
|
|
]
|
|
const c = findSupplierCandidates(withAdobe, 'Adobe Ireland', null, 'IE 6364992 H')
|
|
expect(c[0]).toMatchObject({ supplier_id: 'sup-adobe', score: 1, matched_on: 'vat_number' })
|
|
})
|
|
|
|
it('does not match a VAT number against a different country', () => {
|
|
const withDe = [
|
|
...suppliers,
|
|
{ id: 'sup-de', name: 'Some GmbH', org_number: null, vat_number: 'DE6364992H' },
|
|
]
|
|
expect(findSupplierCandidates(withDe, null, null, 'IE6364992H')).toEqual([])
|
|
})
|
|
|
|
it('returns empty for no extracted signal', () => {
|
|
expect(findSupplierCandidates(suppliers, null, null)).toEqual([])
|
|
})
|
|
})
|