feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
f33628f005
commit
0ad83b8d71
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* gnubok_get_party: the party behind a supplier or customer for an agent.
|
||||
* The dossier itself is built by lib/parties (tested there); this checks the
|
||||
* argument contract and the row-to-party resolution.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const expandParty = vi.fn()
|
||||
vi.mock('@/lib/parties/party-api', () => ({ expandParty: (...args: unknown[]) => expandParty(...args) }))
|
||||
|
||||
import { tools } from '../server'
|
||||
|
||||
const getParty = () => tools.find((t) => t.name === 'gnubok_get_party')!
|
||||
const PARTY = { id: 'p-1', display_name: 'Webhallen Sverige AB', org_number: '5565588224', registry: { status: { label: 'Verksamt', active: true } } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_get_party', () => {
|
||||
it('is read-only and demands exactly one id', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
expect(getParty().annotations).toMatchObject({ readOnlyHint: true })
|
||||
await expect(getParty().execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow(/exactly one/)
|
||||
await expect(getParty().execute({ party_id: 'p-1', supplier_id: 's-1' }, 'company-1', 'user-1', supabase as never)).rejects.toThrow(/exactly one/)
|
||||
expect(expandParty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a supplier to its party and scopes the lookup to the company', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 's-1', party_id: 'p-1' } })
|
||||
expandParty.mockResolvedValue(PARTY)
|
||||
const result = await getParty().execute({ supplier_id: 's-1' }, 'company-1', 'user-1', supabase as never)
|
||||
expect(result).toEqual({ party: PARTY, found: true })
|
||||
expect(findCalls('suppliers', 'eq')).toEqual([
|
||||
['company_id', 'company-1'],
|
||||
['id', 's-1'],
|
||||
])
|
||||
expect(expandParty).toHaveBeenCalledWith(supabase, 'company-1', 'p-1')
|
||||
})
|
||||
|
||||
it('answers found:false for an unknown row, a row without a party, and a dismissed party', async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
expect(await getParty().execute({ customer_id: 'c-9' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
|
||||
reset()
|
||||
enqueue({ data: { id: 'c-1', party_id: null } })
|
||||
expect(await getParty().execute({ customer_id: 'c-1' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
|
||||
expect(expandParty).not.toHaveBeenCalled()
|
||||
expandParty.mockResolvedValue(null)
|
||||
expect(await getParty().execute({ party_id: 'p-gone' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
|
||||
})
|
||||
})
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
} from '@/lib/reports/vat-filing-gate'
|
||||
import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { expandParty } from '@/lib/parties/party-api'
|
||||
import { listForCompany as listCashAccountsForCompany } from '@/lib/cash-accounts/service'
|
||||
import {
|
||||
looksLikeSwedishPersonalNumber,
|
||||
@@ -5948,7 +5949,7 @@ export const tools: McpTool[] = [
|
||||
rows = await fetchAllRows<ListedCustomer>(({ from, to }) => {
|
||||
const query = supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, archived_at')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, party_id, archived_at')
|
||||
.eq('company_id', companyId)
|
||||
return (includeArchived ? query : query.is('archived_at', null))
|
||||
.order('id', { ascending: true })
|
||||
@@ -8481,7 +8482,7 @@ export const tools: McpTool[] = [
|
||||
suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => {
|
||||
const query = supabase
|
||||
.from('suppliers')
|
||||
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, archived_at')
|
||||
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, party_id, archived_at')
|
||||
.eq('company_id', companyId)
|
||||
return (includeArchived ? query : query.is('archived_at', null))
|
||||
.order('id', { ascending: true })
|
||||
@@ -8496,6 +8497,64 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_party',
|
||||
// Specialized: found through gnubok_search_tools and named from the list
|
||||
// tools' party_id; keeps the default tools/list under its byte budget.
|
||||
catalogVisibility: 'search',
|
||||
keywords: ['motpart', 'part', 'leverantör', 'kund', 'företagsregistret', 'scb', 'org.nr', 'organisationsnummer', 'bolagsform', 'f-skatt'],
|
||||
title: 'Get Party (Motpart) Behind a Supplier or Customer',
|
||||
description:
|
||||
'The party (motpart) behind a supplier or customer: legal name, org/VAT number, country, the SCB register summary (status, legal form, industry, seat, size, registrations, contact) and what the ledger has seen. Pass exactly one of party_id, supplier_id, customer_id. Read-only.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
party_id: { type: 'string', format: 'uuid', description: 'The party id.' },
|
||||
supplier_id: { type: 'string', format: 'uuid', description: 'A supplier id; its party is returned.' },
|
||||
customer_id: { type: 'string', format: 'uuid', description: 'A customer id; its party is returned. Private individuals have none.' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
party: { type: ['object', 'null'] },
|
||||
found: { type: 'boolean' },
|
||||
},
|
||||
required: ['party', 'found'],
|
||||
},
|
||||
annotations: ANNOTATIONS_READ_ONLY,
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const given = (['party_id', 'supplier_id', 'customer_id'] as const).filter((k) => typeof args[k] === 'string' && (args[k] as string).trim())
|
||||
if (given.length !== 1) {
|
||||
throw new Error('Pass exactly one of party_id, supplier_id or customer_id.')
|
||||
}
|
||||
let partyId: string | null = null
|
||||
if (given[0] === 'party_id') partyId = String(args.party_id)
|
||||
else {
|
||||
const table = given[0] === 'supplier_id' ? 'suppliers' : 'customers'
|
||||
const { data: row, error } = await supabase
|
||||
.from(table)
|
||||
.select('id, party_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', String(args[given[0]]))
|
||||
.maybeSingle()
|
||||
if (error) throw dbError(error)
|
||||
if (!row) return { party: null, found: false }
|
||||
partyId = (row as { party_id: string | null }).party_id
|
||||
}
|
||||
if (!partyId) return { party: null, found: false }
|
||||
let party
|
||||
try {
|
||||
party = await expandParty(supabase, companyId, partyId)
|
||||
} catch (error) {
|
||||
throw dbError(error)
|
||||
}
|
||||
return { party, found: party !== null }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_supplier',
|
||||
keywords: ['leverantör', 'ny leverantör'],
|
||||
|
||||
Reference in New Issue
Block a user