From c6ca119e737cb0f22ff691f863fbb101dd3ae792 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 4 Sep 2026 17:01:10 +0200 Subject: [PATCH] feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * fix(parties): carry country on the dossier row Co-Authored-By: Claude Fable 5.1 * 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 * fix(parties): review list passes the dialog overflow guard; plural for match counts Co-Authored-By: Claude Fable 5.1 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/(dashboard)/parties/page.tsx | 72 +++-- .../[id]/enrich/__tests__/route.test.ts | 62 ++++- .../parties/[id]/enrich/candidates/route.ts | 61 ++++- components/parties/PartyDossier.tsx | 7 +- components/parties/RegistryReviewDialog.tsx | 250 ++++++++++++++++++ components/parties/ScbPickerDialog.tsx | 1 + components/parties/SuggestionQueue.tsx | 23 +- lib/parties/__tests__/ai-name.test.ts | 53 ++++ lib/parties/__tests__/suggest.test.ts | 39 +++ lib/parties/ai-name.ts | 105 ++++++++ lib/parties/register.ts | 26 +- lib/parties/registry-search.ts | 11 + lib/parties/suggest.ts | 88 +++++- messages/en.json | 24 ++ messages/sv.json | 24 ++ ...000_party_suggestions_rename_untouched.sql | 179 +++++++++++++ tests/pg/party-suggestions.pg.test.ts | 29 +- 18 files changed, 1014 insertions(+), 41 deletions(-) create mode 100644 components/parties/RegistryReviewDialog.tsx create mode 100644 lib/parties/__tests__/ai-name.test.ts create mode 100644 lib/parties/ai-name.ts create mode 100644 supabase/migrations/20260904030000_party_suggestions_rename_untouched.sql diff --git a/DECISIONS.md b/DECISIONS.md index ba51db2d..2b4f8ddd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1568,3 +1568,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Per-invoice payee migrations re-issued as 20260904010000 and 20260904011000 (were 20260903150000 / 20260903193000, merged in #2233 but never applied): the backfill's INSERT into invoice_payee_defaults fired the mirror into company_settings for a company that is a migration-reset source, whose rows are immutable by trigger, so the whole migration rolled back on prod and every later migration queued behind it. Same pattern as #2249: skip company_migration_resets sources in the backfill and re-issue under a fresh version rather than edit the failed file in place, so any environment that did apply the old version (staging, by hand) is reconciled by renaming its schema_migrations row instead of diverging silently. [2026-09-04] ROT/RUT payout matching models the begäran (rot_rut_payout_requests), not the invoice, as the bank-row match candidate: Skatteverket pays one lump sum per begäran covering several invoices, remaining_amount is net of the deduction so a paid ROT/RUT invoice can never match, and 1513 clears per request. Confirm reuses the settle service with the transaction linked in the same call; its own dialog (RotRutPayoutMatchDialog) rather than a third branch in InvoiceMatchDialog, which carries FX/preview/edit logic this two-leg entry never needs. [2026-09-04] Parties name extraction is rule-based first (legal-form and country anchors in lib/parties/name-extract.ts), no LLM in the batch: every candidate is a substring of the voucher text, testable and free; an AI read for the leftovers (bank memos with no anchor) waits for the founder's call on automatic vs on-click. The picker makes no SCB call when the best reading is a foreign company: the register holds Swedish legal persons only, so a search there can only mislead. +[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names. diff --git a/app/(dashboard)/parties/page.tsx b/app/(dashboard)/parties/page.tsx index fd3e25f6..4b2ea0f4 100644 --- a/app/(dashboard)/parties/page.tsx +++ b/app/(dashboard)/parties/page.tsx @@ -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(null) const [confirmOpen, setConfirmOpen] = useState(false) const [dossierId, setDossierId] = useState(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() {

{t('help')}

} - action={ - - } /> {attn} @@ -432,6 +428,23 @@ function SuggestionsPage() { /> setQuery(e.target.value)} placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
+ {scbEnabled && canWrite && reviewable.length > 0 ? ( + + ) : null} + {/* The queue builds itself; this is for the rare manual re-run. */} + 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 ? ( + { + 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 ? ( ({ ...(await importOriginal()), 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() diff --git a/app/api/parties/[id]/enrich/candidates/route.ts b/app/api/parties/[id]/enrich/candidates/route.ts index d4a9daa2..eab49f0d 100644 --- a/app/api/parties/[id]/enrich/candidates/route.ts +++ b/app/api/parties/[id]/enrich/candidates/route.ts @@ -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 | 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) }) diff --git a/components/parties/PartyDossier.tsx b/components/parties/PartyDossier.tsx index b7181b1d..90e91d7f 100644 --- a/components/parties/PartyDossier.tsx +++ b/components/parties/PartyDossier.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { MoreHorizontal } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -12,6 +12,7 @@ import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/c import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register' import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils' import { AccountNub } from './AccountNub' +import { regionName } from './SuggestionQueue' import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format' import type { MergeCandidate } from './MergeDialog' @@ -118,6 +119,7 @@ export function PartyDossier({ reloadKey: number }) { const t = useTranslations('parties') + const locale = useLocale() // { partyId, reloadKey } stamps the loaded dossier, so "loading" and // "failed" are derived instead of set from inside the effect. const [loaded, setLoaded] = useState<{ partyId: string; reloadKey: number; dossier: Dossier | null } | null>(null) @@ -169,6 +171,8 @@ export function PartyDossier({ } const dominant = dossier?.facts.find((f) => f.field === 'dominant_account')?.value as { account?: string; count?: number } | undefined const registryVat = dossier?.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value + const countryRaw = dossier?.facts.find((f) => f.field === 'country')?.value + const countryCode = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null // One primary action: the role the ledger suggests and the party does not // have yet. The other role and everything else live behind the menu. const missingRoles: PartyRole[] = p ? (['supplier', 'customer'] as PartyRole[]).filter((r) => (r === 'supplier' ? !p.roles.supplierId : !p.roles.customerId)) : [] @@ -301,6 +305,7 @@ export function PartyDossier({ value={p.vatNumber ?? (registryVat ? String(registryVat) : {t('fact_missing')})} note={p.vatNumber ? docsFor('vat_number') || undefined : registryVat ? docsFor('vat_number') : undefined} /> + {countryCode ? : null} {dossier.identities.map((i) => ( c.active) + if (!result.truncated && active.length === 1 && result.candidates.length === 1) return { kind: 'match', candidate: active[0]!, aiRead: result.aiRead } + if (result.candidates.length === 0) return { kind: 'none', aiRead: result.aiRead } + return { kind: 'choose', count: result.truncated ? result.total : result.candidates.length, aiRead: result.aiRead } +} + +export function RegistryReviewDialog({ + open, + rows, + onOpenChange, + onChoose, + onApproved, + delayMs = REVIEW_STEP_MS, +}: { + open: boolean + rows: RegisterRow[] + onOpenChange: (open: boolean) => void + /** Open the per-row picker for a row the list could not settle. */ + onChoose: (row: RegisterRow) => void + /** Called once after the approved org numbers are saved. */ + onApproved: (saved: number, failed: number) => void + delayMs?: number +}) { + const t = useTranslations('parties') + const tCommon = useTranslations('common') + const locale = useLocale() + const [states, setStates] = useState([]) + const [searched, setSearched] = useState(0) + const [saving, setSaving] = useState(false) + const runId = useRef(0) + + // Ask SCB row by row while the dialog is open; a closed dialog stops the loop. + useEffect(() => { + if (!open) return + const id = ++runId.current + const initial: ReviewState[] = rows.map((row) => ({ row, outcome: null, approved: false, saved: 'pending' })) + setStates(initial) + setSearched(0) + let cancelled = false + void (async () => { + for (let i = 0; i < rows.length; i += 1) { + if (cancelled || runId.current !== id) return + const row = rows[i]! + let outcome: ReviewOutcome + try { + const res = await fetch(`/api/parties/${row.id}/enrich/candidates`) + if (!res.ok) throw new Error(String(res.status)) + const json = (await res.json()) as { data: RegistryCandidatesResult } + outcome = outcomeOf(json.data) + } catch { + outcome = { kind: 'failed' } + } + if (cancelled || runId.current !== id) return + setStates((prev) => prev.map((s) => (s.row.id === row.id ? { ...s, outcome, approved: outcome.kind === 'match' } : s))) + setSearched(i + 1) + if (i < rows.length - 1) await new Promise((r) => setTimeout(r, delayMs)) + } + })() + return () => { + cancelled = true + } + // rows is the snapshot the dialog was opened with; re-running on every + // register reload would restart the SCB loop mid-way. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, delayMs]) + + const searching = open && searched < rows.length + const matches = states.filter((s) => s.outcome?.kind === 'match') + const others = states.filter((s) => s.outcome && s.outcome.kind !== 'match') + const approvedCount = matches.filter((s) => s.approved && s.saved === 'pending').length + + async function approve() { + const chosen = matches.filter((s) => s.approved && s.saved === 'pending') + if (chosen.length === 0) return + setSaving(true) + let saved = 0 + let failed = 0 + for (let i = 0; i < chosen.length; i += 1) { + const s = chosen[i]! + const candidate = (s.outcome as Extract).candidate + setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: 'saving' } : x))) + let ok = false + try { + const res = await fetch(`/api/parties/${s.row.id}/enrich`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orgNumber: candidate.orgNumber }), + }) + ok = res.ok + } catch { + ok = false + } + if (ok) saved += 1 + else failed += 1 + setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: ok ? 'done' : 'failed' } : x))) + if (i < chosen.length - 1) await new Promise((r) => setTimeout(r, delayMs / 2)) + } + setSaving(false) + onApproved(saved, failed) + } + + return ( + (!saving ? onOpenChange(o) : undefined)}> + + + {t('review_title')} + {searching ? t('review_searching', { done: searched, total: rows.length }) : t('review_intro')} + + +
+ {matches.length > 0 ? ( +
+ + + + + + + + + {matches.map((s) => { + const o = s.outcome as Extract + return ( + + + + + + + ) + })} + +
+ {t('review_col_book')}{t('review_col_match')} +
+ setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, approved: v === true } : x)))} + aria-label={s.row.displayName} + /> + + {s.row.displayName} + {o.aiRead ? {t('review_ai_read', { name: o.aiRead.name })} : null} + + {o.candidate.name} + + {[formatOrgNumber(o.candidate.orgNumber), o.candidate.city, o.candidate.industry].filter(Boolean).join(' · ')} + + + {s.saved === 'done' ? {t('review_saved')} : s.saved === 'failed' ? {t('review_failed')} : null} +
+
+ ) : null} + + {others.length > 0 ? ( +
+

{t('review_others')}

+
    + {others.map((s) => { + const o = s.outcome! + return ( +
  • + + {s.row.displayName} + {'aiRead' in o && o.aiRead ? {t('review_ai_read', { name: o.aiRead.name })} : null} + + + {o.kind === 'foreign' + ? t('review_foreign', { place: o.country ? ` (${regionName(o.country, locale)})` : '' }) + : o.kind === 'choose' + ? t('review_choose_n', { count: o.count }) + : o.kind === 'failed' + ? t('registry_unavailable_title') + : t('review_none')} + + {o.kind === 'choose' || o.kind === 'none' ? ( + + ) : null} +
  • + ) + })} +
+
+ ) : null} + + {!searching && matches.length === 0 && others.length === 0 ?

{t('review_empty')}

: null} +
+ + + + + +
+
+ ) +} diff --git a/components/parties/ScbPickerDialog.tsx b/components/parties/ScbPickerDialog.tsx index b3d7719a..9ff34fb1 100644 --- a/components/parties/ScbPickerDialog.tsx +++ b/components/parties/ScbPickerDialog.tsx @@ -98,6 +98,7 @@ export function ScbPickerDialog({
+ {result?.aiRead && !query.trim() && !loading ?

{t('picker_ai_read', { name: result.aiRead.name })}

: null} {foreign && candidates.length === 0 && !loading ?

{t('picker_foreign_hint')}

: null} {alternates.length > 0 && !loading ? (
diff --git a/components/parties/SuggestionQueue.tsx b/components/parties/SuggestionQueue.tsx index 2b023509..21a571e9 100644 --- a/components/parties/SuggestionQueue.tsx +++ b/components/parties/SuggestionQueue.tsx @@ -1,6 +1,6 @@ 'use client' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Check, ChevronDown } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -17,6 +17,19 @@ import { isDuplicateCandidate, reasonText, rolesLabel } from './format' * here and what it becomes; only rows with a hard key arrive pre-ticked; * bulk confirm opens one dialog that says what happens. */ +/** A party the voucher text places abroad: SCB cannot hold it, so no search is offered. */ +export function isForeign(row: { country: string | null }): boolean { + return !!row.country && row.country !== 'SE' +} + +export function regionName(code: string, locale: string): string { + try { + return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code + } catch { + return code + } +} + export function SuggestionQueue({ rows, selected, @@ -48,6 +61,7 @@ export function SuggestionQueue({ onFind?: (row: RegisterRow) => void }) { const t = useTranslations('parties') + const locale = useLocale() const count = selected.size const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id)) @@ -113,7 +127,12 @@ export function SuggestionQueue({ {reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)} - {onFind && !row.orgNumber && row.kind !== 'person' ? ( + {isForeign(row) ? ( + <> + {' · '} + {t('row_foreign', { country: regionName(row.country as string, locale) })} + + ) : onFind && !row.orgNumber && row.kind !== 'person' ? ( <> {' · '}