feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
be0478c219
commit
c6ca119e73
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const generateStructured = vi.fn()
|
||||
const status = { configured: true }
|
||||
vi.mock('@/lib/ai', () => ({
|
||||
getAiService: () => ({ generateStructured }),
|
||||
getAiStatus: () => ({ configured: status.configured }),
|
||||
}))
|
||||
|
||||
import { readCounterpartName, aiNameAvailable } from '../ai-name'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
status.configured = true
|
||||
})
|
||||
|
||||
describe('readCounterpartName', () => {
|
||||
it('reads the counterpart out of a card memo and normalises country and VAT', async () => {
|
||||
generateStructured.mockResolvedValue({
|
||||
value: { name: 'Booking.com', country: 'nl', vat_number: 'NL 805734958 B01', confidence: 'high' },
|
||||
model: 'test-model',
|
||||
usage: {},
|
||||
})
|
||||
const r = await readCounterpartName(['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
|
||||
expect(r).toEqual({ name: 'Booking.com', country: 'NL', vatNumber: 'NL805734958B01', confidence: 'high', model: 'test-model' })
|
||||
const req = generateStructured.mock.calls[0]![0] as { tier: string; prompt: string; schema: { name: string } }
|
||||
expect(req.tier).toBe('extraction')
|
||||
expect(req.prompt).toContain('1. Hotel at Booking.com')
|
||||
expect(req.schema.name).toBe('counterpart_reading')
|
||||
})
|
||||
|
||||
it('keeps a null name, drops malformed country and VAT values, and sends at most three distinct texts', async () => {
|
||||
generateStructured.mockResolvedValue({ value: { name: null, country: 'Sweden', vat_number: '123', confidence: 'weird' }, model: 'm', usage: {} })
|
||||
const r = await readCounterpartName(['a', 'a', 'b', 'c', 'd'])
|
||||
expect(r).toEqual({ name: null, country: null, vatNumber: null, confidence: 'low', model: 'm' })
|
||||
const req = generateStructured.mock.calls[0]![0] as { prompt: string }
|
||||
expect(req.prompt).toContain('3. c')
|
||||
expect(req.prompt).not.toContain('4. d')
|
||||
})
|
||||
|
||||
it('answers null without a call when the deployment has no model, on empty input, on a bad answer, and on an error', async () => {
|
||||
status.configured = false
|
||||
expect(aiNameAvailable()).toBe(false)
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
expect(generateStructured).not.toHaveBeenCalled()
|
||||
status.configured = true
|
||||
expect(await readCounterpartName(['', ' '])).toBeNull()
|
||||
generateStructured.mockResolvedValueOnce({ value: 'not an object', model: 'm', usage: {} })
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
generateStructured.mockRejectedValueOnce(new Error('boom'))
|
||||
expect(await readCounterpartName(['x'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,45 @@ describe('buildSuggestions', () => {
|
||||
expect(revenue.facts.some((f) => f.field === 'vat_number')).toBe(false)
|
||||
})
|
||||
|
||||
it('groups keys that name the same legal person into one suggestion, and attaches to an existing party by exact legal name', () => {
|
||||
const tic1 = 'TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'
|
||||
const tic2 = 'Utbetalning leverantörsfaktura 20250928, The Intelligence Company AB (publ)'
|
||||
const r = buildSuggestions({
|
||||
observed: [
|
||||
observed({ key: 'tic identity', name: tic1, expense_sek: 2385, occurrences: 1, first_seen: '2026-02-01', last_seen: '2026-02-01' }),
|
||||
observed({ key: 'utbetalning leverantörsfaktura the intelligence company publ', name: tic2, expense_sek: 2385, occurrences: 1, first_seen: '2026-03-01', last_seen: '2026-03-01' }),
|
||||
],
|
||||
evidence: [],
|
||||
existing: [],
|
||||
})
|
||||
expect(r.items).toHaveLength(1)
|
||||
const item = r.items[0]!
|
||||
expect(item.display_name).toBe('The Intelligence Company AB (publ)')
|
||||
expect(item.name_anchored).toBe(true)
|
||||
expect(item.alias_keys).toEqual(['tic identity', 'utbetalning leverantörsfaktura the intelligence company publ'])
|
||||
expect(item.reason.occurrences).toBe(2)
|
||||
expect(item.reason.expense_sek).toBe(4770)
|
||||
expect(item.reason.first_seen).toBe('2026-02-01')
|
||||
expect(item.reason.last_seen).toBe('2026-03-01')
|
||||
|
||||
const confirmed: ExistingParty = { id: 'p-tic', display_name: 'The Intelligence Company AB (publ)', org_number: '5594871682', alias_keys: [], status: 'confirmed' }
|
||||
const attached = buildSuggestions({ observed: [observed({ key: 'tic identity', name: tic1 })], evidence: [], existing: [confirmed] }).items[0]!
|
||||
expect(attached.party_id).toBe('p-tic')
|
||||
expect(attached.reason.attach).toBe('legal_name')
|
||||
|
||||
// A different org number on the key side is a different company with a confusable name.
|
||||
const other = buildSuggestions({
|
||||
observed: [observed({ key: 'tic identity', name: tic1 })],
|
||||
evidence: [{ key: 'tic identity', docs: 1, self_docs: 0, orgs: [{ org: '5560125790', n: 1 }], vat_numbers: [], names: [], bankgiro: [], plusgiro: [] }],
|
||||
existing: [confirmed],
|
||||
}).items[0]!
|
||||
expect(other.party_id).toBeUndefined()
|
||||
// A bank memo head never groups or attaches by name.
|
||||
const memo = buildSuggestions({ observed: [observed({ key: 'beijer byggmaterial', name: 'BEIJER BYGGMATERIAL 2089' })], evidence: [], existing: [{ id: 'p-b', display_name: 'BEIJER BYGGMATERIAL', org_number: null, alias_keys: [], status: 'confirmed' }] }).items[0]!
|
||||
expect(memo.party_id).toBeUndefined()
|
||||
expect(memo.name_anchored).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withholds the hard key and identities when a key mixes two org numbers', () => {
|
||||
const r = buildSuggestions({
|
||||
observed: [observed({ key: 'vattenfall' })],
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Parties: the model reads a counterpart out of a voucher text that the
|
||||
* rules could not anchor.
|
||||
*
|
||||
* lib/parties/name-extract.ts names a company when the text carries a legal
|
||||
* form or a country word. Bank memos carry neither: "Hotel at Booking.com
|
||||
* K3667 Kortköp/uttag · Hotell, svenskt boende", "UBER *TRIP HELP.UBER.COM".
|
||||
* For those, and only those, one model call reads the counterpart the way a
|
||||
* bookkeeper would. The reading is a fact with source 'model', shown as
|
||||
* "läst ur verifikatet", used as the registry query and never as a hard key:
|
||||
* an org number still comes from SCB and a person's click, a VAT number only
|
||||
* when it is written in the text.
|
||||
*
|
||||
* Runs on demand (the picker, the review list), not when the queue builds:
|
||||
* a queue of five hundred rows would otherwise cost five hundred calls that
|
||||
* nobody asked for, and a rebuild would repeat them.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { getAiService, getAiStatus } from '@/lib/ai'
|
||||
|
||||
export interface AiNameReading {
|
||||
/** The counterpart as the model reads it, or null when the text names none. */
|
||||
name: string | null
|
||||
/** ISO 3166-1 alpha-2 when the text says where the counterpart is. */
|
||||
country: string | null
|
||||
/** A VAT number written in the text, if any. */
|
||||
vatNumber: string | null
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
model: string
|
||||
}
|
||||
|
||||
export function aiNameAvailable(): boolean {
|
||||
return getAiStatus().configured
|
||||
}
|
||||
|
||||
const SYSTEM = [
|
||||
'You read descriptions of Swedish bookkeeping vouchers (verifikat) and name the counterpart: the company or organisation the money went to or came from.',
|
||||
'Answer only from the text. Card memos abbreviate: "UBER *TRIP HELP.UBER.COM" is Uber, "Hotel at Booking.com" is Booking.com, "ANTHROPIC* CLAUDE SUB" is Anthropic.',
|
||||
'Leave out payment method words (Kortköp/uttag, Överföring via internet, Bg-bet), references, dates, amounts, account notes and VAT commentary.',
|
||||
'Give the name as the company writes it, with its legal form only if the text has it. Do not invent a legal form or an org number.',
|
||||
'country: ISO 3166-1 alpha-2 only when the text states or unmistakably implies it (Ireland, (NL), USA, utländsk moms with a named country); otherwise null.',
|
||||
'vat_number: only a VAT number written in the text, letters and digits, no spaces; otherwise null.',
|
||||
'If the text names no counterpart (a fee, a category, a transfer between own accounts, a salary), answer name null.',
|
||||
].join(' ')
|
||||
|
||||
const SCHEMA = {
|
||||
name: 'counterpart_reading',
|
||||
description: 'The counterpart named in the voucher text, or null.',
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: ['string', 'null'] },
|
||||
country: { type: ['string', 'null'] },
|
||||
vat_number: { type: ['string', 'null'] },
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
},
|
||||
required: ['name', 'country', 'vat_number', 'confidence'],
|
||||
},
|
||||
}
|
||||
|
||||
const Reading = z.object({
|
||||
name: z.string().trim().min(1).max(120).nullable(),
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.toUpperCase())
|
||||
.pipe(z.string().regex(/^[A-Z]{2}$/))
|
||||
.nullable()
|
||||
.catch(null),
|
||||
vat_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.replace(/[^0-9A-Za-z]/g, '').toUpperCase())
|
||||
.pipe(z.string().regex(/^[A-Z]{2}[0-9A-Z]{8,12}$/))
|
||||
.nullable()
|
||||
.catch(null),
|
||||
confidence: z.enum(['high', 'medium', 'low']).catch('low'),
|
||||
})
|
||||
|
||||
export const AI_NAME_MAX_TEXTS = 3
|
||||
|
||||
/**
|
||||
* One call for one party. Returns null when the deployment has no model or
|
||||
* the answer is unusable; the caller then searches on the memo as before.
|
||||
*/
|
||||
export async function readCounterpartName(texts: string[]): Promise<AiNameReading | null> {
|
||||
const distinct = [...new Set(texts.map((t) => t.trim()).filter(Boolean))].slice(0, AI_NAME_MAX_TEXTS)
|
||||
if (distinct.length === 0 || !aiNameAvailable()) return null
|
||||
const prompt = ['Voucher descriptions for one counterpart:', ...distinct.map((t, i) => `${i + 1}. ${t}`)].join('\n')
|
||||
try {
|
||||
const result = await getAiService().generateStructured({ tier: 'extraction', system: SYSTEM, prompt, maxTokens: 200, schema: SCHEMA })
|
||||
const parsed = Reading.safeParse(result.value)
|
||||
if (!parsed.success) return null
|
||||
return {
|
||||
name: parsed.data.name,
|
||||
country: parsed.data.country,
|
||||
vatNumber: parsed.data.vat_number,
|
||||
confidence: parsed.data.confidence,
|
||||
model: result.model,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -56,6 +56,12 @@ export interface RegisterRow {
|
||||
/** What confirming this suggestion creates, read from which side of the ledger it sits on. */
|
||||
defaultRoles: PartyRole[]
|
||||
createdAt: string
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 read out of the voucher text or a register. Anything
|
||||
* but SE means SCB cannot hold the party, so the queue says so instead of
|
||||
* offering a search that cannot succeed.
|
||||
*/
|
||||
country: string | null
|
||||
}
|
||||
|
||||
export interface ObservedRow {
|
||||
@@ -234,7 +240,7 @@ export async function getRegister(
|
||||
const period = options.period ?? '12m'
|
||||
const q = normalizeQuery(options.q)
|
||||
|
||||
const [parties, customers, suppliers, observed, customerCounts, supplierCounts] = await Promise.all([
|
||||
const [parties, customers, suppliers, observed, customerCounts, supplierCounts, countryFacts] = await Promise.all([
|
||||
// Archived (dismissed) parties stay out of the list but keep their keys
|
||||
// claimed, so a dismissed suggestion does not resurface as observed.
|
||||
fetchAllRows<PartyRecord>(({ from, to }) =>
|
||||
@@ -259,8 +265,24 @@ export async function getRegister(
|
||||
fetchAllRows<{ supplier_id: string }>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('supplier_id').eq('company_id', companyId).not('supplier_id', 'is', null).range(from, to),
|
||||
),
|
||||
fetchAllRows<{ party_id: string; value: unknown; recorded_at: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('party_facts')
|
||||
.select('party_id, value, recorded_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('field', 'country')
|
||||
.is('superseded_at', null)
|
||||
.order('recorded_at', { ascending: false })
|
||||
.range(from, to),
|
||||
),
|
||||
])
|
||||
|
||||
const countryByParty = new Map<string, string>()
|
||||
for (const f of countryFacts) {
|
||||
const code = typeof f.value === 'string' ? f.value.trim().toUpperCase() : ''
|
||||
if (/^[A-Z]{2}$/.test(code) && !countryByParty.has(f.party_id)) countryByParty.set(f.party_id, code)
|
||||
}
|
||||
|
||||
const customerByParty = new Map<string, string>()
|
||||
for (const c of customers) if (c.party_id && !customerByParty.has(c.party_id)) customerByParty.set(c.party_id, c.id)
|
||||
const supplierByParty = new Map<string, string>()
|
||||
@@ -300,6 +322,7 @@ export async function getRegister(
|
||||
similar: similarById.get(p.id) ?? [],
|
||||
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
|
||||
createdAt: p.created_at,
|
||||
country: countryByParty.get(p.id) ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,6 +534,7 @@ export async function getDossier(supabase: SupabaseClient, companyId: string, pa
|
||||
similar: similar.map((s) => ({ id: s.id, displayName: s.displayName })),
|
||||
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
|
||||
createdAt: p.created_at,
|
||||
country: (facts.data as Array<{ field: string; value: unknown }> | null)?.find((f) => f.field === 'country' && typeof f.value === 'string')?.value as string | null ?? null,
|
||||
},
|
||||
facts: ((facts.data ?? []) as Array<Record<string, unknown>>).map((f) => ({
|
||||
id: f.id as string,
|
||||
|
||||
@@ -28,6 +28,17 @@ 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
|
||||
|
||||
+82
-6
@@ -17,6 +17,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 { extractNameCandidates, extractVatNumbers } from './name-extract'
|
||||
import { getObservedParties, type ObservedParty } from './observed'
|
||||
@@ -42,6 +43,7 @@ export interface LedgerKeyEvidence {
|
||||
export interface ExistingParty {
|
||||
id: string
|
||||
display_name: string
|
||||
legal_name?: string | null
|
||||
org_number: string | null
|
||||
alias_keys: string[]
|
||||
status: 'suggested' | 'confirmed'
|
||||
@@ -64,7 +66,7 @@ export interface SuggestionIdentity {
|
||||
|
||||
export interface SuggestionReason {
|
||||
/** How the key attaches, or why it becomes a new party. */
|
||||
attach: 'party_id' | 'org_number' | 'alias_key' | 'new'
|
||||
attach: 'party_id' | 'org_number' | 'alias_key' | 'legal_name' | 'new'
|
||||
occurrences: number
|
||||
expense_sek: number
|
||||
revenue_sek: number
|
||||
@@ -91,6 +93,12 @@ export interface SuggestionItem {
|
||||
vat_number?: string
|
||||
party_id?: string
|
||||
alias_keys: string[]
|
||||
/**
|
||||
* The display name is the legal person named in the voucher text (a
|
||||
* legal-form anchor), not a cleaned bank memo. apply_party_suggestions
|
||||
* may rename an untouched suggestion to it on a later run.
|
||||
*/
|
||||
name_anchored?: boolean
|
||||
reason: SuggestionReason
|
||||
facts: SuggestionFact[]
|
||||
identities: SuggestionIdentity[]
|
||||
@@ -113,6 +121,8 @@ interface PickedName {
|
||||
country?: string
|
||||
/** The text points abroad: foreign legal form, country word or VAT prefix. */
|
||||
foreign?: boolean
|
||||
/** The name is a legal person read out of the text, legal form included. */
|
||||
anchored?: boolean
|
||||
}
|
||||
|
||||
/** The voucher texts under a key, most common first, at most three. */
|
||||
@@ -138,7 +148,14 @@ function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefin
|
||||
candidates.find((c) => c.source === 'legal_form' && !c.foreign) ??
|
||||
candidates.find((c) => c.source === 'legal_form') ??
|
||||
candidates.find((c) => c.source === 'country')
|
||||
if (anchored) return { display: anchored.name, ...(anchored.country ? { country: anchored.country } : {}), foreign: anchored.foreign }
|
||||
if (anchored) {
|
||||
return {
|
||||
display: anchored.name,
|
||||
...(anchored.country ? { country: anchored.country } : {}),
|
||||
foreign: anchored.foreign,
|
||||
anchored: anchored.source === 'legal_form',
|
||||
}
|
||||
}
|
||||
const head = candidates.find((c) => c.source === 'head')
|
||||
return {
|
||||
display: displayNameFromVoucherText(observed.name || observed.key),
|
||||
@@ -172,8 +189,16 @@ export function buildSuggestions(input: {
|
||||
const byOrg = new Map<string, ExistingParty>()
|
||||
const byAlias = new Map<string, ExistingParty>()
|
||||
const byCore = new Map<string, ExistingParty[]>()
|
||||
// Exact legal names, legal form included: registered company names are
|
||||
// unique in Sweden, so "Visma Spcs AB" read out of a voucher text is the
|
||||
// party already called that. Never a fuzzy match; never without the form.
|
||||
const byLegalName = new Map<string, ExistingParty>()
|
||||
for (const p of input.existing) {
|
||||
if (p.org_number && !byOrg.has(p.org_number)) byOrg.set(p.org_number, p)
|
||||
for (const n of [p.display_name, p.legal_name]) {
|
||||
const k = (n ?? '').trim().toLowerCase()
|
||||
if (k && !byLegalName.has(k)) byLegalName.set(k, p)
|
||||
}
|
||||
for (const a of p.alias_keys) if (!byAlias.has(a)) byAlias.set(a, p)
|
||||
const c = coreKey(p.display_name)
|
||||
if (c) byCore.set(c, [...(byCore.get(c) ?? []), p])
|
||||
@@ -193,10 +218,20 @@ export function buildSuggestions(input: {
|
||||
const ev = evidenceByKey.get(o.key)
|
||||
const orgs = ev?.orgs ?? []
|
||||
const org = orgs.length === 1 ? orgs[0]!.org : undefined
|
||||
const existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
|
||||
const name = pickName(o, ev)
|
||||
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
|
||||
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())
|
||||
// A different org number on either side means a different company
|
||||
// with a confusable name; the exact-name rule never overrides a key.
|
||||
if (byName && (!org || !byName.org_number || byName.org_number === org)) {
|
||||
existing = byName
|
||||
attach = 'legal_name'
|
||||
}
|
||||
}
|
||||
const reason: SuggestionReason = {
|
||||
attach: existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new',
|
||||
attach,
|
||||
occurrences: o.occurrences,
|
||||
expense_sek: o.expense_sek,
|
||||
revenue_sek: o.revenue_sek,
|
||||
@@ -260,6 +295,7 @@ export function buildSuggestions(input: {
|
||||
key: o.key,
|
||||
display_name: name.display,
|
||||
...(name.legal ? { legal_name: name.legal } : {}),
|
||||
...(name.anchored ? { name_anchored: true } : {}),
|
||||
kind: 'company',
|
||||
origin: org ? 'document' : 'ledger',
|
||||
...(org ? { org_number: org } : {}),
|
||||
@@ -271,7 +307,47 @@ export function buildSuggestions(input: {
|
||||
identities,
|
||||
})
|
||||
}
|
||||
return { items, skipped }
|
||||
return { items: groupByLegalName(items), skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Two keys that name the same legal person, legal form included, become one
|
||||
* suggestion with both keys as aliases, so "TIC identity · ... The
|
||||
* Intelligence Company AB (publ)" and "Utbetalning leverantörsfaktura, The
|
||||
* Intelligence Company AB (publ)" do not turn into two suppliers. Only for
|
||||
* new items whose name is anchored on a legal form; hard keys and existing
|
||||
* parties are already settled by then.
|
||||
*/
|
||||
function groupByLegalName(items: SuggestionItem[]): SuggestionItem[] {
|
||||
const heads = new Map<string, SuggestionItem>()
|
||||
const out: SuggestionItem[] = []
|
||||
for (const item of items) {
|
||||
const groupable = item.name_anchored && !item.party_id && !item.org_number
|
||||
const k = groupable ? item.display_name.trim().toLowerCase() : null
|
||||
const head = k ? heads.get(k) : undefined
|
||||
if (!head) {
|
||||
if (k) heads.set(k, item)
|
||||
out.push(item)
|
||||
continue
|
||||
}
|
||||
head.alias_keys = [...new Set([...head.alias_keys, ...item.alias_keys])]
|
||||
head.reason.occurrences += item.reason.occurrences
|
||||
head.reason.expense_sek = roundOre(head.reason.expense_sek + item.reason.expense_sek)
|
||||
head.reason.revenue_sek = roundOre(head.reason.revenue_sek + item.reason.revenue_sek)
|
||||
head.reason.docs += item.reason.docs
|
||||
head.reason.self_docs += item.reason.self_docs
|
||||
if (item.reason.first_seen < head.reason.first_seen) head.reason.first_seen = item.reason.first_seen
|
||||
if (item.reason.last_seen > head.reason.last_seen) head.reason.last_seen = item.reason.last_seen
|
||||
if (!head.vat_number && item.vat_number) head.vat_number = item.vat_number
|
||||
head.identities.push(...item.identities)
|
||||
const headTexts = head.facts.find((f) => f.field === 'voucher_text')
|
||||
const itemTexts = item.facts.find((f) => f.field === 'voucher_text')
|
||||
if (headTexts && itemTexts && Array.isArray(headTexts.value) && Array.isArray(itemTexts.value)) {
|
||||
headTexts.value = [...new Set([...(headTexts.value as string[]), ...(itemTexts.value as string[])])].slice(0, 3)
|
||||
}
|
||||
for (const f of item.facts) if (f.field !== 'voucher_text' && !head.facts.some((h) => h.field === f.field)) head.facts.push(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface SuggestSummary {
|
||||
@@ -307,7 +383,7 @@ export async function suggestPartiesForCompany(
|
||||
const existing = await fetchAllRows<ExistingParty>(({ from, to }) =>
|
||||
supabase
|
||||
.from('parties')
|
||||
.select('id, display_name, org_number, alias_keys, status')
|
||||
.select('id, display_name, legal_name, org_number, alias_keys, status')
|
||||
.eq('company_id', companyId)
|
||||
.is('merged_into', null)
|
||||
.is('archived_at', null)
|
||||
|
||||
Reference in New Issue
Block a user