* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party Founder feedback on the first Företagsuppgifter (2026-09-05): the org number twice, the VAT number twice, the legal name repeating the heading, and Kontaktuppgifter showing dashes while the block above had the phone, e-mail and address from SCB. - After a fetch the register's contact details land on the supplier and customer rows that point at the party: an empty field, or one still carrying what the register said last time, takes the new value; a value a person typed stays. Shown as "från SCB" on the row (by equality with the registry fact, no source column). - Företagsuppgifter becomes one status line (legal form, active or not, registrations, a Bolagsverket warning when there is one), industry, seat with registration date, and size. Identity stays in the header (org number now formatted) and Kontaktuppgifter. The legal name shows only when it differs from the row's name. - lib/parties/registry-summary.ts reads the coded SCB facts once for the page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape. - v1: party_id on supplier and customer list rows and detail; ?expand=party on detail embeds identity, the register summary, what the ledger has seen and payment identities. MCP: party_id on gnubok_list_suppliers/customers rows and gnubok_get_party (by party, supplier or customer id). Read-only; the parties resource follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): regenerate the API skill for the party expansion; tighten the get_party description Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget 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>
152 lines
6.9 KiB
TypeScript
152 lines
6.9 KiB
TypeScript
/**
|
|
* Parties: the register's facts as one summary.
|
|
*
|
|
* SCB answers with twenty-odd coded columns. Three surfaces need them as
|
|
* a few plain values: the Företagsuppgifter block on a supplier or customer
|
|
* page, the v1 REST `party` expansion, and the MCP party tool. One reading
|
|
* of the facts, so the three cannot drift. Pure: facts in, summary out.
|
|
*/
|
|
|
|
export interface RegistryFactLike {
|
|
field: string
|
|
value: unknown
|
|
source: string
|
|
fetchedAt?: string | null
|
|
}
|
|
|
|
export interface RegistryAddress {
|
|
co: string | null
|
|
street: string | null
|
|
postal_code: string | null
|
|
city: string | null
|
|
}
|
|
|
|
export interface RegistrySummary {
|
|
legal_name: string | null
|
|
legal_form: string | null
|
|
/** SCB's company status: label and whether it means "active". */
|
|
status: { label: string; active: boolean } | null
|
|
/** Bolagsverket status only when it is a warning (likvidation, konkurs, ...). */
|
|
warning: string | null
|
|
registrations: { f_tax: boolean | null; vat: boolean | null; employer: boolean | null }
|
|
industry: { code: string; label: string } | null
|
|
seat: string | null
|
|
registered_at: string | null
|
|
active_since: string | null
|
|
active_until: string | null
|
|
employees_band: string | null
|
|
turnover: { band: string; year: string | null } | null
|
|
workplaces: number | null
|
|
contact: { email: string | null; phone: string | null; address: RegistryAddress | null }
|
|
vat_number: string | null
|
|
fetched_at: string | null
|
|
}
|
|
|
|
type Coded = { code?: unknown; label?: unknown; warning?: unknown; year?: unknown }
|
|
|
|
function coded(value: unknown): Coded | null {
|
|
return value && typeof value === 'object' ? (value as Coded) : null
|
|
}
|
|
|
|
function text(value: unknown): string | null {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
}
|
|
|
|
/** Registered under SCB's coding: 1 registered, 3 via representant; 0 never, 9 deregistered. */
|
|
function registered(value: unknown): boolean | null {
|
|
const code = text(coded(value)?.code)
|
|
if (code === null) return null
|
|
return code === '1' || code === '2' || code === '3'
|
|
}
|
|
|
|
/** The registry facts of a party as one summary, or null when the register has said nothing. */
|
|
export function registrySummary(facts: RegistryFactLike[]): RegistrySummary | null {
|
|
const scb = facts.filter((f) => f.source === 'registry_scb')
|
|
if (scb.length === 0) return null
|
|
const get = (field: string) => scb.find((f) => f.field === field)?.value
|
|
const status = coded(get('company_status'))
|
|
const bolagsverket = coded(get('bolagsverket_status'))
|
|
const industry = coded(get('industry'))
|
|
const seat = get('seat') as { municipality?: string | null; county?: string | null } | undefined
|
|
const turnover = coded(get('turnover_band'))
|
|
const address = get('postal_address') as Partial<RegistryAddress> | undefined
|
|
const fetchedAt = scb.map((f) => f.fetchedAt ?? null).filter((d): d is string => !!d).sort().at(-1) ?? null
|
|
|
|
return {
|
|
legal_name: text(get('legal_name')),
|
|
legal_form: text(coded(get('legal_form'))?.label),
|
|
status: status ? { label: text(status.label) ?? '', active: text(status.code) === '1' } : null,
|
|
warning: bolagsverket?.warning === true ? (text(bolagsverket.label) ?? null) : null,
|
|
registrations: { f_tax: registered(get('f_tax')), vat: registered(get('vat_registration')), employer: registered(get('employer_registration')) },
|
|
industry: industry && text(industry.label) ? { code: text(industry.code) ?? '', label: text(industry.label) ?? '' } : null,
|
|
seat: seat ? ([seat.municipality, seat.county].filter((x, i, arr): x is string => !!x && (i === 0 || x !== arr[0])).join(', ') || null) : null,
|
|
registered_at: text(get('registered_at')),
|
|
active_since: text(get('active_since')),
|
|
active_until: text(get('active_until')),
|
|
employees_band: text(coded(get('employees_band'))?.label),
|
|
turnover: turnover && text(turnover.label) ? { band: text(turnover.label) ?? '', year: text(turnover.year) } : null,
|
|
workplaces: typeof get('workplaces') === 'number' ? (get('workplaces') as number) : null,
|
|
contact: {
|
|
email: text(get('email')),
|
|
phone: text(get('phone')),
|
|
address:
|
|
address && (text(address.street) || text(address.postal_code) || text(address.city))
|
|
? { co: text(address.co), street: text(address.street), postal_code: text(address.postal_code), city: text(address.city) }
|
|
: null,
|
|
},
|
|
vat_number: text(get('vat_number')),
|
|
fetched_at: fetchedAt,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How a registry address lands on a supplier or customer row. The c/o line
|
|
* goes first, as Swedish post wants it; the street follows.
|
|
*/
|
|
export function addressRowsFromRegistry(address: RegistryAddress): { address_line1: string | null; address_line2: string | null; postal_code: string | null; city: string | null } {
|
|
return address.co
|
|
? { address_line1: address.co, address_line2: address.street, postal_code: address.postal_code, city: address.city }
|
|
: { address_line1: address.street, address_line2: null, postal_code: address.postal_code, city: address.city }
|
|
}
|
|
|
|
export interface ContactRow {
|
|
email: string | null
|
|
phone: string | null
|
|
address_line1: string | null
|
|
address_line2: string | null
|
|
postal_code: string | null
|
|
city: string | null
|
|
}
|
|
|
|
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
|
|
/**
|
|
* Which contact fields to write on a row after a fetch. A field is filled
|
|
* when it is empty, or when it still carries what the register said last
|
|
* time (the person never touched it) and the register now says something
|
|
* else. A value a person typed is never replaced.
|
|
*/
|
|
export function contactFill(row: ContactRow, now: RegistrySummary['contact'], before: RegistrySummary['contact'] | null): Partial<ContactRow> {
|
|
const out: Partial<ContactRow> = {}
|
|
const untouched = (current: string | null, previous: string | null | undefined) => !norm(current) || (previous != null && norm(current) === norm(previous))
|
|
if (now.email && untouched(row.email, before?.email) && norm(row.email) !== norm(now.email)) out.email = now.email
|
|
if (now.phone && untouched(row.phone, before?.phone) && norm(row.phone) !== norm(now.phone)) out.phone = now.phone
|
|
if (now.address) {
|
|
const next = addressRowsFromRegistry(now.address)
|
|
const prev = before?.address ? addressRowsFromRegistry(before.address) : null
|
|
const addressUntouched =
|
|
untouched(row.address_line1, prev?.address_line1) &&
|
|
untouched(row.address_line2, prev?.address_line2) &&
|
|
untouched(row.postal_code, prev?.postal_code) &&
|
|
untouched(row.city, prev?.city)
|
|
const changed = (['address_line1', 'address_line2', 'postal_code', 'city'] as const).some((k) => norm(row[k]) !== norm(next[k]))
|
|
if (addressUntouched && changed) Object.assign(out, next)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** True when the row's value is what the register said: shown as "från SCB". */
|
|
export function fromRegistry(rowValue: string | null | undefined, registryValue: string | null | undefined): boolean {
|
|
return !!norm(rowValue) && norm(rowValue) === norm(registryValue)
|
|
}
|