* 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>
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
/**
|
|
* Parties: what to ask SCB for a party without an org number.
|
|
*
|
|
* The picker used to search on the whole display name, which for an
|
|
* assistant-written voucher is a sentence. This plans the search from the
|
|
* name candidates read out of the party's name and its voucher texts:
|
|
* Swedish legal persons first, the cleaned head last, and nothing at all
|
|
* when the best reading is a foreign company, which SCB cannot hold.
|
|
*/
|
|
import { extractNameCandidates, type NameCandidate } from './name-extract'
|
|
import { nameQuery, type ScbSearchResult } from './scb/client'
|
|
|
|
export interface ForeignReading {
|
|
name: string
|
|
legalForm?: string
|
|
country?: string
|
|
}
|
|
|
|
export interface RegistryQueryPlan {
|
|
/** Queries worth trying, best first. Empty when the party looks foreign. */
|
|
queries: string[]
|
|
/** The best reading of the counterpart when it is not a Swedish legal person. */
|
|
foreign: ForeignReading | null
|
|
candidates: NameCandidate[]
|
|
}
|
|
|
|
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
|
|
|
|
export function planRegistryQueries(input: { legalName: string | null; displayName: string; voucherTexts: string[] }): RegistryQueryPlan {
|
|
const texts = [input.legalName, input.displayName, ...input.voucherTexts].filter((t): t is string => !!t && t.trim().length > 0)
|
|
const seen = new Set<string>()
|
|
const candidates: NameCandidate[] = []
|
|
for (const text of texts) {
|
|
for (const c of extractNameCandidates(text)) {
|
|
const k = c.name.toLowerCase()
|
|
if (seen.has(k)) continue
|
|
seen.add(k)
|
|
candidates.push(c)
|
|
}
|
|
}
|
|
const swedishForm = candidates.filter((c) => c.source === 'legal_form' && !c.foreign)
|
|
const foreignAnchored = candidates.filter((c) => c.foreign && c.source !== 'head')
|
|
const foreign = swedishForm.length === 0 && foreignAnchored.length > 0 ? foreignAnchored[0]! : null
|
|
const ordered = foreign
|
|
? []
|
|
: [...swedishForm, ...candidates.filter((c) => c.source !== 'legal_form' && !c.foreign), ...candidates.filter((c) => c.source === 'head')]
|
|
const queries: string[] = []
|
|
for (const c of ordered) {
|
|
const q = nameQuery(c.name)
|
|
if (q.length < 2 || queries.includes(q)) continue
|
|
queries.push(q)
|
|
if (queries.length >= MAX_REGISTRY_QUERIES) break
|
|
}
|
|
return {
|
|
queries,
|
|
foreign: foreign ? { name: foreign.name, ...(foreign.legalForm ? { legalForm: foreign.legalForm } : {}), ...(foreign.country ? { country: foreign.country } : {}) } : null,
|
|
candidates,
|
|
}
|
|
}
|