Files
accounted/components/parties/RegistryReviewDialog.tsx
c6ca119e73 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>
2026-09-04 17:01:10 +02:00

251 lines
11 KiB
TypeScript

'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>
)
}