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
+56 -16
View File
@@ -21,7 +21,8 @@ import { ObservedTable } from '@/components/parties/ObservedTable'
import { PartyDossier } from '@/components/parties/PartyDossier'
import { ScbPickerDialog } from '@/components/parties/ScbPickerDialog'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { SuggestionQueue } from '@/components/parties/SuggestionQueue'
import { SuggestionQueue, isForeign } from '@/components/parties/SuggestionQueue'
import { RegistryReviewDialog } from '@/components/parties/RegistryReviewDialog'
import { hasHardKey } from '@/components/parties/format'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -78,6 +79,7 @@ function SuggestionsPage() {
const [refreshing, setRefreshing] = useState(false)
const [fetchingRegistry, setFetchingRegistry] = useState(false)
const [picker, setPicker] = useState<{ partyId: string; name: string } | null>(null)
const [review, setReview] = useState<RegisterRow[] | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [dossierId, setDossierId] = useState<string | null>(null)
const [dossierReload, setDossierReload] = useState(0)
@@ -331,7 +333,13 @@ function SuggestionsPage() {
const rows = register?.rows ?? []
const searching = debounced.length > 0
const selectedItems = rows.filter((r) => selected.has(r.id)).map((r) => ({ partyId: r.id, roles: rolesFor(r) }))
const missingOrg = rows.filter((r) => selected.has(r.id) && !r.orgNumber && r.kind !== 'person').length
// Rows SCB could complete but cannot yet (no org number), and rows it can
// never hold (the text places them abroad): two different sentences.
const foreignCount = rows.filter((r) => selected.has(r.id) && isForeign(r)).length
// Rows the review list can settle: suggestions SCB could hold that have
// no org number yet. Foreign rows and people are never asked for.
const reviewable = view === 'suggested' ? rows.filter((r) => !r.orgNumber && r.kind !== 'person' && !isForeign(r)) : []
const missingOrg = rows.filter((r) => selected.has(r.id) && !r.orgNumber && r.kind !== 'person' && !isForeign(r)).length
let attn: React.ReactNode = null
if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'observed') {
@@ -405,18 +413,6 @@ function SuggestionsPage() {
<p>{t('help')}</p>
</HelpPopover>
}
action={
<Button
type="button"
variant="outline"
onClick={() => void refreshSuggestions()}
disabled={!canWrite || refreshing}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" aria-hidden="true" /> : null}
{refreshing ? t('refreshing') : t('refresh')}
</Button>
}
/>
{attn}
@@ -432,6 +428,23 @@ function SuggestionsPage() {
/>
<ToolbarSearch value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
<div className="ml-auto flex items-center gap-2">
{scbEnabled && canWrite && reviewable.length > 0 ? (
<Button type="button" variant="outline" size="sm" onClick={() => setReview(reviewable)} disabled={busy}>
{t('review_open', { count: reviewable.length })}
</Button>
) : null}
{/* The queue builds itself; this is for the rare manual re-run. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void refreshSuggestions()}
disabled={!canWrite || refreshing}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" aria-hidden="true" /> : null}
{refreshing ? t('refreshing') : t('refresh')}
</Button>
<ContextPicker
items={periodItems}
value={period}
@@ -453,8 +466,14 @@ function SuggestionsPage() {
onOpenChange={setConfirmOpen}
title={t('promote_dialog_title', { count: selectedItems.length })}
description={
missingOrg > 0
? `${t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })} ${t('promote_dialog_missing_org', { missing: missingOrg, count: selectedItems.length })}`
missingOrg > 0 || foreignCount > 0
? [
t('promote_dialog_body', { detail: roleSummary(t, selectedItems) }),
missingOrg > 0 ? t('promote_dialog_missing_org', { missing: missingOrg, count: selectedItems.length }) : null,
foreignCount > 0 ? t('promote_dialog_foreign', { foreign: foreignCount, count: selectedItems.length }) : null,
]
.filter(Boolean)
.join(' ')
: t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })
}
confirmLabel={t('promote_n', { count: selectedItems.length })}
@@ -482,6 +501,27 @@ function SuggestionsPage() {
fetching={fetchingRegistry}
/>
{review ? (
<RegistryReviewDialog
open
rows={review}
onOpenChange={(open) => {
if (!open) {
setReview(null)
reload()
}
}}
onChoose={(row) => setPicker({ partyId: row.id, name: row.displayName })}
onApproved={(saved, failed) => {
toast({
title: t('review_done_title', { saved }),
description: failed > 0 ? `${t('review_done_description')} ${t('review_done_failed', { failed })}` : t('review_done_description'),
})
reload()
}}
/>
) : null}
{picker ? (
<ScbPickerDialog
open
@@ -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) })