feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)
Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with dimensions_enabled=false see zero change; existing free-text API writers keep working (validation is toggle-governed). Engine (soft validation): - validateEntryDimensions() in dimension-resolver: zero queries for untagged entries; toggle off → passthrough; toggle on → one settings fetch + two registry queries, rejects unknown dims/codes and archived values with Swedish per-code messages (DimensionValidationError, 400, details.issues). Wired into createDraftEntry + updateDraftEntry before any insert; reversal/ storno paths untouched (verbatim copies). Fails open on transient registry errors — soft validation must never block bookkeeping. MCP (agent write path): - New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js fuzzy), gnubok_create_dimension_value (STAGED via pending_operations — agents never silently mint reporting values; new op type + CHECK migration + executor with duplicate-idempotency). - create_voucher/correct_entry: per-line dimensions bag + default_dimensions, resolve-don't-select server-side (code OR natural-language name; exact → fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with confidence; ambiguous → ranked candidates, no auto-create). - gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top values) — omitted when registry empty. - TOOL_SCOPE_MAP entries; risk tier low for staged value creation. UI: - JournalEntryForm (manual voucher + TransactionBookingDialog embed): header "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with documented inheritance rule) + per-row tag popover + compact KS·PR badges; gated on dimensions_enabled. - Voucher detail: display-only dimension badges with registry-name resolution. - EditDraftEntryDialog carries line dimensions so editing a draft no longer strips tags. categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6be6510d73
commit
11126d6d56
@@ -50,6 +50,10 @@ function mockSupabase(opts: {
|
||||
company?: { name: string | null; org_number: string | null; entity_type: string | null } | null
|
||||
// company_settings.accounting_method. undefined → no settings row (null data).
|
||||
accountingMethod?: string | null
|
||||
// Dimension registry (dimensions PR3). Default: empty → block omitted.
|
||||
dimensionRows?: Array<{ id: string; sie_dim_no: number; name: string }>
|
||||
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
|
||||
dimensionsEnabled?: boolean
|
||||
errors?: { profile?: string; memory?: string; atoms?: string }
|
||||
}) {
|
||||
const profile = opts.profile === undefined ? null : opts.profile
|
||||
@@ -57,6 +61,16 @@ function mockSupabase(opts: {
|
||||
const atomRows = opts.atomRows ?? []
|
||||
const errors = opts.errors ?? {}
|
||||
|
||||
/** Order-agnostic chainable query resolving to `data` when awaited. */
|
||||
const chainResolving = (data: unknown) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'limit']) {
|
||||
chain[m] = vi.fn(() => chain)
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data, error: null })
|
||||
return chain
|
||||
}
|
||||
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'profiles') {
|
||||
@@ -132,15 +146,24 @@ function mockSupabase(opts: {
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data:
|
||||
opts.accountingMethod === undefined
|
||||
opts.accountingMethod === undefined && opts.dimensionsEnabled === undefined
|
||||
? null
|
||||
: { accounting_method: opts.accountingMethod },
|
||||
: {
|
||||
accounting_method: opts.accountingMethod ?? null,
|
||||
dimensions_enabled: opts.dimensionsEnabled ?? false,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'dimensions') {
|
||||
return chainResolving(opts.dimensionRows ?? [])
|
||||
}
|
||||
if (table === 'dimension_values') {
|
||||
return chainResolving(opts.dimensionValueRows ?? [])
|
||||
}
|
||||
throw new Error(`Unexpected table in test mock: ${table}`)
|
||||
}),
|
||||
}
|
||||
@@ -344,4 +367,81 @@ describe('gnubok_get_agent_briefing tool', () => {
|
||||
tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/Failed to load agent memory.*rls-denied/)
|
||||
})
|
||||
|
||||
it('omits the dimensions block entirely when the registry is empty', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null })
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as Record<string, unknown>
|
||||
expect('dimensions' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the dimensions block with enabled flag, counts, and top values capped at 10', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const manyValues = Array.from({ length: 12 }, (_, i) => ({
|
||||
dimension_id: 'dim-6',
|
||||
code: `P${String(i + 1).padStart(3, '0')}`,
|
||||
name: `Projekt ${i + 1}`,
|
||||
}))
|
||||
const supabase = mockSupabase({
|
||||
profile: null,
|
||||
dimensionsEnabled: true,
|
||||
dimensionRows: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe' },
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt' },
|
||||
],
|
||||
dimensionValueRows: [
|
||||
{ dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm' },
|
||||
...manyValues,
|
||||
],
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
dimensions?: {
|
||||
enabled: boolean
|
||||
dimensions: Array<{
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
active_value_count: number
|
||||
top_values: Array<{ code: string; name: string }>
|
||||
}>
|
||||
}
|
||||
}
|
||||
expect(result.dimensions).toBeDefined()
|
||||
expect(result.dimensions!.enabled).toBe(true)
|
||||
expect(result.dimensions!.dimensions).toHaveLength(2)
|
||||
const [ks, projekt] = result.dimensions!.dimensions
|
||||
expect(ks).toMatchObject({ sie_dim_no: 1, name: 'Kostnadsställe', active_value_count: 1 })
|
||||
expect(ks.top_values).toEqual([{ code: 'KS01', name: 'Stockholm' }])
|
||||
expect(projekt.active_value_count).toBe(12)
|
||||
expect(projekt.top_values).toHaveLength(10) // capped
|
||||
})
|
||||
|
||||
it('reports enabled=false in the dimensions block when the toggle is off but values exist', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({
|
||||
profile: null,
|
||||
dimensionsEnabled: false,
|
||||
dimensionRows: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }],
|
||||
dimensionValueRows: [{ dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren' }],
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as { dimensions?: { enabled: boolean } }
|
||||
expect(result.dimensions?.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Dimensions PR3 — MCP surface tests.
|
||||
*
|
||||
* Covers the three new tools (gnubok_list_dimensions,
|
||||
* gnubok_list_dimension_values, staged gnubok_create_dimension_value), the
|
||||
* resolve-don't-select helper (exact / fuzzy / ambiguous / none / archived),
|
||||
* and the dims bag flowing from gnubok_create_voucher params into the staged
|
||||
* lines. Executor-side coverage (commitCreateDimensionValue incl. duplicate
|
||||
* idempotency) lives in lib/pending-operations/__tests__/.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { tools } from '../server'
|
||||
import {
|
||||
resolveValueInDimension,
|
||||
resolveDimensionBags,
|
||||
DimensionResolutionError,
|
||||
mergeLineDimensions,
|
||||
parseDimensionsArg,
|
||||
type DimensionRegistryEntry,
|
||||
} from '../dimensions'
|
||||
|
||||
const listDimensions = tools.find((t) => t.name === 'gnubok_list_dimensions')!
|
||||
const listDimensionValues = tools.find((t) => t.name === 'gnubok_list_dimension_values')!
|
||||
const createDimensionValue = tools.find((t) => t.name === 'gnubok_create_dimension_value')!
|
||||
const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')!
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeDim(overrides: Partial<DimensionRegistryEntry> = {}): DimensionRegistryEntry {
|
||||
return {
|
||||
id: 'dim-6',
|
||||
sie_dim_no: 6,
|
||||
name: 'Projekt',
|
||||
resets_annually: false,
|
||||
is_system: true,
|
||||
is_active: true,
|
||||
sort_order: 20,
|
||||
values: [
|
||||
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v3', code: 'P099', name: 'Gammalt projekt', is_active: false, start_date: null, end_date: null },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration + scopes ────────────────────────────────────────────────────
|
||||
|
||||
describe('dimension tools registration', () => {
|
||||
it('all three tools exist and are mapped in TOOL_SCOPE_MAP (unmapped = any key)', () => {
|
||||
expect(listDimensions).toBeDefined()
|
||||
expect(listDimensionValues).toBeDefined()
|
||||
expect(createDimensionValue).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_list_dimensions).toBe('reports:read')
|
||||
expect(TOOL_SCOPE_MAP.gnubok_list_dimension_values).toBe('reports:read')
|
||||
expect(TOOL_SCOPE_MAP.gnubok_create_dimension_value).toBe('bookkeeping:write')
|
||||
})
|
||||
|
||||
it('gnubok_create_dimension_value stages (staged-operation output contract)', () => {
|
||||
const schema = createDimensionValue.outputSchema as {
|
||||
properties?: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
expect(schema?.properties?.staged).toBeDefined()
|
||||
expect(schema?.required).toContain('staged')
|
||||
expect(createDimensionValue.description).toMatch(/stag(e|ing)/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── gnubok_list_dimensions ───────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_list_dimensions', () => {
|
||||
it('seeds system dims then returns the registry with nested values (dashboard GET shape)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = (await listDimensions.execute({}, 'company-1', 'user-1', supabase as never)) as {
|
||||
dimensions: Array<{ sie_dim_no: number; values: Array<{ code: string }> }>
|
||||
}
|
||||
|
||||
expect(result.dimensions).toHaveLength(2)
|
||||
expect(result.dimensions[0].sie_dim_no).toBe(1)
|
||||
expect(result.dimensions[0].values.map((v) => v.code)).toEqual(['KS01'])
|
||||
expect(result.dimensions[1].values.map((v) => v.code)).toEqual(['P001'])
|
||||
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
|
||||
})
|
||||
|
||||
it('surfaces the ensure RPC failure', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'rls denied' } })
|
||||
await expect(
|
||||
listDimensions.execute({}, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/rls denied/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── gnubok_list_dimension_values ─────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_list_dimension_values', () => {
|
||||
it('rejects an unregistered dimension number', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: null, error: null }) // dimensions lookup → not found
|
||||
await expect(
|
||||
listDimensionValues.execute({ sie_dim_no: 12 }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/Dimension 12 finns inte/)
|
||||
})
|
||||
|
||||
it('lists values alphabetically without a query', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', code: 'P002', name: 'Kontorsflytt', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = (await listDimensionValues.execute(
|
||||
{ sie_dim_no: 6 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { dimension: { sie_dim_no: number }; values: Array<{ code: string; confidence?: number }>; count: number }
|
||||
|
||||
expect(result.dimension.sie_dim_no).toBe(6)
|
||||
expect(result.count).toBe(2)
|
||||
expect(result.values[0].confidence).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ranks by fuzzy confidence when a query is given', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = (await listDimensionValues.execute(
|
||||
{ sie_dim_no: 6, query: 'villa almgren' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { values: Array<{ code: string; confidence?: number }> }
|
||||
|
||||
expect(result.values[0].code).toBe('P001')
|
||||
expect(result.values[0].confidence).toBeGreaterThan(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
// ── gnubok_create_dimension_value (staging) ──────────────────────────────────
|
||||
|
||||
describe('gnubok_create_dimension_value — staging', () => {
|
||||
it('rejects a non-Fortnox code before any staging', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
createDimensionValue.execute(
|
||||
{ sie_dim_no: 6, code: 'P 001"', name: 'Bad code' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Koden får bara innehålla/)
|
||||
})
|
||||
|
||||
it('rejects when the code already exists as an active value', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
|
||||
enqueue({ data: { id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true }, error: null })
|
||||
|
||||
await expect(
|
||||
createDimensionValue.execute(
|
||||
{ sie_dim_no: 6, code: 'P001', name: 'Duplicate' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/finns redan/)
|
||||
})
|
||||
|
||||
it('rejects an archived code with the reactivation hint', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
|
||||
enqueue({ data: { id: 'v1', code: 'P099', name: 'Gammalt', is_active: false }, error: null })
|
||||
|
||||
await expect(
|
||||
createDimensionValue.execute(
|
||||
{ sie_dim_no: 6, code: 'P099', name: 'Revive attempt' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/arkiverat — återaktivera/)
|
||||
})
|
||||
|
||||
it('rejects value dates on a resets-annually dimension (kostnadsställe)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
|
||||
|
||||
await expect(
|
||||
createDimensionValue.execute(
|
||||
{ sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Start-\/slutdatum är inte tillåtna/)
|
||||
})
|
||||
|
||||
it('happy path: stages a create_dimension_value pending operation', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
|
||||
enqueue({ data: null, error: null }) // duplicate lookup → none
|
||||
enqueue({ data: { id: 'op-dim-1' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await createDimensionValue.execute(
|
||||
{ sie_dim_no: 6, code: 'P010', name: 'Villa Almgren etapp 2' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.operation_id).toBe('op-dim-1')
|
||||
expect(result.risk_level).toBe('low')
|
||||
expect(result.preview.code).toBe('P010')
|
||||
expect(result.preview.dimension_name).toBe('Projekt')
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
|
||||
})
|
||||
|
||||
it('dry_run validates and previews without staging', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
|
||||
enqueue({ data: null, error: null }) // duplicate lookup → none
|
||||
|
||||
const result = (await createDimensionValue.execute(
|
||||
{ sie_dim_no: 6, code: 'P010', name: 'Etapp 2', dry_run: true },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; dry_run?: boolean }
|
||||
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.dry_run).toBe(true)
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Resolve-don't-select helper ──────────────────────────────────────────────
|
||||
|
||||
describe('resolveValueInDimension', () => {
|
||||
it('resolves an exact code match without echo (exact=true)', () => {
|
||||
const r = resolveValueInDimension(makeDim(), 'P001')
|
||||
expect(r).toMatchObject({ code: 'P001', exact: true, confidence: 1 })
|
||||
})
|
||||
|
||||
it('resolves an exact name match with confidence 1 but exact=false (echoed)', () => {
|
||||
const r = resolveValueInDimension(makeDim(), 'Kontorsflytt Solna')
|
||||
expect(r).toMatchObject({ code: 'P002', exact: false, confidence: 1 })
|
||||
})
|
||||
|
||||
it('resolves a unique high-confidence fuzzy hit', () => {
|
||||
const r = resolveValueInDimension(makeDim(), 'villa almgren tak')
|
||||
expect(r.code).toBe('P001')
|
||||
expect(r.exact).toBe(false)
|
||||
expect(r.confidence).toBeGreaterThan(0.7)
|
||||
})
|
||||
|
||||
it('rejects an archived code with the Swedish reactivation message', () => {
|
||||
expect(() => resolveValueInDimension(makeDim(), 'P099')).toThrow(/arkiverat — återaktivera/)
|
||||
})
|
||||
|
||||
it('rejects an unknown value with the create-first instruction (never auto-creates)', () => {
|
||||
try {
|
||||
resolveValueInDimension(makeDim(), 'Bryggeriet ombyggnad')
|
||||
expect.unreachable('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DimensionResolutionError)
|
||||
expect((err as Error).message).toMatch(/Okänt projekt: "Bryggeriet ombyggnad" \(dimension 6\)/)
|
||||
expect((err as Error).message).toMatch(/gnubok_create_dimension_value/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ambiguous near-tied candidates with a ranked list', () => {
|
||||
const dim = makeDim({
|
||||
values: [
|
||||
{ id: 'v1', code: 'P001', name: 'Villa Almgren etapp 1', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', code: 'P002', name: 'Villa Almgren etapp 2', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
})
|
||||
try {
|
||||
resolveValueInDimension(dim, 'Villa Almgren etapp')
|
||||
expect.unreachable('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DimensionResolutionError)
|
||||
const e = err as DimensionResolutionError
|
||||
expect(e.candidates.length).toBeGreaterThanOrEqual(2)
|
||||
expect(e.message).toMatch(/Kandidater/)
|
||||
expect(e.message).toMatch(/gnubok_create_dimension_value/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDimensionBags', () => {
|
||||
it('costs zero queries when no line carries a bag', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await resolveDimensionBags(supabase as never, 'company-1', [undefined, undefined])
|
||||
expect(result.resolutions).toEqual([])
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes bags through verbatim when dimensions_enabled is false (backward compatible)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings
|
||||
|
||||
const result = await resolveDimensionBags(supabase as never, 'company-1', [{ '6': 'fritext-projekt' }])
|
||||
expect(result.bags).toEqual([{ '6': 'fritext-projekt' }])
|
||||
expect(result.resolutions).toEqual([])
|
||||
expect(supabase.rpc).not.toHaveBeenCalled() // no ensure, no registry reads
|
||||
})
|
||||
|
||||
it('resolves names to codes and echoes non-exact resolutions when enabled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await resolveDimensionBags(supabase as never, 'company-1', [
|
||||
{ '1': 'KS01', '6': 'villa almgren tak' },
|
||||
{ '6': 'villa almgren tak' },
|
||||
])
|
||||
|
||||
expect(result.bags).toEqual([
|
||||
{ '1': 'KS01', '6': 'P001' },
|
||||
{ '6': 'P001' },
|
||||
])
|
||||
// Exact code KS01 is not echoed; the fuzzy name is echoed ONCE (cached).
|
||||
expect(result.resolutions).toHaveLength(1)
|
||||
expect(result.resolutions[0]).toMatchObject({
|
||||
dimension: 6,
|
||||
input: 'villa almgren tak',
|
||||
resolved_code: 'P001',
|
||||
resolved_name: 'Villa Almgren takrenovering',
|
||||
})
|
||||
expect(result.resolutions[0].confidence).toBeGreaterThan(0.7)
|
||||
})
|
||||
|
||||
it('rejects a dim number with no registry row when enabled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null })
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({
|
||||
data: [{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
await expect(
|
||||
resolveDimensionBags(supabase as never, 'company-1', [{ '12': 'X' }]),
|
||||
).rejects.toThrow(/Okänd dimension 12/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeLineDimensions / parseDimensionsArg', () => {
|
||||
it('line bag wins per key over defaults and legacy aliases fill unset keys', () => {
|
||||
const merged = mergeLineDimensions(
|
||||
{ dimensions: { '6': 'P002' }, cost_center: 'KS01' },
|
||||
{ '6': 'P001', '1': 'KS99' },
|
||||
)
|
||||
// Line's own bag beats the default for dim 6; the line's cost_center alias
|
||||
// beats the default for dim 1 (per-line explicit > voucher default).
|
||||
expect(merged).toEqual({ '1': 'KS01', '6': 'P002' })
|
||||
})
|
||||
|
||||
it('returns undefined when nothing is tagged', () => {
|
||||
expect(mergeLineDimensions({}, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parseDimensionsArg throws loudly on an invalid bag shape', () => {
|
||||
expect(() => parseDimensionsArg({ '0': 'X' }, 'default_dimensions')).toThrow(/Invalid default_dimensions/)
|
||||
expect(() => parseDimensionsArg({ '6': 'har"citat' }, 'lines[0].dimensions')).toThrow(/Invalid lines\[0\]\.dimensions/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Dims bag flowing through gnubok_create_voucher ───────────────────────────
|
||||
|
||||
describe('gnubok_create_voucher — dimensions bag', () => {
|
||||
it('resolves and stages per-line dims + default_dimensions onto the staged lines', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// fiscal period (explicit id path)
|
||||
enqueue({
|
||||
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
|
||||
error: null,
|
||||
})
|
||||
// chart_of_accounts
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '4010', account_name: 'Inköp material', is_active: true },
|
||||
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// resolvePeriodStatusForDate: 2 layers
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
// pending_operations insert
|
||||
enqueue({ data: { id: 'op-dims' }, error: null })
|
||||
|
||||
const result = (await createVoucher.execute(
|
||||
{
|
||||
entry_date: '2026-05-12',
|
||||
description: 'Material Villa Almgren',
|
||||
fiscal_period_id: 'fp-1',
|
||||
default_dimensions: { '6': 'villa almgren tak' },
|
||||
lines: [
|
||||
{ account_number: '4010', debit_amount: 250, credit_amount: 0, dimensions: { '1': 'KS01' } },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as {
|
||||
staged: boolean
|
||||
preview: {
|
||||
lines: Array<{ dimensions: Record<string, string> | null }>
|
||||
dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string; resolved_name: string; confidence: number }>
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
// Expense line: own dim 1 + the voucher default dim 6, resolved to codes.
|
||||
expect(result.preview.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
|
||||
// Bank line inherits the voucher default too (it lacks the key itself).
|
||||
expect(result.preview.lines[1].dimensions).toEqual({ '6': 'P001' })
|
||||
// Non-exact resolution is echoed with the full contract shape.
|
||||
expect(result.preview.dimension_resolutions).toHaveLength(1)
|
||||
expect(result.preview.dimension_resolutions![0]).toMatchObject({
|
||||
dimension: 6,
|
||||
input: 'villa almgren tak',
|
||||
resolved_code: 'P001',
|
||||
resolved_name: 'Villa Almgren takrenovering',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects before staging when a dims value has no registry match (no auto-create)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null })
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({
|
||||
data: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
createVoucher.execute(
|
||||
{
|
||||
entry_date: '2026-05-12',
|
||||
description: 'Unknown project',
|
||||
fiscal_period_id: 'fp-1',
|
||||
lines: [
|
||||
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions: { '6': 'Bryggeriet ombyggnad' } },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the untagged path query-free (legacy tests unchanged): no settings read without dims', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '1010', account_name: 'Balanserade utgifter', is_active: true },
|
||||
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // period status layer 1
|
||||
enqueue({ data: null, error: null }) // period status layer 2
|
||||
enqueue({ data: { id: 'op-plain' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await createVoucher.execute(
|
||||
{
|
||||
entry_date: '2026-05-12',
|
||||
description: 'Ingen dimension',
|
||||
fiscal_period_id: 'fp-1',
|
||||
lines: [
|
||||
{ account_number: '1010', debit_amount: 250, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.dimension_resolutions).toBeUndefined()
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -64,9 +64,16 @@ describe('tools/list payload size guard', () => {
|
||||
// plus per-line supplier-invoice overrides). Each new tool carries its
|
||||
// inputSchema + staging _meta; the growth is genuine wire data, not prose,
|
||||
// so descriptions are already at their trimmed floor (~180–220 chars).
|
||||
// * 40K → 42K with dimensions PR3: gnubok_list_dimensions +
|
||||
// gnubok_list_dimension_values (nested registry output schemas) + staged
|
||||
// gnubok_create_dimension_value (STAGED_OPERATION_SCHEMA + _meta), the
|
||||
// dims bag + default_dimensions on create_voucher/correct_entry, and the
|
||||
// agent-briefing dimensions block. Descriptions were trimmed first
|
||||
// (~200 tokens recovered); the remainder is schema structure agents
|
||||
// depend on for resolve-don't-select, not trimmable prose.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(40_000)
|
||||
expect(approxTokens).toBeLessThan(42_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Dimension helpers for the MCP server (PR3 of the dimensions plan —
|
||||
* dev_docs/dimensions_implementation_plan.md §6).
|
||||
*
|
||||
* Two responsibilities, shared by the dimension tools and the dims-bag write
|
||||
* tools (gnubok_create_voucher / gnubok_correct_entry):
|
||||
*
|
||||
* 1. Registry access — ensure_company_dimensions + the same two-query fetch
|
||||
* the dashboard GET /api/dimensions uses, returning the identical shape.
|
||||
*
|
||||
* 2. Resolve-don't-select — an incoming dimension value may be an object
|
||||
* code ("KS01") OR a natural-language name ("Villa Almgren tak"). The
|
||||
* server resolves exact code → exact name → fuse.js fuzzy over the
|
||||
* dimension's ACTIVE values. A single high-confidence hit resolves (and
|
||||
* is echoed back with its confidence); multiple/low-confidence candidates
|
||||
* reject with a ranked list so the agent retries with a code or stages
|
||||
* gnubok_create_dimension_value. NO auto-create, ever — agents must not
|
||||
* silently mint reporting values.
|
||||
*
|
||||
* Resolution honours the validation contract: company_settings.dimensions_enabled
|
||||
* is fetched ONCE; when false the bags pass through verbatim (free-text
|
||||
* backward compatibility — the engine skips validation too), when true every
|
||||
* value must land on an active registry value. Untagged entries cost zero
|
||||
* queries.
|
||||
*/
|
||||
import Fuse from 'fuse.js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
DimensionsBagSchema,
|
||||
normalizeLineDimensions,
|
||||
type LineDimensions,
|
||||
} from '@/lib/bookkeeping/dimension-resolver'
|
||||
|
||||
// ── Registry shapes (mirror GET /api/dimensions exactly) ─────────────────────
|
||||
|
||||
export interface DimensionValueEntry {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export interface DimensionRegistryEntry {
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
values: DimensionValueEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy get-or-create of the system dims (1 = Kostnadsställe, 6 = Projekt).
|
||||
* Idempotent; keeps core zero-config for companies that never touch dimensions.
|
||||
*/
|
||||
export async function ensureCompanyDimensions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.rpc('ensure_company_dimensions', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
if (error) throw new Error(`Failed to seed system dimensions: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full registry incl. values — the same two queries and the same
|
||||
* nested shape as the dashboard GET /api/dimensions, so agents and the
|
||||
* register UI see one consistent contract.
|
||||
*/
|
||||
export async function fetchDimensionRegistry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<DimensionRegistryEntry[]> {
|
||||
const { data: dims, error: dimsError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||||
.eq('company_id', companyId)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('sie_dim_no', { ascending: true })
|
||||
if (dimsError) throw new Error(`Failed to list dimensions: ${dimsError.message}`)
|
||||
|
||||
const { data: values, error: valuesError } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', companyId)
|
||||
.order('code', { ascending: true })
|
||||
if (valuesError) throw new Error(`Failed to list dimension values: ${valuesError.message}`)
|
||||
|
||||
const valuesByDimension = new Map<string, DimensionValueEntry[]>()
|
||||
for (const v of (values ?? []) as Array<DimensionValueEntry & { dimension_id: string }>) {
|
||||
const bucket = valuesByDimension.get(v.dimension_id) ?? []
|
||||
bucket.push({
|
||||
id: v.id,
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
is_active: v.is_active,
|
||||
start_date: v.start_date,
|
||||
end_date: v.end_date,
|
||||
})
|
||||
valuesByDimension.set(v.dimension_id, bucket)
|
||||
}
|
||||
|
||||
return ((dims ?? []) as Array<Omit<DimensionRegistryEntry, 'values'>>).map((d) => ({
|
||||
...d,
|
||||
values: valuesByDimension.get(d.id) ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Input parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* MCP boundary schema for a dimensions bag where values may be NAMES, not just
|
||||
* codes. Looser than DimensionsBagSchema on length (names go up to 120 chars,
|
||||
* dimension_values.name CHECK) but keeps the SIE-framing charset ban. After
|
||||
* resolution the final bags are re-validated against DimensionsBagSchema — the
|
||||
* exact schema the API layer and staged commit path use — so nothing loose is
|
||||
* ever staged.
|
||||
*/
|
||||
const DimensionsInputSchema = z.record(
|
||||
z.string().regex(/^[1-9]\d*$/, 'nyckeln måste vara ett SIE-dimensionsnummer (t.ex. "1" eller "6")'),
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.regex(/^[^"{}]+$/, 'värdet får inte innehålla ", { eller }'),
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse an untyped `dimensions` / `default_dimensions` tool argument. Throws a
|
||||
* loud, actionable error on invalid shape — the MCP boundary is an input gate,
|
||||
* unlike coerceDimensionsBag (which silently drops on the trusted staged path).
|
||||
*/
|
||||
export function parseDimensionsArg(raw: unknown, field: string): LineDimensions | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
const parsed = DimensionsInputSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0]
|
||||
throw new Error(
|
||||
`Invalid ${field}: ${issue?.message ?? 'ogiltig dimensionsmap'}. ` +
|
||||
'Expected {"<sie_dim_no>":"<kod eller namn>"}, e.g. {"1":"KS01","6":"P001"}.',
|
||||
)
|
||||
}
|
||||
const dims = normalizeLineDimensions({ dimensions: parsed.data })
|
||||
return Object.keys(dims).length > 0 ? dims : undefined
|
||||
}
|
||||
|
||||
// ── Resolve-don't-select ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fuse score cutoff for a single high-confidence fuzzy hit. Fuse scores run
|
||||
* 0 (perfect) → 1 (no relation); 0.3 matches the app-wide precedent for
|
||||
* registry lookups (components/import/OpeningBalanceRowEditor.tsx uses
|
||||
* threshold 0.3 for BAS account search). Anything above is "plausible but not
|
||||
* safe to attach" — plan risk #5 is a confident-but-wrong project tag.
|
||||
*/
|
||||
export const DIMENSION_FUZZY_SCORE_MAX = 0.3
|
||||
/**
|
||||
* Minimum score gap to the runner-up before a fuzzy hit counts as unambiguous.
|
||||
* Two near-tied candidates (e.g. "P001 Villa Alm" vs "P002 Villa Alm etapp 2")
|
||||
* must be disambiguated by the agent, not guessed at.
|
||||
*/
|
||||
export const DIMENSION_FUZZY_AMBIGUITY_MARGIN = 0.15
|
||||
|
||||
/** Echoed in tool responses for every non-exact resolution. */
|
||||
export interface DimensionResolution {
|
||||
dimension: number
|
||||
input: string
|
||||
resolved_code: string
|
||||
resolved_name: string
|
||||
/** 1 for exact code/name matches; 1 − fuse score (2 decimals) for fuzzy. */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** Typed rejection so tests/tools can distinguish resolution failures. */
|
||||
export class DimensionResolutionError extends Error {
|
||||
readonly dimension: number
|
||||
readonly input: string
|
||||
readonly candidates: Array<{ code: string; name: string; confidence: number }>
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
dimension: number,
|
||||
input: string,
|
||||
candidates: Array<{ code: string; name: string; confidence: number }> = [],
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'DimensionResolutionError'
|
||||
this.dimension = dimension
|
||||
this.input = input
|
||||
this.candidates = candidates
|
||||
}
|
||||
}
|
||||
|
||||
function dimensionLabel(dim: Pick<DimensionRegistryEntry, 'sie_dim_no' | 'name'>): string {
|
||||
if (dim.sie_dim_no === 1) return 'kostnadsställe'
|
||||
if (dim.sie_dim_no === 6) return 'projekt'
|
||||
return dim.name
|
||||
}
|
||||
|
||||
interface ResolvedValue {
|
||||
code: string
|
||||
name: string
|
||||
exact: boolean
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one input (code or name) inside one dimension. Pure — takes registry
|
||||
* data, so it is unit-testable without a DB. Throws DimensionResolutionError
|
||||
* on archived / unknown / ambiguous inputs; never auto-creates.
|
||||
*/
|
||||
export function resolveValueInDimension(
|
||||
dim: DimensionRegistryEntry,
|
||||
input: string,
|
||||
): ResolvedValue {
|
||||
const active = dim.values.filter((v) => v.is_active)
|
||||
const label = dimensionLabel(dim)
|
||||
|
||||
// 1. Exact code match among active values — the fast path, not echoed.
|
||||
const exactCode = active.find((v) => v.code === input)
|
||||
if (exactCode) return { code: exactCode.code, name: exactCode.name, exact: true, confidence: 1 }
|
||||
|
||||
// Archived guard: an exact code hit on an inactive value is a hard stop —
|
||||
// same rule the engine enforces at draft time.
|
||||
const archived = dim.values.find((v) => !v.is_active && v.code === input)
|
||||
if (archived) {
|
||||
throw new DimensionResolutionError(
|
||||
`"${input}" är arkiverat — återaktivera värdet för att använda det.`,
|
||||
dim.sie_dim_no,
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Exact (case-insensitive) code or exact name match — unambiguous but
|
||||
// echoed, since the stored code differs from the raw input.
|
||||
const lowered = input.trim().toLowerCase()
|
||||
const exactish = active.filter(
|
||||
(v) => v.code.toLowerCase() === lowered || v.name.trim().toLowerCase() === lowered,
|
||||
)
|
||||
if (exactish.length === 1) {
|
||||
return { code: exactish[0].code, name: exactish[0].name, exact: false, confidence: 1 }
|
||||
}
|
||||
if (exactish.length > 1) {
|
||||
throw new DimensionResolutionError(
|
||||
`"${input}" matchar flera värden i ${label} (dimension ${dim.sie_dim_no}): ` +
|
||||
exactish.map((v) => `"${v.code}" (${v.name})`).join(', ') +
|
||||
'. Ange en exakt kod i stället.',
|
||||
dim.sie_dim_no,
|
||||
input,
|
||||
exactish.map((v) => ({ code: v.code, name: v.name, confidence: 1 })),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Fuzzy over active values.
|
||||
const fuse = new Fuse(active, {
|
||||
keys: ['code', 'name'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
const hits = fuse.search(input)
|
||||
|
||||
if (hits.length === 0) {
|
||||
throw new DimensionResolutionError(
|
||||
`Okänt ${label}: "${input}" (dimension ${dim.sie_dim_no}). Skapa värdet i registret först — ` +
|
||||
'stage det med gnubok_create_dimension_value, eller ange en befintlig kod ' +
|
||||
'(gnubok_list_dimension_values).',
|
||||
dim.sie_dim_no,
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
const top = hits[0]
|
||||
const topScore = top.score ?? 1
|
||||
const runnerUpScore = hits[1]?.score ?? Number.POSITIVE_INFINITY
|
||||
const unambiguous =
|
||||
topScore <= DIMENSION_FUZZY_SCORE_MAX &&
|
||||
runnerUpScore - topScore >= DIMENSION_FUZZY_AMBIGUITY_MARGIN
|
||||
|
||||
if (unambiguous) {
|
||||
return {
|
||||
code: top.item.code,
|
||||
name: top.item.name,
|
||||
exact: false,
|
||||
confidence: roundOre(1 - topScore),
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = hits.slice(0, 5).map((h) => ({
|
||||
code: h.item.code,
|
||||
name: h.item.name,
|
||||
confidence: roundOre(1 - (h.score ?? 1)),
|
||||
}))
|
||||
throw new DimensionResolutionError(
|
||||
`Kunde inte entydigt matcha "${input}" mot ett ${label} (dimension ${dim.sie_dim_no}). Kandidater: ` +
|
||||
candidates.map((c) => `"${c.code}" (${c.name}, ${Math.round(c.confidence * 100)}%)`).join(', ') +
|
||||
'. Ange en exakt kod, eller skapa ett nytt värde med gnubok_create_dimension_value.',
|
||||
dim.sie_dim_no,
|
||||
input,
|
||||
candidates,
|
||||
)
|
||||
}
|
||||
|
||||
export interface ResolveBagsResult {
|
||||
/** Same length/positions as the input; values rewritten to registry codes. */
|
||||
bags: Array<LineDimensions | undefined>
|
||||
/** One entry per distinct non-exact (dimension, input) pair. */
|
||||
resolutions: DimensionResolution[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the dimension bags of a whole voucher in one pass.
|
||||
*
|
||||
* Query budget (validation contract): zero queries when no line carries a
|
||||
* bag; one company_settings read when dimensions are disabled (free-text
|
||||
* passthrough); ensure-RPC + two registry reads when enabled. Never per-line.
|
||||
*/
|
||||
export async function resolveDimensionBags(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
bags: Array<LineDimensions | undefined>,
|
||||
): Promise<ResolveBagsResult> {
|
||||
const hasAny = bags.some((b) => b && Object.keys(b).length > 0)
|
||||
if (!hasAny) return { bags, resolutions: [] }
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('dimensions_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const finalize = (resolved: Array<LineDimensions | undefined>): void => {
|
||||
// Whatever path produced the bags, the staged result must satisfy THE bag
|
||||
// schema (max 40-char codes, SIE-safe charset) — otherwise the commit-side
|
||||
// coerceDimensionsBag would silently drop the whole bag at booking time.
|
||||
for (const bag of resolved) {
|
||||
if (!bag) continue
|
||||
const check = DimensionsBagSchema.safeParse(bag)
|
||||
if (!check.success) {
|
||||
const issue = check.error.issues[0]
|
||||
throw new Error(
|
||||
`Ogiltig dimensionskod: ${issue?.message ?? 'ogiltigt värde'}. ` +
|
||||
'Koder får vara max 40 tecken — registrera värdet i registret och referera det via kod eller namn.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(settings as { dimensions_enabled?: boolean } | null)?.dimensions_enabled) {
|
||||
// Dimensions not enabled: free-text passthrough, exactly like the engine's
|
||||
// validation rule 2 — existing API/MCP writers keep working unchanged.
|
||||
finalize(bags)
|
||||
return { bags, resolutions: [] }
|
||||
}
|
||||
|
||||
await ensureCompanyDimensions(supabase, companyId)
|
||||
const registry = await fetchDimensionRegistry(supabase, companyId)
|
||||
const byDimNo = new Map(registry.map((d) => [d.sie_dim_no, d]))
|
||||
|
||||
const cache = new Map<string, ResolvedValue & { dimension: number; input: string }>()
|
||||
const resolveOne = (dimNo: string, input: string) => {
|
||||
const key = `${dimNo}\u0000${input}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) return cached
|
||||
const dim = byDimNo.get(Number(dimNo))
|
||||
if (!dim) {
|
||||
throw new DimensionResolutionError(
|
||||
`Okänd dimension ${dimNo}. Registrerade dimensioner: ` +
|
||||
(registry.map((d) => `${d.sie_dim_no} (${d.name})`).join(', ') || 'inga') +
|
||||
'. Anropa gnubok_list_dimensions.',
|
||||
Number(dimNo),
|
||||
input,
|
||||
)
|
||||
}
|
||||
const resolved = { ...resolveValueInDimension(dim, input), dimension: dim.sie_dim_no, input }
|
||||
cache.set(key, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const outBags = bags.map((bag) => {
|
||||
if (!bag || Object.keys(bag).length === 0) return bag
|
||||
const out: LineDimensions = {}
|
||||
for (const [dimNo, value] of Object.entries(bag)) {
|
||||
out[dimNo] = resolveOne(dimNo, value).code
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const resolutions: DimensionResolution[] = [...cache.values()]
|
||||
.filter((r) => !r.exact)
|
||||
.map((r) => ({
|
||||
dimension: r.dimension,
|
||||
input: r.input,
|
||||
resolved_code: r.code,
|
||||
resolved_name: r.name,
|
||||
confidence: r.confidence,
|
||||
}))
|
||||
|
||||
finalize(outBags)
|
||||
return { bags: outBags, resolutions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line effective bag for a voucher tool: the line's own normalized map
|
||||
* (explicit bag wins over the deprecated cost_center/project aliases) filled
|
||||
* with voucher-level defaults for keys the line does not set itself.
|
||||
*/
|
||||
export function mergeLineDimensions(
|
||||
line: { dimensions?: LineDimensions; cost_center?: string; project?: string },
|
||||
defaults: LineDimensions | undefined,
|
||||
): LineDimensions | undefined {
|
||||
const own = normalizeLineDimensions({
|
||||
dimensions: line.dimensions ?? null,
|
||||
cost_center: line.cost_center ?? null,
|
||||
project: line.project ?? null,
|
||||
})
|
||||
const merged = { ...(defaults ?? {}), ...own }
|
||||
return Object.keys(merged).length > 0 ? merged : undefined
|
||||
}
|
||||
@@ -36,6 +36,15 @@ import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX,
|
||||
import type { SkillTier } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import {
|
||||
ensureCompanyDimensions,
|
||||
fetchDimensionRegistry,
|
||||
parseDimensionsArg,
|
||||
mergeLineDimensions,
|
||||
resolveDimensionBags,
|
||||
} from './dimensions'
|
||||
import Fuse from 'fuse.js'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
checkIdempotencyKey,
|
||||
@@ -2110,7 +2119,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_agent_briefing',
|
||||
title: 'Get Agent Briefing',
|
||||
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
|
||||
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories, dimensions snapshot (when registered). Call once at session start.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -2177,6 +2186,41 @@ export const tools: McpTool[] = [
|
||||
required: ['id', 'kind', 'content'],
|
||||
},
|
||||
},
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: 'Dimension registry snapshot (kostnadsställe/projekt). OMITTED when the company has none registered; presence means lines can be tagged via the dims bag on gnubok_create_voucher.',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', description: 'When true, dims-bag values are validated against the registry.' },
|
||||
dimensions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sie_dim_no: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
active_value_count: { type: 'number' },
|
||||
top_values: {
|
||||
type: 'array',
|
||||
description: 'Up to 10 active values; full list via gnubok_list_dimension_values.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['code', 'name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['sie_dim_no', 'name', 'active_value_count', 'top_values'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['enabled', 'dimensions'],
|
||||
},
|
||||
},
|
||||
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'],
|
||||
},
|
||||
@@ -2187,7 +2231,25 @@ export const tools: McpTool[] = [
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const [profileRes, memoryRes, userRes, companyRes, settingsRes] = await Promise.all([
|
||||
// Dimension registry is best-effort and cheap: one indexed read, skipped
|
||||
// output when empty (most companies never register dimensions — lazy
|
||||
// seeding means zero rows until first use). Errors never block the
|
||||
// briefing.
|
||||
const safeDimensionsRead = (async () => {
|
||||
try {
|
||||
return await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('sie_dim_no', { ascending: true })
|
||||
} catch {
|
||||
return { data: null, error: new Error('dimensions read failed') }
|
||||
}
|
||||
})()
|
||||
|
||||
const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('agent_profiles')
|
||||
.select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms')
|
||||
@@ -2222,9 +2284,10 @@ export const tools: McpTool[] = [
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, dimensions_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
safeDimensionsRead,
|
||||
])
|
||||
|
||||
if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`)
|
||||
@@ -2260,13 +2323,67 @@ export const tools: McpTool[] = [
|
||||
const companyRow = companyRes.data as
|
||||
| { name: string | null; org_number: string | null; entity_type: string | null }
|
||||
| null
|
||||
const settingsRow = settingsRes.data as
|
||||
| { accounting_method: string | null; dimensions_enabled?: boolean | null }
|
||||
| null
|
||||
const company = {
|
||||
id: companyId,
|
||||
name: companyRow?.name ?? null,
|
||||
org_number: companyRow?.org_number ?? null,
|
||||
entity_type: companyRow?.entity_type ?? null,
|
||||
accounting_method:
|
||||
(settingsRes.data as { accounting_method: string | null } | null)?.accounting_method ?? null,
|
||||
accounting_method: settingsRow?.accounting_method ?? null,
|
||||
}
|
||||
|
||||
// Dimensions block — skipped entirely when the registry is empty so an
|
||||
// untagged company pays nothing (and the agent isn't told about a
|
||||
// feature with no data behind it).
|
||||
const dimensionRows = (dimensionsRes.error ? [] : dimensionsRes.data ?? []) as Array<{
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
}>
|
||||
let dimensionsBlock:
|
||||
| {
|
||||
enabled: boolean
|
||||
dimensions: Array<{
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
active_value_count: number
|
||||
top_values: Array<{ code: string; name: string }>
|
||||
}>
|
||||
}
|
||||
| undefined
|
||||
if (dimensionRows.length > 0) {
|
||||
try {
|
||||
const { data: valueRows, error: valueErr } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('dimension_id, code, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('code', { ascending: true })
|
||||
if (!valueErr) {
|
||||
const byDimension = new Map<string, Array<{ code: string; name: string }>>()
|
||||
for (const v of (valueRows ?? []) as Array<{ dimension_id: string; code: string; name: string }>) {
|
||||
const bucket = byDimension.get(v.dimension_id) ?? []
|
||||
bucket.push({ code: v.code, name: v.name })
|
||||
byDimension.set(v.dimension_id, bucket)
|
||||
}
|
||||
dimensionsBlock = {
|
||||
enabled: settingsRow?.dimensions_enabled === true,
|
||||
dimensions: dimensionRows.map((d) => {
|
||||
const values = byDimension.get(d.id) ?? []
|
||||
return {
|
||||
sie_dim_no: d.sie_dim_no,
|
||||
name: d.name,
|
||||
active_value_count: values.length,
|
||||
top_values: values.slice(0, 10),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — a values-read failure just omits the block.
|
||||
}
|
||||
}
|
||||
|
||||
const atomIds = [
|
||||
@@ -2309,6 +2426,7 @@ export const tools: McpTool[] = [
|
||||
content: m.content,
|
||||
relevance_score: m.relevance_score,
|
||||
})),
|
||||
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -4335,6 +4453,314 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── Dimensions (kostnadsställe/projekt) ──────────────────────
|
||||
|
||||
{
|
||||
name: 'gnubok_list_dimensions',
|
||||
title: 'List Dimensions (Kostnadsställe/Projekt)',
|
||||
description: 'List the dimension registry with values: 1 = kostnadsställe, 6 = projekt, plus custom dims. Call before tagging voucher lines via the dimensions bag on gnubok_create_voucher. System dims are seeded on first call.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimensions: {
|
||||
type: 'array',
|
||||
description: 'Registry entries keyed by sie_dim_no (the dims-bag key), each with its values. code = what goes in the bag; is_active false = archived (unusable on new lines).',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
sie_dim_no: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
resets_annually: { type: 'boolean' },
|
||||
is_system: { type: 'boolean' },
|
||||
is_active: { type: 'boolean' },
|
||||
sort_order: { type: 'number' },
|
||||
values: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
code: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
is_active: { type: 'boolean' },
|
||||
start_date: { type: ['string', 'null'] },
|
||||
end_date: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['id', 'code', 'name', 'is_active', 'start_date', 'end_date'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['id', 'sie_dim_no', 'name', 'resets_annually', 'is_system', 'is_active', 'sort_order', 'values'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['dimensions'],
|
||||
},
|
||||
annotations: {
|
||||
// The lazy ensure_company_dimensions seed is an idempotent get-or-create
|
||||
// of the two system registry rows — semantically a read (the dashboard
|
||||
// GET /api/dimensions does the same).
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, _userId, supabase) {
|
||||
await ensureCompanyDimensions(supabase, companyId)
|
||||
const dimensions = await fetchDimensionRegistry(supabase, companyId)
|
||||
return { dimensions }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_dimension_values',
|
||||
title: 'List Dimension Values',
|
||||
description: 'List values (SIE #OBJEKT codes) for one dimension, optionally fuzzy-matched by query. Use to find the right kostnadsställe/projekt code before tagging lines. sie_dim_no: 1 = kostnadsställe, 6 = projekt.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim from gnubok_list_dimensions.' },
|
||||
query: { type: 'string', description: 'Optional fuzzy search over code + name, ranked by confidence.' },
|
||||
include_inactive: { type: 'boolean', description: 'Include archived values (default false).' },
|
||||
limit: { type: 'number', description: 'Max results, 1–200 (default 50).' },
|
||||
},
|
||||
required: ['sie_dim_no'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimension: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
sie_dim_no: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
resets_annually: { type: 'boolean' },
|
||||
is_active: { type: 'boolean' },
|
||||
},
|
||||
required: ['id', 'sie_dim_no', 'name', 'resets_annually', 'is_active'],
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
code: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
is_active: { type: 'boolean' },
|
||||
start_date: { type: ['string', 'null'] },
|
||||
end_date: { type: ['string', 'null'] },
|
||||
confidence: { type: 'number', description: 'Fuzzy confidence 0–1; present only with query.' },
|
||||
},
|
||||
required: ['id', 'code', 'name', 'is_active', 'start_date', 'end_date'],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
},
|
||||
required: ['dimension', 'values', 'count'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const sieDimNo = Number(args.sie_dim_no)
|
||||
if (!Number.isInteger(sieDimNo) || sieDimNo < 1) {
|
||||
throw new Error('sie_dim_no must be a positive integer SIE dimension number (1 = kostnadsställe, 6 = projekt).')
|
||||
}
|
||||
const includeInactive = args.include_inactive === true
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 200)
|
||||
const query = typeof args.query === 'string' ? args.query.trim() : ''
|
||||
|
||||
await ensureCompanyDimensions(supabase, companyId)
|
||||
|
||||
const { data: dimension, error: dimError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sie_dim_no', sieDimNo)
|
||||
.maybeSingle()
|
||||
if (dimError) throw new Error(`Database error: ${dimError.message}`)
|
||||
if (!dimension) {
|
||||
throw new Error(
|
||||
`Dimension ${sieDimNo} finns inte i registret. Anropa gnubok_list_dimensions för att se registrerade dimensioner.`,
|
||||
)
|
||||
}
|
||||
|
||||
let valuesQuery = supabase
|
||||
.from('dimension_values')
|
||||
.select('id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', companyId)
|
||||
.eq('dimension_id', dimension.id)
|
||||
.order('code', { ascending: true })
|
||||
if (!includeInactive) valuesQuery = valuesQuery.eq('is_active', true)
|
||||
|
||||
const { data: rows, error: valuesError } = await valuesQuery
|
||||
if (valuesError) throw new Error(`Database error: ${valuesError.message}`)
|
||||
const all = (rows ?? []) as Array<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}>
|
||||
|
||||
if (!query) {
|
||||
const values = all.slice(0, limit)
|
||||
return { dimension, values, count: values.length }
|
||||
}
|
||||
|
||||
// Fuzzy ranking — same fuse.js setup as the resolve step so what this
|
||||
// tool shows matches what a dims bag would resolve to.
|
||||
const fuse = new Fuse(all, { keys: ['code', 'name'], includeScore: true, threshold: 0.4 })
|
||||
const values = fuse
|
||||
.search(query)
|
||||
.slice(0, limit)
|
||||
.map((hit) => ({
|
||||
...hit.item,
|
||||
confidence: roundOre(1 - (hit.score ?? 1)),
|
||||
}))
|
||||
return { dimension, values, count: values.length }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_dimension_value',
|
||||
title: 'Create Dimension Value',
|
||||
description: 'Stage a new dimension value (kostnadsställe/projekt object code, SIE #OBJEKT) for user approval — agents never silently mint reporting values. Use when a dims-bag value has no registry match. sie_dim_no: 1 = kostnadsställe, 6 = projekt.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim.' },
|
||||
code: {
|
||||
type: 'string',
|
||||
maxLength: 20,
|
||||
pattern: '^[A-Za-z0-9\\u00C5\\u00C4\\u00D6\\u00E5\\u00E4\\u00F6_+\\-]{1,20}$',
|
||||
description: 'Object code, strict Fortnox format: letters A–Ö, digits, _, + and -. Immutable after creation.',
|
||||
},
|
||||
name: { type: 'string', maxLength: 120, description: 'Human-readable name shown in registers and reports.' },
|
||||
start_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO start date; only on accumulating dims (projekt).' },
|
||||
end_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO end date ≥ start_date; only on accumulating dims.' },
|
||||
dry_run: {
|
||||
type: 'boolean',
|
||||
description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.',
|
||||
},
|
||||
idempotency_key: {
|
||||
type: 'string',
|
||||
description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.',
|
||||
},
|
||||
},
|
||||
required: ['sie_dim_no', 'code', 'name'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
// Strip MCP control fields, then re-validate with the same Zod schema
|
||||
// the commit executor uses (defense in depth, mirrors create_supplier).
|
||||
const { dry_run, idempotency_key, ...valueArgs } = args
|
||||
let params
|
||||
try {
|
||||
params = CreateDimensionValueParamsSchema.parse(valueArgs)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const issue = err.issues[0]
|
||||
const path = issue?.path?.join('.') ?? 'params'
|
||||
throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await ensureCompanyDimensions(supabase, companyId)
|
||||
|
||||
// Pre-flight for a tight agent feedback loop; the executor re-checks all
|
||||
// of this at commit time (the staged row is never trusted).
|
||||
const { data: dimension, error: dimError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sie_dim_no', params.sie_dim_no)
|
||||
.maybeSingle()
|
||||
if (dimError) throw new Error(`Database error: ${dimError.message}`)
|
||||
if (!dimension) {
|
||||
throw new Error(
|
||||
`Okänd dimension ${params.sie_dim_no}. Endast registrerade dimensioner kan få nya värden — ` +
|
||||
'anropa gnubok_list_dimensions (1 = kostnadsställe och 6 = projekt skapas automatiskt).',
|
||||
)
|
||||
}
|
||||
if (dimension.resets_annually && (params.start_date || params.end_date)) {
|
||||
throw new Error(
|
||||
`Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
|
||||
)
|
||||
}
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('id, code, name, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('dimension_id', dimension.id)
|
||||
.eq('code', params.code)
|
||||
.maybeSingle()
|
||||
if (existingError) throw new Error(`Database error: ${existingError.message}`)
|
||||
if (existing?.is_active) {
|
||||
throw new Error(
|
||||
`Värdet "${params.code}" (${existing.name}) finns redan i ${dimension.name} — använd koden direkt i dimensions-baggen.`,
|
||||
)
|
||||
}
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`"${params.code}" är arkiverat — återaktivera värdet i registret för att använda det.`,
|
||||
)
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_dimension_value',
|
||||
`Nytt värde i ${dimension.name}: ${params.code} — ${params.name}`,
|
||||
params,
|
||||
{
|
||||
sie_dim_no: dimension.sie_dim_no,
|
||||
dimension_name: dimension.name,
|
||||
code: params.code,
|
||||
name: params.name,
|
||||
start_date: params.start_date ?? null,
|
||||
end_date: params.end_date ?? null,
|
||||
will: 'create the value in the dimension registry so lines can be tagged with it',
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, tag voucher lines with the new code via the dimensions bag on gnubok_create_voucher, or verify it with gnubok_list_dimension_values.',
|
||||
tool: 'gnubok_list_dimension_values',
|
||||
args: { sie_dim_no: dimension.sie_dim_no },
|
||||
},
|
||||
{
|
||||
dryRun: Boolean(dry_run),
|
||||
idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
@@ -8808,6 +9234,11 @@ export const tools: McpTool[] = [
|
||||
fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' },
|
||||
voucher_series: { type: 'string', description: 'Single letter A–Z. Defaults to A.' },
|
||||
notes: { type: 'string', description: 'Internal notes (max 2000 chars) — visible on the verifikation but not on reports.' },
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every line not setting the key itself. Unknown values are rejected — never auto-created.',
|
||||
},
|
||||
is_opening_balance: { type: 'boolean', description: 'Set true ONLY for a migrated ingående balans (IB). Marks the entry source_type=opening_balance so bank reconciliation excludes it from period movement. Requires every line to be a balance-sheet account (class 1/2) and entry_date = fiscal period start, else rejected. Defaults false.' },
|
||||
inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' },
|
||||
lines: {
|
||||
@@ -8824,8 +9255,13 @@ export const tools: McpTool[] = [
|
||||
amount_in_currency: { type: 'number', description: 'Original amount if currency is not SEK' },
|
||||
exchange_rate: { type: 'number' },
|
||||
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta mapping. The BAS account number is what determines which ruta the line lands in (e.g. 2641 → ruta 48, 2614 → ruta 30). Pick the correct account first.' },
|
||||
cost_center: { type: 'string' },
|
||||
project: { type: 'string' },
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.',
|
||||
},
|
||||
cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' },
|
||||
project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' },
|
||||
},
|
||||
required: ['account_number'],
|
||||
},
|
||||
@@ -8848,7 +9284,7 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
|
||||
// Normalize so validateBalance + preview see consistent numeric types.
|
||||
const lines = rawLines.map((l) => ({
|
||||
const lines = rawLines.map((l, i) => ({
|
||||
account_number: String(l.account_number ?? ''),
|
||||
debit_amount: Number(l.debit_amount) || 0,
|
||||
credit_amount: Number(l.credit_amount) || 0,
|
||||
@@ -8857,6 +9293,7 @@ export const tools: McpTool[] = [
|
||||
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
|
||||
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
|
||||
tax_code: l.tax_code ? String(l.tax_code) : undefined,
|
||||
dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`),
|
||||
cost_center: l.cost_center ? String(l.cost_center) : undefined,
|
||||
project: l.project ? String(l.project) : undefined,
|
||||
}))
|
||||
@@ -8871,6 +9308,22 @@ export const tools: McpTool[] = [
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve-don't-select: merge voucher-level default_dimensions under each
|
||||
// line's own bag/aliases, then resolve codes AND natural-language names
|
||||
// against the registry in ONE pass (zero queries when nothing is tagged;
|
||||
// free-text passthrough while dimensions_enabled is off). Non-exact
|
||||
// resolutions are echoed in the preview so the approver and the agent
|
||||
// both see what "Villa Almgren tak" actually attached to.
|
||||
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
|
||||
const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
lines.map((l) => mergeLineDimensions(l, defaultDimensions)),
|
||||
)
|
||||
for (const [i, line] of lines.entries()) {
|
||||
line.dimensions = resolvedBags[i]
|
||||
}
|
||||
|
||||
// Resolve fiscal period. Two paths:
|
||||
// 1. Caller supplied fiscal_period_id → verify it exists and is open.
|
||||
// 2. Omitted → look up the open period covering entry_date.
|
||||
@@ -8953,6 +9406,7 @@ export const tools: McpTool[] = [
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
line_description: l.line_description ?? null,
|
||||
dimensions: l.dimensions ?? null,
|
||||
}))
|
||||
|
||||
// Optional inbox-direct booking. Validate at staging so the agent gets a
|
||||
@@ -9018,6 +9472,9 @@ export const tools: McpTool[] = [
|
||||
total_credit: balance.totalCredit,
|
||||
line_count: lines.length,
|
||||
lines: previewLines,
|
||||
// Echoed for every non-exact dimension resolution (resolve-don't-
|
||||
// select) so the agent can verify what a name attached to.
|
||||
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
|
||||
inbox_item_id: inboxItemId,
|
||||
document_attached: Boolean(inboxDocumentId),
|
||||
will: inboxItemId
|
||||
@@ -9043,6 +9500,11 @@ export const tools: McpTool[] = [
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every replacement line not setting the key itself. Unknown values are rejected — never auto-created.',
|
||||
},
|
||||
lines: {
|
||||
type: 'array',
|
||||
description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.',
|
||||
@@ -9057,8 +9519,13 @@ export const tools: McpTool[] = [
|
||||
amount_in_currency: { type: 'number' },
|
||||
exchange_rate: { type: 'number' },
|
||||
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta. Pick the correct BAS account first.' },
|
||||
cost_center: { type: 'string' },
|
||||
project: { type: 'string' },
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dimension tags {sie_dim_no: kod eller namn}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.',
|
||||
},
|
||||
cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' },
|
||||
project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' },
|
||||
},
|
||||
required: ['account_number'],
|
||||
},
|
||||
@@ -9076,7 +9543,7 @@ export const tools: McpTool[] = [
|
||||
throw new Error('entry_id and at least two lines are required')
|
||||
}
|
||||
|
||||
const lines = rawLines.map((l) => ({
|
||||
const lines = rawLines.map((l, i) => ({
|
||||
account_number: String(l.account_number ?? ''),
|
||||
debit_amount: Number(l.debit_amount) || 0,
|
||||
credit_amount: Number(l.credit_amount) || 0,
|
||||
@@ -9085,6 +9552,7 @@ export const tools: McpTool[] = [
|
||||
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
|
||||
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
|
||||
tax_code: l.tax_code ? String(l.tax_code) : undefined,
|
||||
dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`),
|
||||
cost_center: l.cost_center ? String(l.cost_center) : undefined,
|
||||
project: l.project ? String(l.project) : undefined,
|
||||
}))
|
||||
@@ -9097,6 +9565,19 @@ export const tools: McpTool[] = [
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve-don't-select — same one-pass registry resolution as
|
||||
// gnubok_create_voucher (codes AND names; unknown/archived/ambiguous
|
||||
// values reject with candidates; nothing is ever auto-created).
|
||||
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
|
||||
const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
lines.map((l) => mergeLineDimensions(l, defaultDimensions)),
|
||||
)
|
||||
for (const [i, line] of lines.entries()) {
|
||||
line.dimensions = resolvedBags[i]
|
||||
}
|
||||
|
||||
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
|
||||
|
||||
// Pre-flight: the executor checks again, but failing fast here gives the
|
||||
@@ -9182,8 +9663,10 @@ export const tools: McpTool[] = [
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
line_description: l.line_description ?? null,
|
||||
dimensions: l.dimensions ?? null,
|
||||
})),
|
||||
},
|
||||
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
|
||||
will: 'post a storno that mirrors the original, then post a new corrected entry, then mark the original as reversed (BFL 5 kap 5§)',
|
||||
},
|
||||
actor,
|
||||
|
||||
Reference in New Issue
Block a user