feat(dimensions): PR4 reports — dimension-filtered P&L + Resultat per projekt/kostnadsställe (#862)
* feat(dimensions): PR4 reports — dimension-filtered P&L everywhere + Resultat per projekt/kostnadsställe The Project P&L milestone of the dimensions plan (dev_docs §7 PR4). One choke point lights up everything: generateTrialBalance gains options.dimensions (SIE dim → code map) pushed down as jsonb containment (dimensions @>, served by idx_jel_dimensions_gin) on both line queries, with company-wide opening balances dropped when filtered (they cannot be dimension-scoped; P&L-safe by whitelist). Resultatrapport, resultaträkning, huvudbok, monthly-breakdown and the TB drill-down inherit the filter; the KPI route filters only its P&L-side inputs (income statement, months, expense composition) — never cash/VAT. New report lib/reports/dimension-pnl.ts — "Resultat per projekt/ kostnadsställe" (Fortnox Resultatrapport projekt): value-as-column matrix over one dimension with an explicit "(Utan dimension)" bucket computed as the residual against the same trial-balance pass resultatrapport uses, so every row and the Totalt column reconcile with the unfiltered resultatrapport by construction. Registered in REPORT_CATALOG (visible only when dimensions_enabled), slug-routed view + xlsx export. UI: DimensionFilter (dimension + value picker, persistent "Filtrerad — ej fullständig rapport" chip) mounts in FocusedReport for catalog entries flagged dimensions: true; huvudbok rows show line dim codes. Statutory exclusion pinned by TEST, not convention: lib/reports/__tests__/dimension-statutory-guard.test.ts fails if the filter parser leaks into balance sheet, balansrapport, kassaflöde, VAT, SIE or full-archive routes/generators, or if the catalog whitelist widens. MCP: new gnubok_get_dimension_pnl (reports:read); dimensions filter arg on get_trial_balance/get_income_statement/get_general_ledger with resolve-don't-select (names → registry codes, resolution echoes); query_journal totals fixed to aggregate the FULL match set (was silently slice-scoped while claiming otherwise) with an honest totals_scope field, plus group_by / group_by_dimension aggregation. Also: voucher-detail dim-6 badge now uses the registry name instead of the non-standard "PR" abbreviation (#859 review follow-up). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dimensions): address #862 review — export disclosure, prior-column suppression, period-label honesty, route hardening - Filtered XLSX/PDF exports now carry the partial-view disclosure past the file boundary (BFNAR 2013:2): filename suffix (-dim6-p001), a "Filtrerad … — ej fullständig rapport" row on every sheet, and a header note/title line in the PDFs. - Resultatrapport drops the prior-year column when a dimension filter is active — project codes are time-limited under K2/K3, so "this code last year" may be a different project (same rule as narrowed date ranges). - dimension-pnl no longer accepts fromDate: the matrix is cumulative from period_start by design (closing-balance semantics), and the period label now states exactly that instead of echoing a lower bound that was never applied. Routes/MCP tool updated to toDate-only. - dimension-pnl routes 404 on an unknown/foreign period id and cap dim_no to 4 digits (matching the MCP tool's PostgREST-path guard, which the generator now also enforces itself). - Statutory-guard test's generateTrialBalance call-site scan is paren-aware instead of a 300-char window; added fully-untagged and injection-guard test cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
86071334cb
commit
01dbef4015
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Dimensions PR4 — gnubok_get_dimension_pnl MCP surface tests.
|
||||
*
|
||||
* Covers registration (schema conventions + scope map) and the execute path:
|
||||
* default-period lookup, explicit period + date-window passthrough, and the
|
||||
* sie_dim_no guard. The matrix math itself is covered by the generator's own
|
||||
* tests in lib/reports/__tests__/ — here generateDimensionPnl is mocked.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
|
||||
import { tools } from '../server'
|
||||
|
||||
vi.mock('@/lib/reports/dimension-pnl', () => ({
|
||||
generateDimensionPnl: vi.fn(),
|
||||
}))
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_dimension_pnl')!
|
||||
const mockGenerate = vi.mocked(generateDimensionPnl)
|
||||
|
||||
function makeReport() {
|
||||
return {
|
||||
dimension: { sie_dim_no: '6', name: 'Projekt' },
|
||||
columns: [
|
||||
{ code: 'P001', name: 'Villa Almgren takrenovering' },
|
||||
{ code: null, name: null },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
class: 3,
|
||||
class_label: '3 Rörelsens inkomster/intäkter',
|
||||
rows: [
|
||||
{ account_number: '3010', account_name: 'Försäljning', values: [1000, 250], total: 1250 },
|
||||
],
|
||||
subtotals: [1000, 250],
|
||||
subtotal_total: 1250,
|
||||
},
|
||||
],
|
||||
net_per_column: [1000, 250],
|
||||
net_total: 1250,
|
||||
period: { start: '2026-01-01', end: '2026-12-31' },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_get_dimension_pnl — registration', () => {
|
||||
it('is registered, read-only, and mapped to reports:read (unmapped = any key)', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool.annotations).toEqual({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
})
|
||||
expect(TOOL_SCOPE_MAP.gnubok_get_dimension_pnl).toBe('reports:read')
|
||||
})
|
||||
|
||||
it('declares a strict inputSchema and a tight description', () => {
|
||||
const input = tool.inputSchema as { additionalProperties?: boolean; required?: string[] }
|
||||
expect(input.additionalProperties).toBe(false)
|
||||
expect(input.required).toEqual(['sie_dim_no'])
|
||||
expect(tool.description.length).toBeLessThanOrEqual(280)
|
||||
})
|
||||
|
||||
it('mirrors the DimensionPnlReport contract in its outputSchema', () => {
|
||||
const output = tool.outputSchema as { required?: string[]; properties?: Record<string, unknown> }
|
||||
expect(output.required).toEqual(['dimension', 'columns', 'groups', 'net_per_column', 'net_total'])
|
||||
expect(output.properties?.period).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Execute ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_get_dimension_pnl — execute', () => {
|
||||
it('passes an explicit period + date window straight to the generator (no period lookup)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const report = makeReport()
|
||||
mockGenerate.mockResolvedValueOnce(report as never)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ sie_dim_no: '6', period_id: 'fp-1', to_date: '2026-03-31' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', '6', {
|
||||
toDate: '2026-03-31',
|
||||
})
|
||||
expect(result).toEqual(report)
|
||||
// Explicit period_id → no fiscal_periods default lookup.
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to the most recent fiscal period when period_id is omitted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'fp-latest', name: '2026' }, error: null }) // fiscal_periods lookup
|
||||
mockGenerate.mockResolvedValueOnce(makeReport() as never)
|
||||
|
||||
await tool.execute({ sie_dim_no: '1' }, 'company-1', 'user-1', supabase as never)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('fiscal_periods')
|
||||
expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-latest', '1', {
|
||||
toDate: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('errors when no fiscal period exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
await expect(
|
||||
tool.execute({ sie_dim_no: '6' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/No fiscal periods found/)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-numeric sie_dim_no before touching the database', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
for (const bad of ['projekt', '', '0', '6; drop']) {
|
||||
await expect(
|
||||
tool.execute({ sie_dim_no: bad }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/positive SIE dimension number/)
|
||||
}
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a numeric sie_dim_no by coercing it to string (lenient hosts)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
mockGenerate.mockResolvedValueOnce(makeReport() as never)
|
||||
|
||||
await tool.execute({ sie_dim_no: 6, period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never)
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', '6', {
|
||||
toDate: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -71,9 +71,16 @@ describe('tools/list payload size guard', () => {
|
||||
// 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.
|
||||
// * 42K → 43K with dimensions PR4 reports: gnubok_get_dimension_pnl (the
|
||||
// value-as-column matrix outputSchema is the wire contract agents read
|
||||
// the report through), the shared `dimensions` filter arg + echo props
|
||||
// on trial balance / income statement / general ledger, and
|
||||
// group_by/group_by_dimension + totals_scope + groups on
|
||||
// gnubok_query_journal. Descriptions trimmed first (~100 tokens
|
||||
// recovered); the ~55-token remainder is schema structure.
|
||||
// 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(42_000)
|
||||
expect(approxTokens).toBeLessThan(43_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Unit tests for gnubok_query_journal.
|
||||
*
|
||||
* Verifies tool registration and the post-fetch amount filter + totals
|
||||
* computation. The supabase query-builder chain is exercised by the live
|
||||
* MCP smoke test; here we just check the result-shape pipeline.
|
||||
* Verifies tool registration, the post-fetch amount filter, the full-match
|
||||
* aggregate pass (totals/groups over ALL matching lines via fetchAllRows,
|
||||
* totals_scope='full_match'), and the slice-scoped free-text path
|
||||
* (totals_scope='returned_slice'). The supabase query-builder chain is
|
||||
* exercised by the live MCP smoke test; here we check the result-shape
|
||||
* pipeline.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
@@ -19,10 +22,12 @@ describe('gnubok_query_journal — registration', () => {
|
||||
|
||||
it('declares the expected output fields', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const schema = tool.outputSchema as { required?: string[] }
|
||||
const schema = tool.outputSchema as { required?: string[]; properties?: Record<string, unknown> }
|
||||
expect(schema.required).toContain('lines')
|
||||
expect(schema.required).toContain('totals')
|
||||
expect(schema.required).toContain('total_lines')
|
||||
expect(schema.required).toContain('totals_scope')
|
||||
expect(schema.properties?.groups).toBeDefined()
|
||||
})
|
||||
|
||||
it('is mapped to reports:read scope', () => {
|
||||
@@ -33,7 +38,9 @@ describe('gnubok_query_journal — registration', () => {
|
||||
/**
|
||||
* Build a minimal supabase mock that returns a fixed line set when the chain
|
||||
* is awaited. Uses a chainable proxy whose every method returns itself, with
|
||||
* the terminal awaitable resolving to { data, error, count }.
|
||||
* the terminal awaitable resolving to { data, error, count }. Every .from()
|
||||
* call sees the SAME rows, so on the non-text path both the display query and
|
||||
* the fetchAllRows full-match aggregate pass read one identical match set.
|
||||
*/
|
||||
function makeChainMock(lines: unknown[], count: number) {
|
||||
const result = { data: lines, error: null, count }
|
||||
@@ -183,9 +190,11 @@ describe('gnubok_query_journal — execute', () => {
|
||||
)) as {
|
||||
lines: { line_id: string }[]
|
||||
totals: { debit: number; credit: number; net: number }
|
||||
totals_scope: string
|
||||
truncated: boolean
|
||||
total_lines: number
|
||||
returned_lines: number
|
||||
db_matched_pre_amount_filter: number | null
|
||||
}
|
||||
|
||||
// amount_min: 1000 should filter out the 50-line
|
||||
@@ -194,6 +203,10 @@ describe('gnubok_query_journal — execute', () => {
|
||||
expect(result.totals.debit).toBe(5000)
|
||||
expect(result.totals.credit).toBe(0)
|
||||
expect(result.totals.net).toBe(5000)
|
||||
// Non-text path: totals come from the full-match aggregate pass.
|
||||
expect(result.totals_scope).toBe('full_match')
|
||||
expect(result.total_lines).toBe(1)
|
||||
expect(result.db_matched_pre_amount_filter).toBe(2)
|
||||
})
|
||||
|
||||
it('caps accounts list at 50', async () => {
|
||||
@@ -206,33 +219,125 @@ describe('gnubok_query_journal — execute', () => {
|
||||
).rejects.toThrow(/capped at 50/)
|
||||
})
|
||||
|
||||
it('marks truncated=true when count exceeds returned', async () => {
|
||||
it('marks truncated=true and computes totals over the FULL match set when the slice is capped', async () => {
|
||||
// Regression for the slice-totals bug: the display query is capped at
|
||||
// `limit`, but totals/total_lines must come from the fetchAllRows
|
||||
// aggregate pass over ALL matching lines (totals_scope='full_match').
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const lines = [
|
||||
{
|
||||
id: 'l1', account_number: '1930',
|
||||
debit_amount: 100, credit_amount: 0,
|
||||
currency: 'SEK', line_description: null, project: null, cost_center: null, sort_order: 0,
|
||||
journal_entries: {
|
||||
id: 'e1', voucher_number: 1, voucher_series: 'A',
|
||||
entry_date: '2026-01-01', description: 'Inbetalning',
|
||||
source_type: 'bank_transaction', status: 'posted',
|
||||
},
|
||||
},
|
||||
const displaySlice = [makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 })]
|
||||
const fullMatchSet = [
|
||||
makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 }),
|
||||
makeLineRow({ id: 'l2', account_number: '1930', debit_amount: 200 }),
|
||||
makeLineRow({ id: 'l3', account_number: '1930', debit_amount: 300 }),
|
||||
]
|
||||
// count=999 simulates "many more matched than were returned"
|
||||
const supabase = makeChainMock(lines, 999)
|
||||
|
||||
// .from() call order: display query first, then the aggregate pass
|
||||
// (single page — 3 rows < PAGE_SIZE ends the fetchAllRows loop).
|
||||
const { supabase, callCount } = makeQueueMock([
|
||||
{ data: displaySlice, count: 1 },
|
||||
{ data: fullMatchSet, count: 3 },
|
||||
])
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ accounts: ['1930'], limit: 1 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as { truncated: boolean; total_lines: number; returned_lines: number }
|
||||
)) as {
|
||||
truncated: boolean
|
||||
total_lines: number
|
||||
returned_lines: number
|
||||
totals: { debit: number; credit: number; net: number }
|
||||
totals_scope: string
|
||||
}
|
||||
|
||||
expect(callCount()).toBe(2)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.total_lines).toBe(999)
|
||||
expect(result.total_lines).toBe(3)
|
||||
expect(result.returned_lines).toBe(1)
|
||||
expect(result.totals).toEqual({ debit: 600, credit: 0, net: 600 })
|
||||
expect(result.totals_scope).toBe('full_match')
|
||||
})
|
||||
|
||||
it('rejects group_by + group_by_dimension together', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const supabase = makeChainMock([], 0)
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
{ group_by: 'account_number', group_by_dimension: '6' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
),
|
||||
).rejects.toThrow(/either group_by or group_by_dimension/)
|
||||
})
|
||||
|
||||
it('group_by buckets the full match set and sorts by |net| descending', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const rows = [
|
||||
makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }),
|
||||
makeLineRow({ id: 'l2', account_number: '4010', debit_amount: 50 }),
|
||||
makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }),
|
||||
]
|
||||
// makeChainMock feeds the SAME rows to display + aggregate passes.
|
||||
const supabase = makeChainMock(rows, 3)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ group_by: 'account_number', limit: 100 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as {
|
||||
groups: Array<{ key: string; debit: number; credit: number; net: number; line_count: number }>
|
||||
totals_scope: string
|
||||
applied_filters: { group_by: string | null; group_by_dimension: string | null }
|
||||
}
|
||||
|
||||
expect(result.totals_scope).toBe('full_match')
|
||||
expect(result.groups).toEqual([
|
||||
{ key: '4010', debit: 150, credit: 0, net: 150, line_count: 2 },
|
||||
{ key: '5010', debit: 0, credit: 30, net: -30, line_count: 1 },
|
||||
])
|
||||
expect(result.applied_filters.group_by).toBe('account_number')
|
||||
expect(result.applied_filters.group_by_dimension).toBeNull()
|
||||
})
|
||||
|
||||
it('group_by_dimension buckets by the dimensions jsonb with an untagged fallback', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const rows = [
|
||||
{ ...makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }), dimensions: { '6': 'P001' } },
|
||||
{ ...makeLineRow({ id: 'l2', account_number: '4011', debit_amount: 50 }), dimensions: { '6': 'P001', '1': 'KS01' } },
|
||||
{ ...makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }), dimensions: null },
|
||||
]
|
||||
const supabase = makeChainMock(rows, 3)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ group_by_dimension: '6', limit: 100 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as {
|
||||
groups: Array<{ key: string; debit: number; credit: number; net: number; line_count: number }>
|
||||
totals_scope: string
|
||||
applied_filters: { group_by: string | null; group_by_dimension: string | null }
|
||||
}
|
||||
|
||||
expect(result.totals_scope).toBe('full_match')
|
||||
expect(result.groups).toEqual([
|
||||
{ key: 'P001', debit: 150, credit: 0, net: 150, line_count: 2 },
|
||||
{ key: '(utan dimension)', debit: 0, credit: 30, net: -30, line_count: 1 },
|
||||
])
|
||||
expect(result.applied_filters.group_by_dimension).toBe('6')
|
||||
})
|
||||
|
||||
it('rejects a non-numeric group_by_dimension', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const supabase = makeChainMock([], 0)
|
||||
|
||||
await expect(
|
||||
tool.execute({ group_by_dimension: 'projekt' }, 'company-1', 'user-1', supabase),
|
||||
).rejects.toThrow(/positive SIE dimension number/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -264,12 +369,14 @@ describe('gnubok_query_journal — free-text search', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as { lines: Array<{ line_id: string }>; returned_lines: number }
|
||||
)) as { lines: Array<{ line_id: string }>; returned_lines: number; totals_scope: string }
|
||||
|
||||
expect(callCount()).toBe(2)
|
||||
expect(result.returned_lines).toBe(2)
|
||||
const ids = result.lines.map((l) => l.line_id).sort()
|
||||
expect(ids).toEqual(['L1', 'L2'])
|
||||
// Free-text path never runs the full aggregate pass — the output says so.
|
||||
expect(result.totals_scope).toBe('returned_slice')
|
||||
})
|
||||
|
||||
it('deduplicates rows returned by both query legs', async () => {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Dimensions PR4 — the shared `dimensions` filter arg on the report tools
|
||||
* (gnubok_get_trial_balance / gnubok_get_income_statement /
|
||||
* gnubok_get_general_ledger).
|
||||
*
|
||||
* The report generators are mocked; what is under test is the MCP layer:
|
||||
* resolve-don't-select (names → registry codes via the real
|
||||
* resolveDimensionBags against a queued supabase mock), the options handoff
|
||||
* to each generator, and the dimension_filter / dimension_resolutions echoes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { tools } from '../server'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({ generateTrialBalance: vi.fn() }))
|
||||
vi.mock('@/lib/reports/income-statement', () => ({ generateIncomeStatement: vi.fn() }))
|
||||
vi.mock('@/lib/reports/general-ledger', () => ({ generateGeneralLedger: vi.fn() }))
|
||||
|
||||
const trialBalance = tools.find((t) => t.name === 'gnubok_get_trial_balance')!
|
||||
const incomeStatement = tools.find((t) => t.name === 'gnubok_get_income_statement')!
|
||||
const generalLedger = tools.find((t) => t.name === 'gnubok_get_general_ledger')!
|
||||
|
||||
const mockTrialBalance = vi.mocked(generateTrialBalance)
|
||||
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
|
||||
const mockGeneralLedger = vi.mocked(generateGeneralLedger)
|
||||
|
||||
const PERIOD_ROW = {
|
||||
id: 'fp-1',
|
||||
name: '2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
}
|
||||
|
||||
/** Registry fixtures matching dimension-tools.test.ts conventions. */
|
||||
function enqueueRegistry(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings
|
||||
enqueue({ data: null, error: null }) // ensure_company_dimensions 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: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('report tools declare the dimensions filter arg', () => {
|
||||
it('is present with string-map shape on all three tools', () => {
|
||||
for (const tool of [trialBalance, incomeStatement, generalLedger]) {
|
||||
const props = (tool.inputSchema as { properties: Record<string, { type?: string; additionalProperties?: unknown }> }).properties
|
||||
expect(props.dimensions, tool.name).toBeDefined()
|
||||
expect(props.dimensions.type, tool.name).toBe('object')
|
||||
expect(props.dimensions.additionalProperties, tool.name).toEqual({ type: 'string' })
|
||||
}
|
||||
})
|
||||
|
||||
it('echo fields are declared but never required', () => {
|
||||
for (const tool of [trialBalance, incomeStatement, generalLedger]) {
|
||||
const schema = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
|
||||
expect(schema.properties?.dimension_filter, tool.name).toBeDefined()
|
||||
expect(schema.properties?.dimension_resolutions, tool.name).toBeDefined()
|
||||
expect(schema.required ?? [], tool.name).not.toContain('dimension_filter')
|
||||
expect(schema.required ?? [], tool.name).not.toContain('dimension_resolutions')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_trial_balance — dimensions filter', () => {
|
||||
it('resolves a value NAME to its registry code, filters, and echoes both', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD_ROW, error: null }) // period info
|
||||
enqueueRegistry(enqueue)
|
||||
mockTrialBalance.mockResolvedValueOnce({ rows: [], totalDebit: 0, totalCredit: 0 } as never)
|
||||
|
||||
const result = (await trialBalance.execute(
|
||||
{ period_id: 'fp-1', dimensions: { '6': 'villa almgren tak' } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as {
|
||||
dimension_filter?: Record<string, string>
|
||||
dimension_resolutions?: Array<{ resolved_code: string; input: string }>
|
||||
}
|
||||
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(result.dimension_filter).toEqual({ '6': 'P001' })
|
||||
expect(result.dimension_resolutions).toHaveLength(1)
|
||||
expect(result.dimension_resolutions![0]).toMatchObject({
|
||||
input: 'villa almgren tak',
|
||||
resolved_code: 'P001',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes undefined options and omits echoes when no filter is given', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD_ROW, error: null })
|
||||
mockTrialBalance.mockResolvedValueOnce({ rows: [], totalDebit: 0, totalCredit: 0 } as never)
|
||||
|
||||
const result = (await trialBalance.execute(
|
||||
{ period_id: 'fp-1' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', undefined)
|
||||
expect(result).not.toHaveProperty('dimension_filter')
|
||||
expect(result).not.toHaveProperty('dimension_resolutions')
|
||||
// Zero registry queries when nothing is tagged.
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets DimensionResolutionError propagate with the create-first hint', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD_ROW, error: null })
|
||||
enqueueRegistry(enqueue)
|
||||
|
||||
await expect(
|
||||
trialBalance.execute(
|
||||
{ period_id: 'fp-1', dimensions: { '6': 'Bryggeriet ombyggnad' } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
|
||||
expect(mockTrialBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_income_statement — dimensions filter', () => {
|
||||
it('accepts an exact code without echoing resolutions', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD_ROW, error: null }) // period info
|
||||
enqueueRegistry(enqueue)
|
||||
mockIncomeStatement.mockResolvedValueOnce({ net_result: 42 } as never)
|
||||
|
||||
const result = (await incomeStatement.execute(
|
||||
{ period_id: 'fp-1', dimensions: { '6': 'P001' } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as {
|
||||
net_result: number
|
||||
period: { start: string; end: string }
|
||||
dimension_filter?: Record<string, string>
|
||||
dimension_resolutions?: unknown
|
||||
}
|
||||
|
||||
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(result.net_result).toBe(42)
|
||||
expect(result.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||||
expect(result.dimension_filter).toEqual({ '6': 'P001' })
|
||||
// Exact code match is not a resolution — no echo.
|
||||
expect(result.dimension_resolutions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_general_ledger — dimensions filter', () => {
|
||||
it('passes the bag through verbatim when dimensions_enabled is false (free-text passthrough)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings
|
||||
mockGeneralLedger.mockResolvedValueOnce({ accounts: [] } as never)
|
||||
|
||||
const result = (await generalLedger.execute(
|
||||
{ period_id: 'fp-1', dimensions: { '6': 'fritext-projekt' } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { dimension_filter?: Record<string, string> }
|
||||
|
||||
expect(mockGeneralLedger).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', undefined, undefined, {
|
||||
dimensions: { '6': 'fritext-projekt' },
|
||||
})
|
||||
expect(result.dimension_filter).toEqual({ '6': 'fritext-projekt' })
|
||||
// Disabled → no ensure RPC, no registry reads.
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,9 @@ import {
|
||||
parseDimensionsArg,
|
||||
mergeLineDimensions,
|
||||
resolveDimensionBags,
|
||||
type DimensionResolution,
|
||||
} from './dimensions'
|
||||
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
|
||||
import Fuse from 'fuse.js'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -1546,6 +1548,47 @@ function previousPeriodArgs(
|
||||
return null
|
||||
}
|
||||
|
||||
// Shared by the report tools' optional `dimensions` filter arg: parse the raw
|
||||
// bag, then resolve value NAMES → registry codes in one pass (resolve-don't-
|
||||
// select — the exact contract gnubok_create_voucher uses, incl. free-text
|
||||
// passthrough while dimensions_enabled is off). A DimensionResolutionError
|
||||
// propagates to the caller with ranked candidates. The resolved bag is echoed
|
||||
// back as `dimension_filter` so the agent can verify what a name attached to.
|
||||
async function resolveReportDimensionFilter(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
raw: unknown,
|
||||
): Promise<{ filter?: Record<string, string>; resolutions: DimensionResolution[] }> {
|
||||
if (!raw || typeof raw !== 'object' || Object.keys(raw as object).length === 0) {
|
||||
return { resolutions: [] }
|
||||
}
|
||||
const parsed = parseDimensionsArg(raw, 'dimensions')
|
||||
const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [parsed])
|
||||
return { filter: bags[0], resolutions }
|
||||
}
|
||||
|
||||
// Input-schema fragment for that arg — identical shape on trial balance,
|
||||
// income statement, and general ledger.
|
||||
const REPORT_DIMENSIONS_FILTER_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. P&L view only — opening balances are excluded when set.',
|
||||
} as const
|
||||
|
||||
// Output-schema fragments for the echo fields (never in `required`).
|
||||
const DIMENSION_FILTER_OUTPUT_PROPS = {
|
||||
dimension_filter: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Echo of the applied filter, resolved to registry codes.',
|
||||
},
|
||||
dimension_resolutions: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'Non-exact name→code resolution echoes (resolve-don\'t-select).',
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────
|
||||
|
||||
export const tools: McpTool[] = [
|
||||
@@ -3467,12 +3510,13 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_trial_balance',
|
||||
title: 'Trial Balance (Råbalans)',
|
||||
description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period.',
|
||||
description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA,
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -3487,6 +3531,7 @@ export const tools: McpTool[] = [
|
||||
period_start: { type: 'string' },
|
||||
period_end: { type: 'string' },
|
||||
account_count: { type: 'number' },
|
||||
...DIMENSION_FILTER_OUTPUT_PROPS,
|
||||
},
|
||||
required: ['rows', 'total_debit', 'total_credit', 'is_balanced'],
|
||||
},
|
||||
@@ -3525,12 +3570,22 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
// Optional dimensions filter — names resolve to registry codes first
|
||||
// (resolve-don't-select), then flow into the generator's jsonb
|
||||
// containment filter.
|
||||
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
|
||||
|
||||
// Delegate to the canonical, paginated trial-balance builder. The
|
||||
// previous inline query had no pagination, so PostgREST's 1000-row
|
||||
// default silently truncated any period with >1000 entry lines (wrong
|
||||
// sums, false "not balanced"), and it ignored opening balances.
|
||||
// generateTrialBalance paginates and rolls IB forward.
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, periodId!)
|
||||
const trialBalance = await generateTrialBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
periodId!,
|
||||
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
|
||||
)
|
||||
|
||||
const rows = trialBalance.rows
|
||||
.map((r) => {
|
||||
@@ -3558,6 +3613,8 @@ export const tools: McpTool[] = [
|
||||
period_start: period.period_start,
|
||||
period_end: period.period_end,
|
||||
account_count: rows.length,
|
||||
...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}),
|
||||
...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3776,15 +3833,19 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_income_statement',
|
||||
title: 'Income Statement (Resultaträkning)',
|
||||
description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category.',
|
||||
description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA,
|
||||
},
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: { ...DIMENSION_FILTER_OUTPUT_PROPS },
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
@@ -3818,12 +3879,21 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
const result = await generateIncomeStatement(supabase, companyId, periodId!)
|
||||
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
|
||||
|
||||
const result = await generateIncomeStatement(
|
||||
supabase,
|
||||
companyId,
|
||||
periodId!,
|
||||
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
|
||||
)
|
||||
result.period = { start: period.period_start, end: period.period_end }
|
||||
|
||||
return {
|
||||
period_name: period.name,
|
||||
...result,
|
||||
...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}),
|
||||
...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -4761,6 +4831,115 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_dimension_pnl',
|
||||
title: 'P&L per Dimension (Resultat per projekt)',
|
||||
description: 'Resultat per projekt/kostnadsställe: P&L matrix over one SIE dimension — each value with activity becomes a column plus an untagged bucket, and the Totalt column reconciles exactly with the resultatrapport. sie_dim_no: 1 = kostnadsställe, 6 = projekt.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sie_dim_no: { type: 'string', description: "SIE dimension number: '1' = kostnadsställe, '6' = projekt, or a custom dim from gnubok_list_dimensions." },
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
to_date: { type: 'string', description: 'Optional end date (YYYY-MM-DD); the matrix is always cumulative from period start (closing-balance semantics, reconciles with resultatrapport)' },
|
||||
},
|
||||
required: ['sie_dim_no'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimension: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sie_dim_no: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: 'One per value with activity; code null = the "(Utan dimension)" residual bucket.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: ['string', 'null'] },
|
||||
name: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: 'array',
|
||||
description: 'BAS class groups (3–8); each row\'s values[] aligns with columns[].',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
class: { type: 'number' },
|
||||
class_label: { type: 'string' },
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
account_number: { type: 'string' },
|
||||
account_name: { type: 'string' },
|
||||
values: { type: 'array', items: { type: 'number' } },
|
||||
total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
subtotals: { type: 'array', items: { type: 'number' } },
|
||||
subtotal_total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
net_per_column: { type: 'array', items: { type: 'number' } },
|
||||
net_total: { type: 'number', description: 'Matches resultatrapport net result for the same window.' },
|
||||
period: {
|
||||
type: 'object',
|
||||
properties: { start: { type: 'string' }, end: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
required: ['dimension', 'columns', 'groups', 'net_per_column', 'net_total'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const sieDimNo = String(args.sie_dim_no ?? '').trim()
|
||||
// Positive-integer guard — the value is interpolated into a PostgREST
|
||||
// jsonb path expression downstream, so free-form strings are rejected.
|
||||
if (!/^[1-9]\d{0,3}$/.test(sieDimNo)) {
|
||||
throw new Error("sie_dim_no must be a positive SIE dimension number, e.g. '1' (kostnadsställe) or '6' (projekt).")
|
||||
}
|
||||
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
// If no period specified, find the most recent one (same default as
|
||||
// gnubok_get_trial_balance).
|
||||
if (!periodId) {
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (!periods) {
|
||||
throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.')
|
||||
}
|
||||
periodId = periods.id
|
||||
}
|
||||
|
||||
const toDate = args.to_date as string | undefined
|
||||
|
||||
return await generateDimensionPnl(supabase, companyId, periodId!, sieDimNo, { toDate })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
@@ -4819,7 +4998,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_general_ledger',
|
||||
title: 'General Ledger (Huvudbok)',
|
||||
description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range filter. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.',
|
||||
description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range + dimensions filters. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4827,9 +5006,13 @@ export const tools: McpTool[] = [
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
account_from: { type: 'string', description: 'Starting account number filter' },
|
||||
account_to: { type: 'string', description: 'Ending account number filter' },
|
||||
dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA,
|
||||
},
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: { ...DIMENSION_FILTER_OUTPUT_PROPS },
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
@@ -4855,14 +5038,28 @@ export const tools: McpTool[] = [
|
||||
const accountFrom = args.account_from as string | undefined
|
||||
const accountTo = args.account_to as string | undefined
|
||||
|
||||
return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo)
|
||||
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
|
||||
|
||||
const report = await generateGeneralLedger(
|
||||
supabase,
|
||||
companyId,
|
||||
periodId!,
|
||||
accountFrom,
|
||||
accountTo,
|
||||
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
|
||||
)
|
||||
return {
|
||||
...report,
|
||||
...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}),
|
||||
...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_query_journal',
|
||||
title: 'Query Journal Lines',
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Returns lines with voucher metadata + totals.",
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Optional group_by aggregation. Returns lines + totals over the full match set (see totals_scope).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4882,7 +5079,9 @@ export const tools: McpTool[] = [
|
||||
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
|
||||
project: { type: 'string', description: 'Filter by project code' },
|
||||
cost_center: { type: 'string', description: 'Filter by cost center' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' },
|
||||
group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' },
|
||||
group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1–500 (default 100). Totals/groups cover the FULL match set even when truncated, except under free-text search (see totals_scope).' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -4903,9 +5102,28 @@ export const tools: McpTool[] = [
|
||||
net: { type: 'number', description: 'debit minus credit (positive = net debit)' },
|
||||
},
|
||||
},
|
||||
totals_scope: {
|
||||
type: 'string',
|
||||
enum: ['full_match', 'returned_slice'],
|
||||
description: 'full_match: totals/groups aggregate ALL matching lines regardless of limit. returned_slice: free-text search aggregates only the returned window.',
|
||||
},
|
||||
groups: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
debit: { type: 'number' },
|
||||
credit: { type: 'number' },
|
||||
net: { type: 'number' },
|
||||
line_count: { type: 'number' },
|
||||
},
|
||||
},
|
||||
description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.',
|
||||
},
|
||||
applied_filters: { type: 'object' },
|
||||
},
|
||||
required: ['lines', 'total_lines', 'returned_lines', 'totals'],
|
||||
required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
@@ -4933,16 +5151,44 @@ export const tools: McpTool[] = [
|
||||
const project = args.project as string | undefined
|
||||
const costCenter = args.cost_center as string | undefined
|
||||
|
||||
// Each text-search leg needs its own builder instance — PostgREST
|
||||
// query builders are not reusable across awaits. The factory closes
|
||||
// over the resolved filter values above.
|
||||
const buildBaseQuery = () => {
|
||||
const GROUP_BY_FIELDS = ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'] as const
|
||||
const groupBy = args.group_by as (typeof GROUP_BY_FIELDS)[number] | undefined
|
||||
const groupByDimension =
|
||||
args.group_by_dimension !== undefined && args.group_by_dimension !== null
|
||||
? String(args.group_by_dimension).trim()
|
||||
: undefined
|
||||
|
||||
if (groupBy && groupByDimension) {
|
||||
throw new Error('Use either group_by or group_by_dimension, not both')
|
||||
}
|
||||
if (groupBy && !GROUP_BY_FIELDS.includes(groupBy)) {
|
||||
throw new Error(`group_by must be one of: ${GROUP_BY_FIELDS.join(', ')}`)
|
||||
}
|
||||
// Positive-integer guard — the schema says string but hosts don't always
|
||||
// validate, and the value keys into the dimensions jsonb bag.
|
||||
if (groupByDimension && !/^[1-9]\d{0,3}$/.test(groupByDimension)) {
|
||||
throw new Error('group_by_dimension must be a positive SIE dimension number, e.g. "6" (projekt)')
|
||||
}
|
||||
const wantsGroups = Boolean(groupBy || groupByDimension)
|
||||
|
||||
// The dimensions jsonb only rides along when a group needs it — it is
|
||||
// the widest column on the line and the aggregate pass fetches ALL rows.
|
||||
const dimsSelect = groupByDimension ? ', dimensions' : ''
|
||||
const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)`
|
||||
// Lean projection for the full-match aggregate pass — only what totals
|
||||
// and group buckets need. journal_entries stays embedded (!inner)
|
||||
// because the entry-level filters bind to it.
|
||||
const AGGREGATE_SELECT = `id, account_number, debit_amount, credit_amount, project, cost_center${dimsSelect}, journal_entries!inner(voucher_series, source_type, company_id)`
|
||||
|
||||
// Each query pass needs its own builder instance — PostgREST query
|
||||
// builders are not reusable across awaits. The factory closes over the
|
||||
// resolved filter values above and applies IDENTICAL filters for every
|
||||
// projection, so display, text legs, and the aggregate pass always see
|
||||
// the same match set.
|
||||
const buildFilteredQuery = (select: string) => {
|
||||
let q = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)',
|
||||
{ count: 'exact' }
|
||||
)
|
||||
.select(select)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
|
||||
if (status === 'all') {
|
||||
@@ -4973,7 +5219,7 @@ export const tools: McpTool[] = [
|
||||
return q
|
||||
}
|
||||
|
||||
const applyOrderAndLimit = <T extends ReturnType<typeof buildBaseQuery>>(q: T): T =>
|
||||
const applyOrderAndLimit = <T extends ReturnType<typeof buildFilteredQuery>>(q: T): T =>
|
||||
q
|
||||
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
|
||||
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
|
||||
@@ -4989,6 +5235,7 @@ export const tools: McpTool[] = [
|
||||
line_description: string | null
|
||||
project: string | null
|
||||
cost_center: string | null
|
||||
dimensions?: Record<string, string> | null
|
||||
sort_order: number
|
||||
journal_entries: {
|
||||
id: string
|
||||
@@ -5001,6 +5248,20 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
}
|
||||
|
||||
// Lean row shape of the full-match aggregate pass. Field-compatible
|
||||
// with LineRow everywhere groupKey/totals read, so both can feed the
|
||||
// same aggregation code.
|
||||
type AggregateRow = {
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
project: string | null
|
||||
cost_center: string | null
|
||||
dimensions?: Record<string, string> | null
|
||||
journal_entries: { voucher_series: string; source_type: string }
|
||||
}
|
||||
|
||||
// Free-text search runs as two parallel .ilike() queries — one against
|
||||
// line_description (base table) and one against journal_entries.description
|
||||
// (embedded resource). PostgREST's flat .or() filter cannot span a base
|
||||
@@ -5043,7 +5304,7 @@ export const tools: McpTool[] = [
|
||||
const legLimit = Math.min(limit * 2, 500)
|
||||
|
||||
const buildLeg = (column: 'line_description' | 'journal_entries.description') =>
|
||||
buildBaseQuery()
|
||||
buildFilteredQuery(DISPLAY_SELECT)
|
||||
.ilike(column, pattern)
|
||||
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
|
||||
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
|
||||
@@ -5089,36 +5350,73 @@ export const tools: McpTool[] = [
|
||||
(byLine.data?.length ?? 0) >= legLimit ||
|
||||
(byEntry.data?.length ?? 0) >= legLimit
|
||||
} else {
|
||||
const res = await applyOrderAndLimit(buildBaseQuery())
|
||||
const res = await applyOrderAndLimit(buildFilteredQuery(DISPLAY_SELECT))
|
||||
if (res.error) {
|
||||
log.warn('query_journal failed', { companyId, userId, error: res.error.message })
|
||||
throw new Error('Database error while running journal query')
|
||||
}
|
||||
data = (res.data ?? []) as unknown as LineRow[]
|
||||
dbMatched = res.count ?? data.length
|
||||
dbMatched = data.length
|
||||
}
|
||||
|
||||
// Full-match aggregate pass (non-text path only): re-run the same
|
||||
// filters with a lean projection over ALL matching rows so totals and
|
||||
// groups are exact regardless of `limit`. The free-text path stays
|
||||
// slice-scoped (its per-leg windows make a full pass unbounded) and
|
||||
// says so via totals_scope='returned_slice'.
|
||||
let fullRows: AggregateRow[] | null = null
|
||||
if (!text) {
|
||||
try {
|
||||
fullRows = await fetchAllRows<AggregateRow>(
|
||||
({ from, to }) =>
|
||||
buildFilteredQuery(AGGREGATE_SELECT)
|
||||
// Stable total order on the line PK for correct paging.
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to) as unknown as PromiseLike<{
|
||||
data: AggregateRow[] | null
|
||||
error: { message: string } | null
|
||||
}>,
|
||||
{ dedupeBy: (r) => r.id },
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('query_journal aggregate pass failed', {
|
||||
companyId,
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw new Error('Database error while running journal query')
|
||||
}
|
||||
}
|
||||
|
||||
// Apply amount filter post-fetch — PostgREST can't OR an abs(debit) >= n
|
||||
// with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking
|
||||
// max(debit, credit) works.
|
||||
// max(debit, credit) works. The SAME predicate runs over the display
|
||||
// slice and the full aggregate set so both describe one match set.
|
||||
const amountMin = args.amount_min as number | undefined
|
||||
const amountMax = args.amount_max as number | undefined
|
||||
const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number'
|
||||
const filtered = data.filter((r) => {
|
||||
const passesAmountFilter = (r: { debit_amount: number; credit_amount: number }) => {
|
||||
const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0)
|
||||
if (typeof amountMin === 'number' && lineAmount < amountMin) return false
|
||||
if (typeof amountMax === 'number' && lineAmount > amountMax) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
const filtered = data.filter(passesAmountFilter)
|
||||
const fullFiltered = fullRows ? fullRows.filter(passesAmountFilter) : null
|
||||
|
||||
// Compute totals on the fetched-and-filtered set. Note: when truncated,
|
||||
// these are totals of the returned slice, not the full match. The
|
||||
// truncated flag tells the agent whether to issue a narrower query.
|
||||
// Totals aggregate over the full match set when available (non-text),
|
||||
// else over the returned slice (free-text) — totals_scope tells the
|
||||
// agent which one it got.
|
||||
const totalsSource: Array<{ debit_amount: number; credit_amount: number }> =
|
||||
fullFiltered ?? filtered
|
||||
let totalDebit = 0
|
||||
let totalCredit = 0
|
||||
const lines = filtered.map((r) => {
|
||||
for (const r of totalsSource) {
|
||||
totalDebit += Number(r.debit_amount) || 0
|
||||
totalCredit += Number(r.credit_amount) || 0
|
||||
}
|
||||
|
||||
const lines = filtered.map((r) => {
|
||||
return {
|
||||
line_id: r.id,
|
||||
journal_entry_id: r.journal_entries.id,
|
||||
@@ -5138,31 +5436,74 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
})
|
||||
|
||||
// PostgREST's `count` is computed before the post-fetch amount filter,
|
||||
// so when amount_min/amount_max is set it reflects the wider DB-side
|
||||
// match — not the lines actually returned. Reporting that as
|
||||
// `total_lines` would mislead an agent into chasing a truncated tail
|
||||
// that has already been filtered out client-side. When the amount
|
||||
// filter ran, anchor `total_lines` and `truncated` to the filtered
|
||||
// result, and surface the pre-filter count + a flag separately so an
|
||||
// agent can still tell the DB matched more (it just didn't pass the
|
||||
// amount predicate).
|
||||
const total_lines = amountFilterApplied ? lines.length : dbMatched
|
||||
const truncated = amountFilterApplied
|
||||
? data.length >= limit && lines.length === limit
|
||||
: dbMatched > lines.length || legCapHit
|
||||
// Optional group_by aggregation — over the same set totals used, so
|
||||
// group sums always reconcile with `totals`.
|
||||
let groups:
|
||||
| Array<{ key: string; debit: number; credit: number; net: number; line_count: number }>
|
||||
| undefined
|
||||
if (wantsGroups) {
|
||||
const groupSource: Array<AggregateRow | LineRow> = fullFiltered ?? filtered
|
||||
const keyOf = (r: AggregateRow | LineRow): string => {
|
||||
if (groupByDimension) return r.dimensions?.[groupByDimension] ?? '(utan dimension)'
|
||||
switch (groupBy) {
|
||||
case 'voucher_series': return r.journal_entries.voucher_series
|
||||
case 'source_type': return r.journal_entries.source_type
|
||||
case 'cost_center': return r.cost_center ?? '(utan dimension)'
|
||||
case 'project': return r.project ?? '(utan dimension)'
|
||||
default: return r.account_number
|
||||
}
|
||||
}
|
||||
const bucketMap = new Map<string, { debit: number; credit: number; count: number }>()
|
||||
for (const r of groupSource) {
|
||||
const key = keyOf(r)
|
||||
const bucket = bucketMap.get(key) ?? { debit: 0, credit: 0, count: 0 }
|
||||
bucket.debit += Number(r.debit_amount) || 0
|
||||
bucket.credit += Number(r.credit_amount) || 0
|
||||
bucket.count += 1
|
||||
bucketMap.set(key, bucket)
|
||||
}
|
||||
groups = [...bucketMap.entries()]
|
||||
.map(([key, bucket]) => ({
|
||||
key,
|
||||
debit: roundOre(bucket.debit),
|
||||
credit: roundOre(bucket.credit),
|
||||
net: roundOre(bucket.debit - bucket.credit),
|
||||
line_count: bucket.count,
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.net) - Math.abs(a.net))
|
||||
}
|
||||
|
||||
// Non-text path: the aggregate pass IS the full match set, so
|
||||
// total_lines / truncated / pre-amount count all anchor to it. Text
|
||||
// path: no full pass exists — total_lines stays slice-anchored exactly
|
||||
// as before (amount filter → post-filter slice; otherwise the merged
|
||||
// distinct count), and legCapHit keeps `truncated` honest.
|
||||
const total_lines = fullFiltered
|
||||
? fullFiltered.length
|
||||
: amountFilterApplied
|
||||
? lines.length
|
||||
: dbMatched
|
||||
const truncated = fullFiltered
|
||||
? fullFiltered.length > lines.length
|
||||
: amountFilterApplied
|
||||
? data.length >= limit && lines.length === limit
|
||||
: dbMatched > lines.length || legCapHit
|
||||
return {
|
||||
lines,
|
||||
truncated,
|
||||
total_lines,
|
||||
returned_lines: lines.length,
|
||||
amount_filter_applied_post_fetch: amountFilterApplied,
|
||||
db_matched_pre_amount_filter: amountFilterApplied ? dbMatched : null,
|
||||
db_matched_pre_amount_filter: amountFilterApplied
|
||||
? (fullRows ? fullRows.length : dbMatched)
|
||||
: null,
|
||||
totals: {
|
||||
debit: Math.round(totalDebit * 100) / 100,
|
||||
credit: Math.round(totalCredit * 100) / 100,
|
||||
net: Math.round((totalDebit - totalCredit) * 100) / 100,
|
||||
},
|
||||
totals_scope: fullFiltered ? 'full_match' : 'returned_slice',
|
||||
...(groups ? { groups } : {}),
|
||||
applied_filters: {
|
||||
account_from: accountFrom ?? null,
|
||||
account_to: accountTo ?? null,
|
||||
@@ -5179,6 +5520,8 @@ export const tools: McpTool[] = [
|
||||
status,
|
||||
project: project ?? null,
|
||||
cost_center: costCenter ?? null,
|
||||
group_by: groupBy ?? null,
|
||||
group_by_dimension: groupByDimension ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user