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
@@ -1568,3 +1568,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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) : <span className="text-muted-foreground">{t('fact_missing')}</span>)}
|
||||
note={p.vatNumber ? docsFor('vat_number') || undefined : registryVat ? docsFor('vat_number') : undefined}
|
||||
/>
|
||||
{countryCode ? <Row label={t('fact_country')} value={regionName(countryCode, locale)} note={docsFor('country') || undefined} /> : null}
|
||||
{dossier.identities.map((i) => (
|
||||
<Row
|
||||
key={i.id}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
|
||||
import type { ScbCandidate } from '@/lib/parties/scb/client'
|
||||
import type { RegistryCandidatesResult } from '@/lib/parties/registry-search'
|
||||
import type { RegisterRow } from '@/lib/parties/register'
|
||||
import { formatOrgNumber } from '@/lib/utils'
|
||||
import { regionName } from './SuggestionQueue'
|
||||
|
||||
/**
|
||||
* One list instead of one picker per row. For every suggestion without an
|
||||
* org number the server plans a query from the voucher text and asks SCB;
|
||||
* a row whose query matches exactly one active company is shown with that
|
||||
* company ticked, and the person approves the whole list at once. Zero or
|
||||
* several matches, and foreign companies, are listed underneath with the
|
||||
* per-row picker still available. Nothing is chosen without the click.
|
||||
*
|
||||
* SCB allows ten calls per ten seconds and one search is up to two calls,
|
||||
* so rows are asked one at a time with a pause between them; results appear
|
||||
* as they land and approval can start before the last row is in.
|
||||
*/
|
||||
|
||||
/** Spacing between SCB rounds: two calls per search under ten per ten seconds. */
|
||||
export const REVIEW_STEP_MS = 2200
|
||||
|
||||
export type ReviewOutcome =
|
||||
| { kind: 'match'; candidate: ScbCandidate; aiRead: RegistryCandidatesResult['aiRead'] }
|
||||
| { kind: 'choose'; count: number; aiRead: RegistryCandidatesResult['aiRead'] }
|
||||
| { kind: 'none'; aiRead: RegistryCandidatesResult['aiRead'] }
|
||||
| { kind: 'foreign'; name: string; country: string | null }
|
||||
| { kind: 'failed' }
|
||||
|
||||
export interface ReviewState {
|
||||
row: RegisterRow
|
||||
outcome: ReviewOutcome | null
|
||||
approved: boolean
|
||||
saved: 'pending' | 'saving' | 'done' | 'failed'
|
||||
}
|
||||
|
||||
export function outcomeOf(result: RegistryCandidatesResult): ReviewOutcome {
|
||||
if (result.foreign && result.candidates.length === 0) return { kind: 'foreign', name: result.foreign.name, country: result.foreign.country ?? null }
|
||||
const active = result.candidates.filter((c) => 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<ReviewState[]>([])
|
||||
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<ReviewOutcome, { kind: 'match' }>).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 (
|
||||
<Dialog open={open} onOpenChange={(o) => (!saving ? onOpenChange(o) : undefined)}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('review_title')}</DialogTitle>
|
||||
<DialogDescription>{searching ? t('review_searching', { done: searched, total: rows.length }) : t('review_intro')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-6 overflow-y-auto">
|
||||
{matches.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${TH_CLASS} w-8`} />
|
||||
<th className={TH_CLASS}>{t('review_col_book')}</th>
|
||||
<th className={TH_CLASS}>{t('review_col_match')}</th>
|
||||
<th className={TH_CLASS} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.map((s) => {
|
||||
const o = s.outcome as Extract<ReviewOutcome, { kind: 'match' }>
|
||||
return (
|
||||
<tr key={s.row.id}>
|
||||
<td className={TD_CLASS}>
|
||||
<Checkbox
|
||||
checked={s.approved}
|
||||
disabled={s.saved !== 'pending' || saving}
|
||||
onCheckedChange={(v) => setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, approved: v === true } : x)))}
|
||||
aria-label={s.row.displayName}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} max-w-[16rem] truncate`} title={s.row.displayName}>
|
||||
{s.row.displayName}
|
||||
{o.aiRead ? <span className="block text-xs text-muted-foreground">{t('review_ai_read', { name: o.aiRead.name })}</span> : null}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<span className="font-medium">{o.candidate.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{[formatOrgNumber(o.candidate.orgNumber), o.candidate.city, o.candidate.industry].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-right`}>
|
||||
{s.saved === 'done' ? <Badge variant="success">{t('review_saved')}</Badge> : s.saved === 'failed' ? <Badge variant="warning">{t('review_failed')}</Badge> : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{others.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('review_others')}</p>
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{others.map((s) => {
|
||||
const o = s.outcome!
|
||||
return (
|
||||
<li key={s.row.id} className="flex items-center gap-3 px-4 py-2 text-[13px]">
|
||||
<span className="min-w-0 flex-1 truncate" title={s.row.displayName}>
|
||||
{s.row.displayName}
|
||||
{'aiRead' in o && o.aiRead ? <span className="block text-xs text-muted-foreground">{t('review_ai_read', { name: o.aiRead.name })}</span> : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{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')}
|
||||
</span>
|
||||
{o.kind === 'choose' || o.kind === 'none' ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onChoose(s.row)} disabled={saving}>
|
||||
{t('review_choose')}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!searching && matches.length === 0 && others.length === 0 ? <p className="text-sm text-muted-foreground">{t('review_empty')}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{tCommon('close')}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void approve()} disabled={saving || approvedCount === 0}>
|
||||
{saving ? t('review_approving') : t('review_approve', { count: approvedCount })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export function ScbPickerDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{result?.aiRead && !query.trim() && !loading ? <p className="text-sm text-muted-foreground">{t('picker_ai_read', { name: result.aiRead.name })}</p> : null}
|
||||
{foreign && candidates.length === 0 && !loading ? <p className="text-sm text-muted-foreground">{t('picker_foreign_hint')}</p> : null}
|
||||
{alternates.length > 0 && !loading ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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({
|
||||
</td>
|
||||
<td className={`${TD_CLASS} min-w-[16rem] max-w-[28rem] text-muted-foreground`}>
|
||||
{reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)}
|
||||
{onFind && !row.orgNumber && row.kind !== 'person' ? (
|
||||
{isForeign(row) ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-foreground">{t('row_foreign', { country: regionName(row.country as string, locale) })}</span>
|
||||
</>
|
||||
) : onFind && !row.orgNumber && row.kind !== 'person' ? (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
|
||||
@@ -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' })],
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Parties: the model reads a counterpart out of a voucher text that the
|
||||
* rules could not anchor.
|
||||
*
|
||||
* lib/parties/name-extract.ts names a company when the text carries a legal
|
||||
* form or a country word. Bank memos carry neither: "Hotel at Booking.com
|
||||
* K3667 Kortköp/uttag · Hotell, svenskt boende", "UBER *TRIP HELP.UBER.COM".
|
||||
* For those, and only those, one model call reads the counterpart the way a
|
||||
* bookkeeper would. The reading is a fact with source 'model', shown as
|
||||
* "läst ur verifikatet", used as the registry query and never as a hard key:
|
||||
* an org number still comes from SCB and a person's click, a VAT number only
|
||||
* when it is written in the text.
|
||||
*
|
||||
* Runs on demand (the picker, the review list), not when the queue builds:
|
||||
* a queue of five hundred rows would otherwise cost five hundred calls that
|
||||
* nobody asked for, and a rebuild would repeat them.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { getAiService, getAiStatus } from '@/lib/ai'
|
||||
|
||||
export interface AiNameReading {
|
||||
/** The counterpart as the model reads it, or null when the text names none. */
|
||||
name: string | null
|
||||
/** ISO 3166-1 alpha-2 when the text says where the counterpart is. */
|
||||
country: string | null
|
||||
/** A VAT number written in the text, if any. */
|
||||
vatNumber: string | null
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
model: string
|
||||
}
|
||||
|
||||
export function aiNameAvailable(): boolean {
|
||||
return getAiStatus().configured
|
||||
}
|
||||
|
||||
const SYSTEM = [
|
||||
'You read descriptions of Swedish bookkeeping vouchers (verifikat) and name the counterpart: the company or organisation the money went to or came from.',
|
||||
'Answer only from the text. Card memos abbreviate: "UBER *TRIP HELP.UBER.COM" is Uber, "Hotel at Booking.com" is Booking.com, "ANTHROPIC* CLAUDE SUB" is Anthropic.',
|
||||
'Leave out payment method words (Kortköp/uttag, Överföring via internet, Bg-bet), references, dates, amounts, account notes and VAT commentary.',
|
||||
'Give the name as the company writes it, with its legal form only if the text has it. Do not invent a legal form or an org number.',
|
||||
'country: ISO 3166-1 alpha-2 only when the text states or unmistakably implies it (Ireland, (NL), USA, utländsk moms with a named country); otherwise null.',
|
||||
'vat_number: only a VAT number written in the text, letters and digits, no spaces; otherwise null.',
|
||||
'If the text names no counterpart (a fee, a category, a transfer between own accounts, a salary), answer name null.',
|
||||
].join(' ')
|
||||
|
||||
const SCHEMA = {
|
||||
name: 'counterpart_reading',
|
||||
description: 'The counterpart named in the voucher text, or null.',
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: ['string', 'null'] },
|
||||
country: { type: ['string', 'null'] },
|
||||
vat_number: { type: ['string', 'null'] },
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
},
|
||||
required: ['name', 'country', 'vat_number', 'confidence'],
|
||||
},
|
||||
}
|
||||
|
||||
const Reading = z.object({
|
||||
name: z.string().trim().min(1).max(120).nullable(),
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.toUpperCase())
|
||||
.pipe(z.string().regex(/^[A-Z]{2}$/))
|
||||
.nullable()
|
||||
.catch(null),
|
||||
vat_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.replace(/[^0-9A-Za-z]/g, '').toUpperCase())
|
||||
.pipe(z.string().regex(/^[A-Z]{2}[0-9A-Z]{8,12}$/))
|
||||
.nullable()
|
||||
.catch(null),
|
||||
confidence: z.enum(['high', 'medium', 'low']).catch('low'),
|
||||
})
|
||||
|
||||
export const AI_NAME_MAX_TEXTS = 3
|
||||
|
||||
/**
|
||||
* One call for one party. Returns null when the deployment has no model or
|
||||
* the answer is unusable; the caller then searches on the memo as before.
|
||||
*/
|
||||
export async function readCounterpartName(texts: string[]): Promise<AiNameReading | null> {
|
||||
const distinct = [...new Set(texts.map((t) => t.trim()).filter(Boolean))].slice(0, AI_NAME_MAX_TEXTS)
|
||||
if (distinct.length === 0 || !aiNameAvailable()) return null
|
||||
const prompt = ['Voucher descriptions for one counterpart:', ...distinct.map((t, i) => `${i + 1}. ${t}`)].join('\n')
|
||||
try {
|
||||
const result = await getAiService().generateStructured({ tier: 'extraction', system: SYSTEM, prompt, maxTokens: 200, schema: SCHEMA })
|
||||
const parsed = Reading.safeParse(result.value)
|
||||
if (!parsed.success) return null
|
||||
return {
|
||||
name: parsed.data.name,
|
||||
country: parsed.data.country,
|
||||
vatNumber: parsed.data.vat_number,
|
||||
confidence: parsed.data.confidence,
|
||||
model: result.model,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -56,6 +56,12 @@ export interface RegisterRow {
|
||||
/** What confirming this suggestion creates, read from which side of the ledger it sits on. */
|
||||
defaultRoles: PartyRole[]
|
||||
createdAt: string
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 read out of the voucher text or a register. Anything
|
||||
* but SE means SCB cannot hold the party, so the queue says so instead of
|
||||
* offering a search that cannot succeed.
|
||||
*/
|
||||
country: string | null
|
||||
}
|
||||
|
||||
export interface ObservedRow {
|
||||
@@ -234,7 +240,7 @@ export async function getRegister(
|
||||
const period = options.period ?? '12m'
|
||||
const q = normalizeQuery(options.q)
|
||||
|
||||
const [parties, customers, suppliers, observed, customerCounts, supplierCounts] = await Promise.all([
|
||||
const [parties, customers, suppliers, observed, customerCounts, supplierCounts, countryFacts] = await Promise.all([
|
||||
// Archived (dismissed) parties stay out of the list but keep their keys
|
||||
// claimed, so a dismissed suggestion does not resurface as observed.
|
||||
fetchAllRows<PartyRecord>(({ from, to }) =>
|
||||
@@ -259,8 +265,24 @@ export async function getRegister(
|
||||
fetchAllRows<{ supplier_id: string }>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('supplier_id').eq('company_id', companyId).not('supplier_id', 'is', null).range(from, to),
|
||||
),
|
||||
fetchAllRows<{ party_id: string; value: unknown; recorded_at: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('party_facts')
|
||||
.select('party_id, value, recorded_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('field', 'country')
|
||||
.is('superseded_at', null)
|
||||
.order('recorded_at', { ascending: false })
|
||||
.range(from, to),
|
||||
),
|
||||
])
|
||||
|
||||
const countryByParty = new Map<string, string>()
|
||||
for (const f of countryFacts) {
|
||||
const code = typeof f.value === 'string' ? f.value.trim().toUpperCase() : ''
|
||||
if (/^[A-Z]{2}$/.test(code) && !countryByParty.has(f.party_id)) countryByParty.set(f.party_id, code)
|
||||
}
|
||||
|
||||
const customerByParty = new Map<string, string>()
|
||||
for (const c of customers) if (c.party_id && !customerByParty.has(c.party_id)) customerByParty.set(c.party_id, c.id)
|
||||
const supplierByParty = new Map<string, string>()
|
||||
@@ -300,6 +322,7 @@ export async function getRegister(
|
||||
similar: similarById.get(p.id) ?? [],
|
||||
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
|
||||
createdAt: p.created_at,
|
||||
country: countryByParty.get(p.id) ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,6 +534,7 @@ export async function getDossier(supabase: SupabaseClient, companyId: string, pa
|
||||
similar: similar.map((s) => ({ id: s.id, displayName: s.displayName })),
|
||||
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
|
||||
createdAt: p.created_at,
|
||||
country: (facts.data as Array<{ field: string; value: unknown }> | null)?.find((f) => f.field === 'country' && typeof f.value === 'string')?.value as string | null ?? null,
|
||||
},
|
||||
facts: ((facts.data ?? []) as Array<Record<string, unknown>>).map((f) => ({
|
||||
id: f.id as string,
|
||||
|
||||
@@ -28,6 +28,17 @@ export interface RegistryCandidatesResult extends ScbSearchResult {
|
||||
/** Every query the server tried or would try, best first. */
|
||||
queries: string[]
|
||||
foreign: ForeignReading | null
|
||||
/**
|
||||
* What the model read out of a text the rules could not anchor (a bank
|
||||
* memo), when a model is configured. Shown as "läst ur verifikatet"; the
|
||||
* search ran on it. Null when the rules found a name or no model answered.
|
||||
*/
|
||||
aiRead: { name: string; country: string | null } | null
|
||||
}
|
||||
|
||||
/** True when nothing in the texts anchored a name: only cleaned heads remain. */
|
||||
export function needsModelReading(plan: RegistryQueryPlan): boolean {
|
||||
return plan.foreign === null && plan.candidates.every((c) => c.source === 'head')
|
||||
}
|
||||
|
||||
export const MAX_REGISTRY_QUERIES = 3
|
||||
|
||||
+82
-6
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { coreKey, displayNameFromVoucherText } from './ledger-key'
|
||||
import { extractNameCandidates, extractVatNumbers } from './name-extract'
|
||||
import { getObservedParties, type ObservedParty } from './observed'
|
||||
@@ -42,6 +43,7 @@ export interface LedgerKeyEvidence {
|
||||
export interface ExistingParty {
|
||||
id: string
|
||||
display_name: string
|
||||
legal_name?: string | null
|
||||
org_number: string | null
|
||||
alias_keys: string[]
|
||||
status: 'suggested' | 'confirmed'
|
||||
@@ -64,7 +66,7 @@ export interface SuggestionIdentity {
|
||||
|
||||
export interface SuggestionReason {
|
||||
/** How the key attaches, or why it becomes a new party. */
|
||||
attach: 'party_id' | 'org_number' | 'alias_key' | 'new'
|
||||
attach: 'party_id' | 'org_number' | 'alias_key' | 'legal_name' | 'new'
|
||||
occurrences: number
|
||||
expense_sek: number
|
||||
revenue_sek: number
|
||||
@@ -91,6 +93,12 @@ export interface SuggestionItem {
|
||||
vat_number?: string
|
||||
party_id?: string
|
||||
alias_keys: string[]
|
||||
/**
|
||||
* The display name is the legal person named in the voucher text (a
|
||||
* legal-form anchor), not a cleaned bank memo. apply_party_suggestions
|
||||
* may rename an untouched suggestion to it on a later run.
|
||||
*/
|
||||
name_anchored?: boolean
|
||||
reason: SuggestionReason
|
||||
facts: SuggestionFact[]
|
||||
identities: SuggestionIdentity[]
|
||||
@@ -113,6 +121,8 @@ interface PickedName {
|
||||
country?: string
|
||||
/** The text points abroad: foreign legal form, country word or VAT prefix. */
|
||||
foreign?: boolean
|
||||
/** The name is a legal person read out of the text, legal form included. */
|
||||
anchored?: boolean
|
||||
}
|
||||
|
||||
/** The voucher texts under a key, most common first, at most three. */
|
||||
@@ -138,7 +148,14 @@ function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefin
|
||||
candidates.find((c) => c.source === 'legal_form' && !c.foreign) ??
|
||||
candidates.find((c) => c.source === 'legal_form') ??
|
||||
candidates.find((c) => c.source === 'country')
|
||||
if (anchored) return { display: anchored.name, ...(anchored.country ? { country: anchored.country } : {}), foreign: anchored.foreign }
|
||||
if (anchored) {
|
||||
return {
|
||||
display: anchored.name,
|
||||
...(anchored.country ? { country: anchored.country } : {}),
|
||||
foreign: anchored.foreign,
|
||||
anchored: anchored.source === 'legal_form',
|
||||
}
|
||||
}
|
||||
const head = candidates.find((c) => c.source === 'head')
|
||||
return {
|
||||
display: displayNameFromVoucherText(observed.name || observed.key),
|
||||
@@ -172,8 +189,16 @@ export function buildSuggestions(input: {
|
||||
const byOrg = new Map<string, ExistingParty>()
|
||||
const byAlias = new Map<string, ExistingParty>()
|
||||
const byCore = new Map<string, ExistingParty[]>()
|
||||
// Exact legal names, legal form included: registered company names are
|
||||
// unique in Sweden, so "Visma Spcs AB" read out of a voucher text is the
|
||||
// party already called that. Never a fuzzy match; never without the form.
|
||||
const byLegalName = new Map<string, ExistingParty>()
|
||||
for (const p of input.existing) {
|
||||
if (p.org_number && !byOrg.has(p.org_number)) byOrg.set(p.org_number, p)
|
||||
for (const n of [p.display_name, p.legal_name]) {
|
||||
const k = (n ?? '').trim().toLowerCase()
|
||||
if (k && !byLegalName.has(k)) byLegalName.set(k, p)
|
||||
}
|
||||
for (const a of p.alias_keys) if (!byAlias.has(a)) byAlias.set(a, p)
|
||||
const c = coreKey(p.display_name)
|
||||
if (c) byCore.set(c, [...(byCore.get(c) ?? []), p])
|
||||
@@ -193,10 +218,20 @@ export function buildSuggestions(input: {
|
||||
const ev = evidenceByKey.get(o.key)
|
||||
const orgs = ev?.orgs ?? []
|
||||
const org = orgs.length === 1 ? orgs[0]!.org : undefined
|
||||
const existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
|
||||
const name = pickName(o, ev)
|
||||
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
|
||||
let attach: SuggestionReason['attach'] = existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new'
|
||||
if (!existing && name.anchored) {
|
||||
const byName = byLegalName.get(name.display.trim().toLowerCase())
|
||||
// A different org number on either side means a different company
|
||||
// with a confusable name; the exact-name rule never overrides a key.
|
||||
if (byName && (!org || !byName.org_number || byName.org_number === org)) {
|
||||
existing = byName
|
||||
attach = 'legal_name'
|
||||
}
|
||||
}
|
||||
const reason: SuggestionReason = {
|
||||
attach: existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new',
|
||||
attach,
|
||||
occurrences: o.occurrences,
|
||||
expense_sek: o.expense_sek,
|
||||
revenue_sek: o.revenue_sek,
|
||||
@@ -260,6 +295,7 @@ export function buildSuggestions(input: {
|
||||
key: o.key,
|
||||
display_name: name.display,
|
||||
...(name.legal ? { legal_name: name.legal } : {}),
|
||||
...(name.anchored ? { name_anchored: true } : {}),
|
||||
kind: 'company',
|
||||
origin: org ? 'document' : 'ledger',
|
||||
...(org ? { org_number: org } : {}),
|
||||
@@ -271,7 +307,47 @@ export function buildSuggestions(input: {
|
||||
identities,
|
||||
})
|
||||
}
|
||||
return { items, skipped }
|
||||
return { items: groupByLegalName(items), skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Two keys that name the same legal person, legal form included, become one
|
||||
* suggestion with both keys as aliases, so "TIC identity · ... The
|
||||
* Intelligence Company AB (publ)" and "Utbetalning leverantörsfaktura, The
|
||||
* Intelligence Company AB (publ)" do not turn into two suppliers. Only for
|
||||
* new items whose name is anchored on a legal form; hard keys and existing
|
||||
* parties are already settled by then.
|
||||
*/
|
||||
function groupByLegalName(items: SuggestionItem[]): SuggestionItem[] {
|
||||
const heads = new Map<string, SuggestionItem>()
|
||||
const out: SuggestionItem[] = []
|
||||
for (const item of items) {
|
||||
const groupable = item.name_anchored && !item.party_id && !item.org_number
|
||||
const k = groupable ? item.display_name.trim().toLowerCase() : null
|
||||
const head = k ? heads.get(k) : undefined
|
||||
if (!head) {
|
||||
if (k) heads.set(k, item)
|
||||
out.push(item)
|
||||
continue
|
||||
}
|
||||
head.alias_keys = [...new Set([...head.alias_keys, ...item.alias_keys])]
|
||||
head.reason.occurrences += item.reason.occurrences
|
||||
head.reason.expense_sek = roundOre(head.reason.expense_sek + item.reason.expense_sek)
|
||||
head.reason.revenue_sek = roundOre(head.reason.revenue_sek + item.reason.revenue_sek)
|
||||
head.reason.docs += item.reason.docs
|
||||
head.reason.self_docs += item.reason.self_docs
|
||||
if (item.reason.first_seen < head.reason.first_seen) head.reason.first_seen = item.reason.first_seen
|
||||
if (item.reason.last_seen > head.reason.last_seen) head.reason.last_seen = item.reason.last_seen
|
||||
if (!head.vat_number && item.vat_number) head.vat_number = item.vat_number
|
||||
head.identities.push(...item.identities)
|
||||
const headTexts = head.facts.find((f) => f.field === 'voucher_text')
|
||||
const itemTexts = item.facts.find((f) => f.field === 'voucher_text')
|
||||
if (headTexts && itemTexts && Array.isArray(headTexts.value) && Array.isArray(itemTexts.value)) {
|
||||
headTexts.value = [...new Set([...(headTexts.value as string[]), ...(itemTexts.value as string[])])].slice(0, 3)
|
||||
}
|
||||
for (const f of item.facts) if (f.field !== 'voucher_text' && !head.facts.some((h) => h.field === f.field)) head.facts.push(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface SuggestSummary {
|
||||
@@ -307,7 +383,7 @@ export async function suggestPartiesForCompany(
|
||||
const existing = await fetchAllRows<ExistingParty>(({ from, to }) =>
|
||||
supabase
|
||||
.from('parties')
|
||||
.select('id, display_name, org_number, alias_keys, status')
|
||||
.select('id, display_name, legal_name, org_number, alias_keys, status')
|
||||
.eq('company_id', companyId)
|
||||
.is('merged_into', null)
|
||||
.is('archived_at', null)
|
||||
|
||||
@@ -8521,6 +8521,30 @@
|
||||
"picker_foreign": "{name} looks like a foreign company{place}. The SCB register only covers Swedish companies.",
|
||||
"picker_foreign_hint": "Add the contact with its name and VAT number instead, or search on another name if it is a Swedish company after all.",
|
||||
"picker_try_instead": "Try instead",
|
||||
"row_foreign": "Foreign company ({country}), not in the SCB register",
|
||||
"promote_dialog_foreign": "{foreign} of {count} are foreign companies not in the SCB register; name and VAT number come from the documents.",
|
||||
"fact_country": "Country",
|
||||
"review_open": "Find org numbers ({count})",
|
||||
"review_title": "Find org numbers in the company register",
|
||||
"review_searching": "Asking SCB, {done} of {total} …",
|
||||
"review_intro": "SCB was asked with the name in the voucher text. Rows with exactly one match are shown ticked; nothing is chosen until you approve.",
|
||||
"review_col_book": "In the books",
|
||||
"review_col_match": "Match in SCB",
|
||||
"review_others": "Needs a choice or cannot be found",
|
||||
"review_choose": "Choose",
|
||||
"review_choose_n": "{count, plural, one {1 match} other {# matches}}",
|
||||
"review_none": "No match",
|
||||
"review_foreign": "Foreign company{place}",
|
||||
"review_ai_read": "Read from the voucher: {name}",
|
||||
"review_approve": "Approve {count}",
|
||||
"review_approving": "Saving …",
|
||||
"review_saved": "Saved",
|
||||
"review_failed": "Failed",
|
||||
"review_done_title": "{saved} org numbers saved",
|
||||
"review_done_description": "The SCB details are fetched when you add them to the register.",
|
||||
"review_done_failed": "{failed} could not be saved.",
|
||||
"review_empty": "No rows are missing an org number.",
|
||||
"picker_ai_read": "Read from the voucher: {name}.",
|
||||
"fact_trade_name": "Trade name",
|
||||
"open_dossier": "Open {name}",
|
||||
"attn_create": "Create suggestions",
|
||||
|
||||
@@ -8521,6 +8521,30 @@
|
||||
"picker_foreign": "{name} ser ut att vara ett utländskt bolag{place}. SCB:s register täcker bara svenska företag.",
|
||||
"picker_foreign_hint": "Lägg upp kontakten med namn och momsnummer i stället, eller sök på ett annat namn om det ändå är ett svenskt bolag.",
|
||||
"picker_try_instead": "Sök i stället på",
|
||||
"row_foreign": "Utländskt bolag ({country}), finns inte i SCB",
|
||||
"promote_dialog_foreign": "{foreign} av {count} är utländska bolag och finns inte i SCB; namn och momsnummer kommer från underlagen.",
|
||||
"fact_country": "Land",
|
||||
"review_open": "Hitta org.nr ({count})",
|
||||
"review_title": "Hitta org.nr i företagsregistret",
|
||||
"review_searching": "Frågar SCB, {done} av {total} …",
|
||||
"review_intro": "SCB frågades med namnet i verifikatet. Rader med exakt en träff visas ikryssade; ingenting väljs utan att du godkänner.",
|
||||
"review_col_book": "I bokföringen",
|
||||
"review_col_match": "Träff i SCB",
|
||||
"review_others": "Behöver ett val eller går inte att hitta",
|
||||
"review_choose": "Välj",
|
||||
"review_choose_n": "{count, plural, one {1 träff} other {# träffar}}",
|
||||
"review_none": "Ingen träff",
|
||||
"review_foreign": "Utländskt bolag{place}",
|
||||
"review_ai_read": "Läst ur verifikatet: {name}",
|
||||
"review_approve": "Godkänn {count}",
|
||||
"review_approving": "Sparar …",
|
||||
"review_saved": "Sparat",
|
||||
"review_failed": "Misslyckades",
|
||||
"review_done_title": "{saved} org.nr sparade",
|
||||
"review_done_description": "Uppgifterna från SCB hämtas när du lägger upp dem.",
|
||||
"review_done_failed": "{failed} gick inte att spara.",
|
||||
"review_empty": "Inga rader saknar org.nr.",
|
||||
"picker_ai_read": "Läst ur verifikatet: {name}.",
|
||||
"fact_trade_name": "Firma",
|
||||
"open_dossier": "Öppna {name}",
|
||||
"attn_create": "Skapa förslag",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Parties: a later suggestion run may rename an untouched suggestion.
|
||||
--
|
||||
-- apply_party_suggestions only ever added aliases, hard keys and facts to an
|
||||
-- existing party, never a name, so a suggestion made before the legal-form
|
||||
-- anchoring (feat/parties-name-extract) kept its sentence-long or bank-memo
|
||||
-- name for good: on 2026-09-04 that was 4 rows in one company and 510 in
|
||||
-- another. Now an item whose display name is anchored on a legal form read
|
||||
-- out of the voucher text (name_anchored) renames a party that is still a
|
||||
-- suggestion nobody has touched: no decision, no user or registry fact.
|
||||
-- Confirmed parties, decided ones and names a person or a register gave are
|
||||
-- never renamed. The summary gains 'renamed'.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.apply_party_suggestions(
|
||||
p_company_id uuid,
|
||||
p_user_id uuid,
|
||||
p_items jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_item jsonb;
|
||||
v_fact jsonb;
|
||||
v_ident jsonb;
|
||||
v_party_id uuid;
|
||||
v_key text;
|
||||
v_org text;
|
||||
v_vat text;
|
||||
v_aliases text[];
|
||||
v_created integer := 0;
|
||||
v_attached integer := 0;
|
||||
v_identities integer := 0;
|
||||
v_facts integer := 0;
|
||||
v_seen integer;
|
||||
v_rename boolean;
|
||||
v_renamed integer := 0;
|
||||
BEGIN
|
||||
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
||||
RAISE EXCEPTION 'apply_party_suggestions: p_user_id must be the caller' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
|
||||
RAISE EXCEPTION 'apply_party_suggestions: p_items must be a JSON array' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
|
||||
v_key := nullif(btrim(coalesce(v_item->>'key', '')), '');
|
||||
IF v_key IS NULL THEN
|
||||
RAISE EXCEPTION 'apply_party_suggestions: every item needs a key' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
v_org := public.normalize_org_number(v_item->>'org_number');
|
||||
v_vat := nullif(upper(regexp_replace(coalesce(v_item->>'vat_number', ''), '[^0-9A-Za-z]', '', 'g')), '');
|
||||
v_aliases := ARRAY(SELECT DISTINCT x FROM (
|
||||
SELECT v_key AS x UNION ALL SELECT jsonb_array_elements_text(coalesce(v_item->'alias_keys', '[]'::jsonb))
|
||||
) a WHERE x IS NOT NULL AND btrim(x) <> '');
|
||||
|
||||
v_party_id := NULL;
|
||||
IF v_item->>'party_id' IS NOT NULL THEN
|
||||
SELECT id INTO v_party_id FROM public.parties
|
||||
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL;
|
||||
IF v_party_id IS NULL THEN
|
||||
RAISE EXCEPTION 'apply_party_suggestions: party % is not a live party of this company', v_item->>'party_id'
|
||||
USING ERRCODE = '23503';
|
||||
END IF;
|
||||
END IF;
|
||||
IF v_party_id IS NULL AND v_org IS NOT NULL THEN
|
||||
SELECT id INTO v_party_id FROM public.parties
|
||||
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
|
||||
END IF;
|
||||
-- A VAT number is a hard key too: foreign suppliers (Framer B.V.,
|
||||
-- Anthropic Ireland) never carry a Swedish org number, and three keys for
|
||||
-- one Dutch company made three suggestions on 2026-09-03.
|
||||
IF v_party_id IS NULL AND v_vat IS NOT NULL THEN
|
||||
SELECT id INTO v_party_id FROM public.parties
|
||||
WHERE company_id = p_company_id AND merged_into IS NULL
|
||||
AND upper(regexp_replace(coalesce(vat_number, ''), '[^0-9A-Za-z]', '', 'g')) = v_vat
|
||||
ORDER BY (status = 'confirmed') DESC, created_at
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
IF v_party_id IS NULL THEN
|
||||
SELECT id INTO v_party_id FROM public.parties
|
||||
WHERE company_id = p_company_id AND merged_into IS NULL AND alias_keys @> ARRAY[v_key]
|
||||
ORDER BY (status = 'confirmed') DESC, created_at
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_party_id IS NULL THEN
|
||||
INSERT INTO public.parties (company_id, user_id, display_name, legal_name, kind, status, org_number, vat_number, alias_keys, origin, suggested_reason)
|
||||
VALUES (
|
||||
p_company_id, p_user_id,
|
||||
coalesce(nullif(btrim(v_item->>'display_name'), ''), v_key),
|
||||
nullif(btrim(v_item->>'legal_name'), ''),
|
||||
coalesce(v_item->>'kind', 'company'),
|
||||
'suggested',
|
||||
v_org,
|
||||
nullif(btrim(v_item->>'vat_number'), ''),
|
||||
v_aliases,
|
||||
coalesce(v_item->>'origin', 'ledger'),
|
||||
v_item->'reason'
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id INTO v_party_id;
|
||||
IF v_party_id IS NULL THEN
|
||||
-- Lost a race on (company_id, org_number): attach to the winner.
|
||||
SELECT id INTO v_party_id FROM public.parties
|
||||
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
|
||||
v_attached := v_attached + 1;
|
||||
ELSE
|
||||
v_created := v_created + 1;
|
||||
END IF;
|
||||
ELSE
|
||||
v_attached := v_attached + 1;
|
||||
-- An untouched suggestion (no decision, no user or registry fact) takes
|
||||
-- a better name from a later run when that name is anchored on a legal
|
||||
-- form read out of the voucher text; a cleaned bank memo never
|
||||
-- replaces anything. Names a person or a register gave stay.
|
||||
v_rename := coalesce((v_item->>'name_anchored')::boolean, false)
|
||||
AND nullif(btrim(v_item->>'display_name'), '') IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.parties p
|
||||
WHERE p.id = v_party_id AND p.status = 'suggested'
|
||||
AND p.display_name IS DISTINCT FROM btrim(v_item->>'display_name')
|
||||
AND NOT EXISTS (SELECT 1 FROM public.party_decisions d WHERE d.party_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM public.party_facts f WHERE f.party_id = p.id AND f.source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol'))
|
||||
);
|
||||
UPDATE public.parties
|
||||
SET alias_keys = ARRAY(SELECT DISTINCT x FROM unnest(alias_keys || v_aliases) AS x),
|
||||
vat_number = coalesce(vat_number, nullif(btrim(v_item->>'vat_number'), '')),
|
||||
legal_name = coalesce(legal_name, nullif(btrim(v_item->>'legal_name'), '')),
|
||||
org_number = coalesce(org_number, v_org),
|
||||
display_name = CASE WHEN v_rename THEN btrim(v_item->>'display_name') ELSE display_name END
|
||||
WHERE id = v_party_id
|
||||
AND (NOT (alias_keys @> v_aliases)
|
||||
OR v_rename
|
||||
OR (vat_number IS NULL AND nullif(btrim(v_item->>'vat_number'), '') IS NOT NULL)
|
||||
OR (legal_name IS NULL AND nullif(btrim(v_item->>'legal_name'), '') IS NOT NULL)
|
||||
OR (org_number IS NULL AND v_org IS NOT NULL));
|
||||
IF v_rename THEN v_renamed := v_renamed + 1; END IF;
|
||||
END IF;
|
||||
|
||||
FOR v_ident IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'identities', '[]'::jsonb)) LOOP
|
||||
v_seen := greatest(coalesce((v_ident->>'seen_count')::integer, 1), 1);
|
||||
INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, status, source, first_seen, last_seen, seen_count)
|
||||
VALUES (
|
||||
v_party_id, p_company_id, p_user_id,
|
||||
v_ident->>'scheme', v_ident->>'value',
|
||||
CASE WHEN v_seen >= 2 THEN 'known' ELSE 'unverified' END,
|
||||
coalesce(v_ident->>'source', 'document'),
|
||||
(v_ident->>'first_seen')::date, (v_ident->>'last_seen')::date, v_seen
|
||||
)
|
||||
ON CONFLICT (party_id, scheme, value) DO UPDATE
|
||||
SET seen_count = greatest(party_identities.seen_count, EXCLUDED.seen_count),
|
||||
first_seen = least(party_identities.first_seen, EXCLUDED.first_seen),
|
||||
last_seen = greatest(party_identities.last_seen, EXCLUDED.last_seen),
|
||||
status = CASE WHEN greatest(party_identities.seen_count, EXCLUDED.seen_count) >= 2 THEN 'known' ELSE party_identities.status END
|
||||
WHERE party_identities.seen_count < EXCLUDED.seen_count
|
||||
OR party_identities.last_seen IS DISTINCT FROM greatest(party_identities.last_seen, EXCLUDED.last_seen)
|
||||
OR party_identities.first_seen IS DISTINCT FROM least(party_identities.first_seen, EXCLUDED.first_seen);
|
||||
v_identities := v_identities + 1;
|
||||
END LOOP;
|
||||
|
||||
FOR v_fact IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'facts', '[]'::jsonb)) LOOP
|
||||
INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference)
|
||||
SELECT v_party_id, p_company_id, p_user_id, v_fact->>'field', v_fact->'value', v_fact->>'source', v_fact->'reference'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.party_facts f
|
||||
WHERE f.party_id = v_party_id AND f.field = v_fact->>'field' AND f.source = v_fact->>'source'
|
||||
AND f.value = v_fact->'value' AND f.superseded_at IS NULL
|
||||
);
|
||||
IF FOUND THEN v_facts := v_facts + 1; END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object('created', v_created, 'attached', v_attached, 'identities', v_identities, 'facts', v_facts, 'renamed', v_renamed);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -127,7 +127,7 @@ describe('apply_party_suggestions (pg)', () => {
|
||||
})
|
||||
|
||||
const first = await apply(c.companyId, c.userId, [item({ org_number: '556666-1012' })])
|
||||
expect(first).toEqual({ created: 1, attached: 0, identities: 1, facts: 1 })
|
||||
expect(first).toEqual({ created: 1, attached: 0, identities: 1, facts: 1, renamed: 0 })
|
||||
const party = await getPool().query<{ id: string; status: string; org_number: string; alias_keys: string[]; suggested_reason: { attach: string }; origin: string }>(
|
||||
`SELECT id, status, org_number, alias_keys, suggested_reason, origin FROM public.parties WHERE company_id = $1`,
|
||||
[c.companyId],
|
||||
@@ -141,7 +141,7 @@ describe('apply_party_suggestions (pg)', () => {
|
||||
const second = await apply(c.companyId, c.userId, [
|
||||
item({ key: 'loopia webbhotell', alias_keys: ['loopia webbhotell'], org_number: LOOPIA_ORG, identities: [{ scheme: 'bankgiro', value: '53170900', first_seen: '2026-03-10', last_seen: '2026-03-10', seen_count: 2 }] }),
|
||||
])
|
||||
expect(second).toEqual({ created: 0, attached: 1, identities: 1, facts: 0 })
|
||||
expect(second).toEqual({ created: 0, attached: 1, identities: 1, facts: 0, renamed: 0 })
|
||||
const after = await getPool().query<{ n: string; alias_keys: string[] }>(
|
||||
`SELECT (SELECT count(*) FROM public.parties WHERE company_id = $1)::text AS n, alias_keys FROM public.parties WHERE id = $2`,
|
||||
[c.companyId, partyId],
|
||||
@@ -174,6 +174,31 @@ describe('apply_party_suggestions (pg)', () => {
|
||||
expect([...rows.rows[0]!.alias_keys].sort()).toEqual(['framer utlägg', 'utlägg framer'])
|
||||
})
|
||||
|
||||
it('renames an untouched suggestion to a legal-form anchored name on a later run, never a decided one or a memo', async () => {
|
||||
const c = await seedCompany()
|
||||
await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'TIC identity' }])
|
||||
const renamed = await apply(c.companyId, c.userId, [
|
||||
{ key: 'tic identity', display_name: 'The Intelligence Company AB (publ)', name_anchored: true },
|
||||
])
|
||||
expect(renamed).toMatchObject({ created: 0, attached: 1, renamed: 1 })
|
||||
const after = await getPool().query<{ id: string; display_name: string }>(
|
||||
`SELECT id, display_name FROM public.parties WHERE company_id = $1`,
|
||||
[c.companyId],
|
||||
)
|
||||
expect(after.rows).toHaveLength(1)
|
||||
expect(after.rows[0]!.display_name).toBe('The Intelligence Company AB (publ)')
|
||||
|
||||
// A memo-shaped name (not anchored) never replaces anything.
|
||||
const memo = await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'TIC IDENTITY BG 0000005786439' }])
|
||||
expect(memo).toMatchObject({ renamed: 0 })
|
||||
// Once a person has decided on the row, no later run touches its name.
|
||||
await decide(c.companyId, c.userId, [after.rows[0]!.id], 'confirm')
|
||||
const decided = await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'The Intelligence Company Nordic AB', name_anchored: true }])
|
||||
expect(decided).toMatchObject({ renamed: 0 })
|
||||
const final = await getPool().query<{ display_name: string }>(`SELECT display_name FROM public.parties WHERE company_id = $1`, [c.companyId])
|
||||
expect(final.rows[0]!.display_name).toBe('The Intelligence Company AB (publ)')
|
||||
})
|
||||
|
||||
it('never merges on name: same core text becomes a second suggested party', async () => {
|
||||
const c = await seedCompany()
|
||||
await apply(c.companyId, c.userId, [{ key: 'fortnox', display_name: 'Fortnox AB', org_number: ORG }])
|
||||
|
||||
Reference in New Issue
Block a user