* 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>
66 lines
2.6 KiB
TypeScript
66 lines
2.6 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
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|