feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)
* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones
The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.
- lib/parties/name-extract.ts: name candidates read out of the text,
anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
...) and on country words, plus EU VAT numbers. Every candidate is a
substring of the text; foreign forms and countries mark the candidate
as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
the voucher texts are stored as a ledger fact for the picker, the
country is stored when the text says, and a single foreign VAT number
in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
cleaned head last, at most three queries, stopping at the first hit;
no SCB call when the best reading is foreign, the response says which
company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
täcker bara svenska företag." with a hint to save by name and VAT
number; alternate readings offered as one-click searches when the
first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
not seen, not only on a first visit; the toast only appears when
something was created.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(parties): take a text-derived VAT number only on the expense side
A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.
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
7240bfe7f3
commit
82859d01db
@@ -1,15 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { ScbCandidate, ScbSearchResult } from '@/lib/parties/scb/client'
|
||||
import type { ScbCandidate } from '@/lib/parties/scb/client'
|
||||
import type { RegistryCandidatesResult } from '@/lib/parties/registry-search'
|
||||
import { formatOrgNumber } from '@/lib/utils'
|
||||
|
||||
function regionName(code: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "SCB hittar två företag som liknar Adobe Systems Software, vilket menar
|
||||
* du?" The picker for a party without an org number: the user chooses,
|
||||
@@ -33,8 +42,9 @@ export function ScbPickerDialog({
|
||||
}) {
|
||||
const t = useTranslations('parties')
|
||||
const tCommon = useTranslations('common')
|
||||
const locale = useLocale()
|
||||
const [query, setQuery] = useState('')
|
||||
const [loaded, setLoaded] = useState<{ key: string; result: ScbSearchResult | null; failed: boolean } | null>(null)
|
||||
const [loaded, setLoaded] = useState<{ key: string; result: RegistryCandidatesResult | null; failed: boolean } | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const key = `${partyId}:${query.trim()}`
|
||||
@@ -49,7 +59,7 @@ export function ScbPickerDialog({
|
||||
try {
|
||||
const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''
|
||||
const res = await fetch(`/api/parties/${partyId}/enrich/candidates${params}`, { signal: ctrl.signal })
|
||||
const json = (await res.json()) as { data?: ScbSearchResult }
|
||||
const json = (await res.json()) as { data?: RegistryCandidatesResult }
|
||||
if (!cancelled) setLoaded({ key, result: res.ok ? (json.data ?? null) : null, failed: !res.ok })
|
||||
} catch {
|
||||
if (!cancelled) setLoaded({ key, result: null, failed: true })
|
||||
@@ -64,6 +74,10 @@ export function ScbPickerDialog({
|
||||
|
||||
const result = current?.result ?? null
|
||||
const candidates = result?.candidates ?? []
|
||||
const foreign = !query.trim() && result?.foreign ? result.foreign : null
|
||||
const foreignPlace = foreign?.country ? ` (${regionName(foreign.country, locale)})` : ''
|
||||
// Other readings of the voucher text, offered when the one used found nothing.
|
||||
const alternates = result && candidates.length === 0 && !query.trim() ? result.queries.filter((q) => q !== result.query) : []
|
||||
const chosen = candidates.find((c) => c.orgNumber === selected) ?? null
|
||||
|
||||
return (
|
||||
@@ -76,12 +90,25 @@ export function ScbPickerDialog({
|
||||
? result.truncated
|
||||
? t('picker_too_many', { count: result.total, query: result.query })
|
||||
: candidates.length === 0
|
||||
? t('picker_none', { query: result.query })
|
||||
? foreign
|
||||
? t('picker_foreign', { name: foreign.name, place: foreignPlace })
|
||||
: t('picker_none', { query: result.query })
|
||||
: t('picker_found', { count: candidates.length, query: result.query })
|
||||
: t('picker_body', { name: partyName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<span>{t('picker_try_instead')}</span>
|
||||
{alternates.map((q) => (
|
||||
<Button key={q} type="button" variant="outline" size="sm" onClick={() => setQuery(q)}>
|
||||
{q}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user