feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)
Founder test on a real company (2026-09-05): a supplier created from "Webhallen Oktober · Dataskärmar till kontoret" kept that text as its name, and the SCB facts fetched for the party were nowhere on the supplier page. - Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name, org number, VAT number, country, then the SCB facts under one source line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one action. The registry helpers move out of the dossier into RegistryFacts so the three surfaces share them. - The enrich route makes the registry's legal name the displayed name of the party and of supplier and customer rows that still carry the party's old name; all-capitals names are set in title case (lib/parties/registry-name.ts). Names a person set stay. - legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps its vouchers, so a rebuild attaches the new key instead of offering the same company again. - GET /api/parties/[id] reports whether SCB is configured. 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
Jakob Wennberg
Claude Fable 5.1
parent
397a3b9bca
commit
04898c3178
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { displayNameFromRegistry, sameName } from '../registry-name'
|
||||
|
||||
describe('displayNameFromRegistry', () => {
|
||||
it('sets an all-capitals registry name in title case and keeps legal forms and acronyms', () => {
|
||||
expect(displayNameFromRegistry('WEBHALLEN SVERIGE AB')).toBe('Webhallen Sverige AB')
|
||||
expect(displayNameFromRegistry('AKTIEBOLAGET VOLVO')).toBe('Aktiebolaget Volvo')
|
||||
expect(displayNameFromRegistry('THE INTELLIGENCE COMPANY AB (PUBL)')).toBe('The Intelligence Company AB (publ)')
|
||||
expect(displayNameFromRegistry('SEB KORT BANK AB')).toBe('SEB Kort Bank AB')
|
||||
expect(displayNameFromRegistry('SVENSK-DANSKA BYGG HB')).toBe('Svensk-Danska Bygg HB')
|
||||
expect(displayNameFromRegistry('FÖRENINGEN FÖR SVENSK MUSIK')).toBe('Föreningen för Svensk Musik')
|
||||
})
|
||||
|
||||
it('leaves a name with lowercase letters exactly as written', () => {
|
||||
expect(displayNameFromRegistry('The Intelligence Company AB (publ)')).toBe('The Intelligence Company AB (publ)')
|
||||
expect(displayNameFromRegistry('Visma Spcs AB')).toBe('Visma Spcs AB')
|
||||
expect(displayNameFromRegistry(' Framer B.V. ')).toBe('Framer B.V.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameName', () => {
|
||||
it('compares names case- and whitespace-insensitively and never matches empties', () => {
|
||||
expect(sameName('Visma Spcs AB', 'VISMA SPCS AB')).toBe(true)
|
||||
expect(sameName('Webhallen Oktober', 'WEBHALLEN SVERIGE AB')).toBe(false)
|
||||
expect(sameName('', '')).toBe(false)
|
||||
expect(sameName(null, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -157,6 +157,21 @@ describe('buildSuggestions', () => {
|
||||
expect(memo.name_anchored).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attaches a new key to the party confirmed under the pre-2026-09-04 key for the same vouchers', () => {
|
||||
const text = 'Webhallen Oktober · Dataskärmar till kontoret'
|
||||
const confirmed: ExistingParty = {
|
||||
id: 'p-web',
|
||||
display_name: 'Webhallen Oktober · Dataskärmar till kontoret',
|
||||
org_number: null,
|
||||
alias_keys: ['webhallen oktober dataskärmar till kontoret'],
|
||||
status: 'confirmed',
|
||||
}
|
||||
const item = buildSuggestions({ observed: [observed({ key: 'webhallen', name: text })], evidence: [], existing: [confirmed] }).items[0]!
|
||||
expect(item.party_id).toBe('p-web')
|
||||
expect(item.reason.attach).toBe('alias_key')
|
||||
expect(item.alias_keys).toEqual(['webhallen'])
|
||||
})
|
||||
|
||||
it('withholds the hard key and identities when a key mixes two org numbers', () => {
|
||||
const r = buildSuggestions({
|
||||
observed: [observed({ key: 'vattenfall' })],
|
||||
|
||||
@@ -98,3 +98,28 @@ export function displayNameFromVoucherText(raw: string): string {
|
||||
.trim()
|
||||
return cleaned.length >= 2 ? cleaned : raw.trim()
|
||||
}
|
||||
|
||||
const LEGACY_AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/
|
||||
const LEGACY_LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/
|
||||
const LEGACY_TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/
|
||||
|
||||
/**
|
||||
* The ledger key as it was computed before 2026-09-04 (migration
|
||||
* 20260904002000): the whole description normalised, with only the AP
|
||||
* prefix and short numbers stripped. Parties confirmed under that key keep
|
||||
* it as an alias, and the vouchers that produced it now map to the new key,
|
||||
* so a rebuild would otherwise offer the same company again as a fresh
|
||||
* suggestion. buildSuggestions asks this for every voucher text and attaches
|
||||
* the new key to the party that already owns the old one.
|
||||
*/
|
||||
export function legacyLedgerKey(raw: string | null | undefined): string {
|
||||
const k = normalizeCounterpartyName(raw ?? '')
|
||||
if (!k) return ''
|
||||
const stripped = k
|
||||
.replace(LEGACY_AP_PREFIX, '')
|
||||
.replace(LEGACY_LEADING_SUPPLIER_NUMBER, '')
|
||||
.replace(LEGACY_TRAILING_SHORT_DIGITS, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return stripped === '' ? k : stripped
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Parties: the name a register gives, as the register shows it.
|
||||
*
|
||||
* Bolagsverket registers many company names in capitals, and SCB returns
|
||||
* them that way ("WEBHALLEN SVERIGE AB", "AKTIEBOLAGET VOLVO"). A supplier
|
||||
* list full of capitals reads like shouting, so an all-capitals name is set
|
||||
* in title case with the legal-form tokens kept as they are written. A name
|
||||
* with any lowercase letter is a name someone typed deliberately and is left
|
||||
* alone. legal_name and the fact keep the registry's own spelling; only the
|
||||
* display name is set this way.
|
||||
*/
|
||||
|
||||
const KEEP_UPPER = new Set(['AB', 'HB', 'KB', 'AS', 'ASA', 'OY', 'OYJ', 'BV', 'NV', 'SA', 'AG', 'LLC', 'PLC', 'KG', 'GMBH', 'USA', 'UK', 'EU', 'IKEA', 'SJ', 'SL', 'SEB', 'ICA', 'SCB', 'KPMG', 'PWC', 'EY', 'BDO', 'ABB', 'SKF', 'SSAB', 'SAS', 'NCC', 'JM', 'HSB', 'LRF', 'ATG'])
|
||||
const KEEP_LOWER = new Set(['AV', 'OCH', 'I', 'FÖR', 'MED', 'PÅ', 'TILL', 'FRÅN', 'DE', 'DEL', 'VON', 'VAN', 'DER', 'DEN', 'OF', 'THE', 'AND'])
|
||||
|
||||
function isAllCaps(s: string): boolean {
|
||||
return /\p{Lu}/u.test(s) && !/\p{Ll}/u.test(s)
|
||||
}
|
||||
|
||||
function titleWord(word: string, first: boolean): string {
|
||||
const upper = word.toUpperCase()
|
||||
if (upper === '(PUBL)') return '(publ)'
|
||||
if (KEEP_UPPER.has(upper)) return upper === 'GMBH' ? 'GmbH' : upper
|
||||
if (!first && KEEP_LOWER.has(upper)) return word.toLowerCase()
|
||||
// Hyphenated and apostrophe parts each get a capital: "SVENSK-DANSKA" -> "Svensk-Danska".
|
||||
return word.toLowerCase().replace(/(^|[-'’/&(])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toUpperCase())
|
||||
}
|
||||
|
||||
/** The display form of a registry name: title case when the register wrote it in capitals. */
|
||||
export function displayNameFromRegistry(legalName: string): string {
|
||||
const trimmed = legalName.replace(/\s+/g, ' ').trim()
|
||||
if (!trimmed || !isAllCaps(trimmed)) return trimmed
|
||||
return trimmed
|
||||
.split(' ')
|
||||
.map((w, i) => titleWord(w, i === 0))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Same company name, spelling and case aside. */
|
||||
export function sameName(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
|
||||
return norm(a) !== '' && norm(a) === norm(b)
|
||||
}
|
||||
+14
-1
@@ -18,7 +18,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { coreKey, displayNameFromVoucherText } from './ledger-key'
|
||||
import { coreKey, displayNameFromVoucherText, legacyLedgerKey } from './ledger-key'
|
||||
import { extractNameCandidates, extractVatNumbers } from './name-extract'
|
||||
import { getObservedParties, type ObservedParty } from './observed'
|
||||
|
||||
@@ -220,6 +220,19 @@ export function buildSuggestions(input: {
|
||||
const org = orgs.length === 1 ? orgs[0]!.org : undefined
|
||||
const name = pickName(o, ev)
|
||||
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
|
||||
if (!existing) {
|
||||
// A party claimed under the pre-2026-09-04 key keeps its vouchers: the
|
||||
// old key is still an alias on it, and the same texts now produce the
|
||||
// new key. Without this, every confirmed party from before the key
|
||||
// change would come back as a fresh suggestion.
|
||||
for (const text of voucherTexts(o)) {
|
||||
const legacy = byAlias.get(legacyLedgerKey(text))
|
||||
if (legacy) {
|
||||
existing = legacy
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
let attach: SuggestionReason['attach'] = existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new'
|
||||
if (!existing && name.anchored) {
|
||||
const byName = byLegalName.get(name.display.trim().toLowerCase())
|
||||
|
||||
Reference in New Issue
Block a user