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:
Jakob Wennberg
2026-09-04 17:01:10 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent be0478c219
commit c6ca119e73
18 changed files with 1014 additions and 41 deletions
@@ -16,6 +16,13 @@ vi.mock('@/lib/parties/scb/client', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/parties/scb/client')>()),
createScbClient: () => ({ lookupByOrgNumber, searchByName }),
}))
const readCounterpartName = vi.fn()
const ai = { available: false, capability: true }
vi.mock('@/lib/entitlements/has-capability', () => ({ hasCapability: () => Promise.resolve(ai.capability) }))
vi.mock('@/lib/parties/ai-name', () => ({
readCounterpartName: (texts: string[]) => readCounterpartName(texts),
aiNameAvailable: () => ai.available,
}))
const configured = { value: true }
vi.mock('@/lib/parties/scb/config', () => ({
isScbConfigured: () => configured.value,
@@ -40,6 +47,8 @@ beforeEach(() => {
reset()
eventBus.clear()
configured.value = true
ai.available = false
ai.capability = true
mockSupabase.auth.getUser.mockResolvedValue({ data: { user } })
})
@@ -153,7 +162,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
searchByName.mockResolvedValue(result)
const a = await parseJsonResponse<{ data: typeof result }>(await candidates())
expect(a.status).toBe(200)
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null })
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null, aiRead: null })
expect(searchByName).toHaveBeenLastCalledWith('Adobe Systems Software')
enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } })
await candidates('Adobe Nordic')
@@ -165,7 +174,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
const hit = { query: 'TIC identity', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'TIC Identity AB', active: true }] }
enqueue({ data: { id: PARTY, display_name: 'TIC identity', legal_name: null } })
enqueue({
data: [{ value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
data: [{ field: 'voucher_text', value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
})
searchByName.mockResolvedValueOnce(miss).mockResolvedValueOnce(hit)
const { status, body } = await parseJsonResponse<{ data: { query: string; queries: string[]; candidates: unknown[] } }>(await candidates())
@@ -175,9 +184,56 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
expect(body.data.candidates).toHaveLength(1)
})
it('lets the model read a bank memo once, keeps the reading as a fact, and searches on it', async () => {
ai.available = true
readCounterpartName.mockResolvedValue({ name: 'Booking.com', country: 'NL', vatNumber: null, confidence: 'high', model: 'm' })
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [{ field: 'voucher_text', value: ['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'] }] })
enqueue({ data: { recorded: 1 } }) // record_party_facts
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; foreign: unknown; aiRead: unknown; candidates: unknown[] } }>(await candidates())
expect(status).toBe(200)
expect(readCounterpartName).toHaveBeenCalledWith(['Hotel at Booking.com', 'Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
// A Dutch reading: no SCB call, the picker explains, the reading is shown.
expect(searchByName).not.toHaveBeenCalled()
expect(body.data.foreign).toEqual({ name: 'Booking.com', country: 'NL' })
expect(body.data.aiRead).toEqual({ name: 'Booking.com', country: 'NL' })
const rpc = mockSupabase.rpc.mock.calls.find((c) => c[0] === 'record_party_facts')
expect(rpc?.[1]).toMatchObject({ p_source: 'model', p_party_id: PARTY, p_facts: [{ field: 'ai_name' }] })
// Cached: no second model call, and a Swedish reading is searched for.
readCounterpartName.mockClear()
enqueue({ data: { id: PARTY, display_name: 'UBER *TRIP HELP.UBER.COM', legal_name: null } })
enqueue({ data: [{ field: 'ai_name', value: { name: 'Uber Sweden AB', country: 'SE', vatNumber: null, confidence: 'high', model: 'm' } }] })
searchByName.mockResolvedValue({ query: 'Uber Sweden', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'Uber Sweden AB', active: true }] })
const second = await parseJsonResponse<{ data: { queries: string[]; aiRead: unknown } }>(await candidates())
expect(readCounterpartName).not.toHaveBeenCalled()
expect(searchByName).toHaveBeenLastCalledWith('Uber Sweden')
expect(second.body.data.aiRead).toEqual({ name: 'Uber Sweden AB', country: 'SE' })
})
it('does not call the model when the rules already anchored a name, when the company lacks the AI capability, or when no model is configured', async () => {
ai.available = true
enqueue({ data: { id: PARTY, display_name: 'Visma Spcs AB', legal_name: null } })
enqueue({ data: [] })
searchByName.mockResolvedValue({ query: 'Visma Spcs', mode: 'starts_with', total: 1, truncated: false, candidates: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
ai.capability = false
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
ai.capability = true
ai.available = false
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
})
it('never asks SCB about a foreign company, and says which one it read', async () => {
enqueue({ data: { id: PARTY, display_name: 'Framer B.V.', legal_name: null } })
enqueue({ data: [{ value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
enqueue({ data: [{ field: 'voucher_text', value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; candidates: unknown[]; foreign: unknown } }>(await candidates())
expect(status).toBe(200)
expect(searchByName).not.toHaveBeenCalled()
+51 -10
View File
@@ -6,7 +6,10 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createScbClient, type ScbSearchResult } from '@/lib/parties/scb/client'
import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config'
import { ScbApiError } from '@/lib/parties/scb/transport'
import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
import { needsModelReading, planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
import { readCounterpartName, aiNameAvailable, type AiNameReading } from '@/lib/parties/ai-name'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
/**
* GET /api/parties/[id]/enrich/candidates?q=: SCB companies whose name
@@ -19,7 +22,7 @@ import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/partie
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'parties.enrich.candidates',
async (request, { supabase, companyId, log, requestId }, { params }) => {
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const validated = validateQuery(request, PartySearchRegistryQuerySchema, { log, operation: 'parties.enrich.candidates' })
@@ -40,21 +43,58 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
const explicit = validated.data.q?.trim()
let queries: string[]
let foreign: RegistryCandidatesResult['foreign'] = null
let aiRead: RegistryCandidatesResult['aiRead'] = null
if (explicit) {
queries = [explicit]
} else {
const { data: textFacts, error: factsError } = await supabase
const { data: facts, error: factsError } = await supabase
.from('party_facts')
.select('value')
.select('field, value')
.eq('company_id', companyId)
.eq('party_id', id)
.eq('field', 'voucher_text')
.in('field', ['voucher_text', 'ai_name'])
.is('superseded_at', null)
if (factsError) throw new Error(`party_facts lookup failed: ${factsError.message}`)
const voucherTexts = ((textFacts ?? []) as Array<{ value: unknown }>).flatMap((f) =>
Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : [],
)
const plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
const rows = (facts ?? []) as Array<{ field: string; value: unknown }>
const voucherTexts = rows
.filter((f) => f.field === 'voucher_text')
.flatMap((f) => (Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : []))
let plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
// A bank memo the rules could not anchor: the model reads it once,
// the reading is kept as a fact with source 'model', and the search
// runs on the reading. A rebuilt queue does not repeat the call. Same
// gate as every other model call on the company's data: the AI
// capability, which the company holds by plan and can switch off.
if (needsModelReading(plan) && aiNameAvailable() && (await hasCapability(supabase, companyId, CAPABILITY.ai))) {
const cached = rows.find((f) => f.field === 'ai_name')?.value as Partial<AiNameReading> | undefined
let reading: AiNameReading | null =
cached && typeof cached === 'object' && 'name' in cached
? { name: cached.name ?? null, country: cached.country ?? null, vatNumber: cached.vatNumber ?? null, confidence: cached.confidence ?? 'low', model: cached.model ?? '' }
: null
if (!reading) {
reading = await readCounterpartName([p.display_name, ...voucherTexts])
if (reading) {
const { error: recordError } = await supabase.rpc('record_party_facts', {
p_company_id: companyId,
p_user_id: user.id,
p_party_id: id,
p_source: 'model',
p_facts: [{ field: 'ai_name', value: reading, reference: { model: reading.model, texts: voucherTexts.length } }],
p_fetched_at: new Date().toISOString(),
})
if (recordError) log.warn('record_party_facts (model reading) failed', { partyId: id, message: recordError.message })
}
}
if (reading?.name) {
aiRead = { name: reading.name, country: reading.country }
const readPlan = planRegistryQueries({ legalName: reading.name, displayName: p.display_name, voucherTexts: [] })
plan =
reading.country && reading.country !== 'SE' && !readPlan.candidates.some((c) => c.source === 'legal_form' && !c.foreign)
? { queries: [], foreign: { name: reading.name, country: reading.country }, candidates: readPlan.candidates }
: readPlan
}
}
queries = plan.queries
foreign = plan.foreign
}
@@ -68,6 +108,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
candidates: [],
queries: [],
foreign,
aiRead,
}
return NextResponse.json({ data: empty })
}
@@ -79,7 +120,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
last = await client.searchByName(q)
if (last.candidates.length > 0 || last.truncated) break
}
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign }
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign, aiRead }
return NextResponse.json({ data: result })
} catch (err) {
log.warn('scb search failed', { partyId: id, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) })