feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)
* fix(parties): one suggestion per legal person, and a later run may rename an untouched one
Found while walking the queue end to end: two voucher keys naming the same
company ("TIC identity · … The Intelligence Company AB (publ)" and
"Utbetalning leverantörsfaktura …, The Intelligence Company AB (publ)")
became two suggestions and, after Lägg upp, two suppliers; and a suggestion
made before the legal-form anchoring kept its sentence-long name for good,
because apply_party_suggestions never touched a name.
- Suggestions whose display name is anchored on a legal form read out of
the voucher text (name_anchored) are grouped: one item, both keys as
aliases, stats summed. Such a name also attaches to an existing party
called exactly that, legal form included, unless an org number on either
side says otherwise. Registered company names are unique in Sweden; a
bank memo never groups or attaches by name.
- Migration 20260904030000: apply_party_suggestions renames a suggestion
nobody has touched (no decision, no user or registry fact) to an anchored
name from a later run, and reports 'renamed'. Confirmed and decided
parties keep their names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(parties): read legal_name for exact-name attach; say a row is foreign instead of offering SCB
next build: ExistingParty had no legal_name, so the exact-legal-name index
did not compile. The query now selects it.
Queue rows whose voucher text places the company abroad show
"Utländskt bolag (Nederländerna), finns inte i SCB" instead of a search
that cannot succeed; the promote dialog counts them separately from rows
that merely lack an org number; the dossier shows the country.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(parties): carry country on the dossier row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(parties): one review list for SCB matches, a model reading for bank memos, refresh demoted
- Review list ("Hitta org.nr (n)" in the queue toolbar): every suggestion
SCB could hold but that lacks an org number is asked for, one row at a
time under SCB's rate limit; rows with exactly one active match are
shown ticked and approved in one click, the rest keep the per-row
picker. Nothing is written before the click.
- Model reading (lib/parties/ai-name.ts, through getAiService): when the
rules find no legal form or country in the texts, one call reads the
counterpart out of the bank memo; kept as a 'model' fact, shown as
"Läst ur verifikatet", used as the query, never as a hard key. On
demand only, never when the queue builds.
- "Uppdatera förslag" moves from the page header to a ghost button in the
toolbar: the queue builds itself now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(parties): review list passes the dialog overflow guard; plural for match counts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(parties): gate the model reading on the company's AI capability
Same gate as every other model call on company data: the capability the
company holds by plan and can switch off. No call, no fact, no reading
without it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
be0478c219
commit
c6ca119e73
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const generateStructured = vi.fn()
|
||||
const status = { configured: true }
|
||||
vi.mock('@/lib/ai', () => ({
|
||||
getAiService: () => ({ generateStructured }),
|
||||
getAiStatus: () => ({ configured: status.configured }),
|
||||
}))
|
||||
|
||||
import { readCounterpartName, aiNameAvailable } from '../ai-name'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
status.configured = true
|
||||
})
|
||||
|
||||
describe('readCounterpartName', () => {
|
||||
it('reads the counterpart out of a card memo and normalises country and VAT', async () => {
|
||||
generateStructured.mockResolvedValue({
|
||||
value: { name: 'Booking.com', country: 'nl', vat_number: 'NL 805734958 B01', confidence: 'high' },
|
||||
model: 'test-model',
|
||||
usage: {},
|
||||
})
|
||||
const r = await readCounterpartName(['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
|
||||
expect(r).toEqual({ name: 'Booking.com', country: 'NL', vatNumber: 'NL805734958B01', confidence: 'high', model: 'test-model' })
|
||||
const req = generateStructured.mock.calls[0]![0] as { tier: string; prompt: string; schema: { name: string } }
|
||||
expect(req.tier).toBe('extraction')
|
||||
expect(req.prompt).toContain('1. Hotel at Booking.com')
|
||||
expect(req.schema.name).toBe('counterpart_reading')
|
||||
})
|
||||
|
||||
it('keeps a null name, drops malformed country and VAT values, and sends at most three distinct texts', async () => {
|
||||
generateStructured.mockResolvedValue({ value: { name: null, country: 'Sweden', vat_number: '123', confidence: 'weird' }, model: 'm', usage: {} })
|
||||
const r = await readCounterpartName(['a', 'a', 'b', 'c', 'd'])
|
||||
expect(r).toEqual({ name: null, country: null, vatNumber: null, confidence: 'low', model: 'm' })
|
||||
const req = generateStructured.mock.calls[0]![0] as { prompt: string }
|
||||
expect(req.prompt).toContain('3. c')
|
||||
expect(req.prompt).not.toContain('4. d')
|
||||
})
|
||||
|
||||
it('answers null without a call when the deployment has no model, on empty input, on a bad answer, and on an error', async () => {
|
||||
status.configured = false
|
||||
expect(aiNameAvailable()).toBe(false)
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
expect(generateStructured).not.toHaveBeenCalled()
|
||||
status.configured = true
|
||||
expect(await readCounterpartName(['', ' '])).toBeNull()
|
||||
generateStructured.mockResolvedValueOnce({ value: 'not an object', model: 'm', usage: {} })
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
generateStructured.mockRejectedValueOnce(new Error('boom'))
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,45 @@ describe('buildSuggestions', () => {
|
||||
expect(revenue.facts.some((f) => f.field === 'vat_number')).toBe(false)
|
||||
})
|
||||
|
||||
it('groups keys that name the same legal person into one suggestion, and attaches to an existing party by exact legal name', () => {
|
||||
const tic1 = 'TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'
|
||||
const tic2 = 'Utbetalning leverantörsfaktura 20250928, The Intelligence Company AB (publ)'
|
||||
const r = buildSuggestions({
|
||||
observed: [
|
||||
observed({ key: 'tic identity', name: tic1, expense_sek: 2385, occurrences: 1, first_seen: '2026-02-01', last_seen: '2026-02-01' }),
|
||||
observed({ key: 'utbetalning leverantörsfaktura the intelligence company publ', name: tic2, expense_sek: 2385, occurrences: 1, first_seen: '2026-03-01', last_seen: '2026-03-01' }),
|
||||
],
|
||||
evidence: [],
|
||||
existing: [],
|
||||
})
|
||||
expect(r.items).toHaveLength(1)
|
||||
const item = r.items[0]!
|
||||
expect(item.display_name).toBe('The Intelligence Company AB (publ)')
|
||||
expect(item.name_anchored).toBe(true)
|
||||
expect(item.alias_keys).toEqual(['tic identity', 'utbetalning leverantörsfaktura the intelligence company publ'])
|
||||
expect(item.reason.occurrences).toBe(2)
|
||||
expect(item.reason.expense_sek).toBe(4770)
|
||||
expect(item.reason.first_seen).toBe('2026-02-01')
|
||||
expect(item.reason.last_seen).toBe('2026-03-01')
|
||||
|
||||
const confirmed: ExistingParty = { id: 'p-tic', display_name: 'The Intelligence Company AB (publ)', org_number: '5594871682', alias_keys: [], status: 'confirmed' }
|
||||
const attached = buildSuggestions({ observed: [observed({ key: 'tic identity', name: tic1 })], evidence: [], existing: [confirmed] }).items[0]!
|
||||
expect(attached.party_id).toBe('p-tic')
|
||||
expect(attached.reason.attach).toBe('legal_name')
|
||||
|
||||
// A different org number on the key side is a different company with a confusable name.
|
||||
const other = buildSuggestions({
|
||||
observed: [observed({ key: 'tic identity', name: tic1 })],
|
||||
evidence: [{ key: 'tic identity', docs: 1, self_docs: 0, orgs: [{ org: '5560125790', n: 1 }], vat_numbers: [], names: [], bankgiro: [], plusgiro: [] }],
|
||||
existing: [confirmed],
|
||||
}).items[0]!
|
||||
expect(other.party_id).toBeUndefined()
|
||||
// A bank memo head never groups or attaches by name.
|
||||
const memo = buildSuggestions({ observed: [observed({ key: 'beijer byggmaterial', name: 'BEIJER BYGGMATERIAL 2089' })], evidence: [], existing: [{ id: 'p-b', display_name: 'BEIJER BYGGMATERIAL', org_number: null, alias_keys: [], status: 'confirmed' }] }).items[0]!
|
||||
expect(memo.party_id).toBeUndefined()
|
||||
expect(memo.name_anchored).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withholds the hard key and identities when a key mixes two org numbers', () => {
|
||||
const r = buildSuggestions({
|
||||
observed: [observed({ key: 'vattenfall' })],
|
||||
|
||||
Reference in New Issue
Block a user