* 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>
252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import type { TrialBalanceRow } from '@/types'
|
||
|
||
// ============================================================
|
||
// Resultat per projekt/kostnadsställe (dimensions PR4).
|
||
//
|
||
// generateTrialBalance is mocked (post-processor pattern, like
|
||
// resultatrapport.test.ts); the registry + tagged-line queries use a
|
||
// table-keyed FIFO mock.
|
||
// ============================================================
|
||
|
||
vi.mock('../trial-balance', () => ({
|
||
generateTrialBalance: vi.fn(),
|
||
}))
|
||
|
||
type MockResult = { data?: unknown; error?: unknown }
|
||
let mockResults: Record<string, MockResult[]>
|
||
|
||
function makeBuilder(tableName: string) {
|
||
const b: Record<string, unknown> = {}
|
||
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'not', 'contains', 'order', 'range']) {
|
||
b[m] = vi.fn().mockReturnValue(b)
|
||
}
|
||
const consume = (): MockResult => {
|
||
const queue = mockResults[tableName]
|
||
if (!queue || queue.length === 0) return { data: null, error: null }
|
||
return queue.shift()!
|
||
}
|
||
b.single = vi.fn().mockImplementation(async () => consume())
|
||
b.maybeSingle = vi.fn().mockImplementation(async () => consume())
|
||
b.then = (resolve: (v: unknown) => void) => resolve(consume())
|
||
return b
|
||
}
|
||
|
||
function makeClient() {
|
||
return {
|
||
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
} as any
|
||
}
|
||
|
||
import { generateDimensionPnl } from '../dimension-pnl'
|
||
import { generateTrialBalance } from '../trial-balance'
|
||
|
||
const mockTrialBalance = vi.mocked(generateTrialBalance)
|
||
|
||
function tbRow(partial: Partial<TrialBalanceRow>): TrialBalanceRow {
|
||
return {
|
||
account_number: '3001',
|
||
account_name: 'Försäljning',
|
||
account_class: 3,
|
||
opening_debit: 0,
|
||
opening_credit: 0,
|
||
period_debit: 0,
|
||
period_credit: 0,
|
||
closing_debit: 0,
|
||
closing_credit: 0,
|
||
...partial,
|
||
}
|
||
}
|
||
|
||
function tb(rows: TrialBalanceRow[]) {
|
||
return { rows, totalDebit: 0, totalCredit: 0, isBalanced: true }
|
||
}
|
||
|
||
let supabase: ReturnType<typeof makeClient>
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
mockResults = {}
|
||
supabase = makeClient()
|
||
})
|
||
|
||
const PERIOD = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||
|
||
describe('generateDimensionPnl', () => {
|
||
it('builds the value-as-column matrix with an untagged residual that reconciles to the trial balance', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||
dimensions: [
|
||
{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null },
|
||
],
|
||
dimension_values: [
|
||
{
|
||
data: [
|
||
{ code: 'P001', name: 'Villa Almgren' },
|
||
{ code: 'P002', name: 'Kontorsbygget' },
|
||
],
|
||
error: null,
|
||
},
|
||
],
|
||
journal_entry_lines: [
|
||
{
|
||
data: [
|
||
{ id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 600, dimensions: { '6': 'P001' } },
|
||
{ id: 'l2', account_number: '3001', debit_amount: 0, credit_amount: 300, dimensions: { '6': 'P002' } },
|
||
{ id: 'l3', account_number: '4010', debit_amount: 400, credit_amount: 0, dimensions: { '6': 'P001' } },
|
||
// Balance-account line — outside the P&L scope, must be ignored.
|
||
{ id: 'l4', account_number: '1930', debit_amount: 0, credit_amount: 900, dimensions: { '6': 'P001' } },
|
||
],
|
||
error: null,
|
||
},
|
||
],
|
||
}
|
||
mockTrialBalance.mockResolvedValue(
|
||
tb([
|
||
// 3001: 1000 total credit — only 900 of it is tagged → 100 untagged.
|
||
tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }),
|
||
tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 400 }),
|
||
tbRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 900 }),
|
||
tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 600 }),
|
||
]),
|
||
)
|
||
|
||
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6')
|
||
|
||
expect(report.dimension).toEqual({ sie_dim_no: '6', name: 'Projekt' })
|
||
expect(report.columns).toEqual([
|
||
{ code: 'P001', name: 'Villa Almgren' },
|
||
{ code: 'P002', name: 'Kontorsbygget' },
|
||
{ code: null, name: null }, // (Utan dimension)
|
||
])
|
||
|
||
const revenue = report.groups.find((g) => g.class === 3)!
|
||
expect(revenue.rows).toEqual([
|
||
{ account_number: '3001', account_name: 'Försäljning', values: [600, 300, 100], total: 1000 },
|
||
])
|
||
const costs = report.groups.find((g) => g.class === 4)!
|
||
expect(costs.rows).toEqual([
|
||
{ account_number: '4010', account_name: 'Inköp', values: [-400, 0, 0], total: -400 },
|
||
])
|
||
|
||
// Every row sums exactly to its Totalt (reconciliation by construction).
|
||
for (const g of report.groups) {
|
||
for (const r of g.rows) {
|
||
expect(r.values.reduce((s, v) => s + v, 0)).toBeCloseTo(r.total, 10)
|
||
}
|
||
}
|
||
|
||
expect(report.net_per_column).toEqual([200, 300, 100])
|
||
// net_total = resultatrapport semantics over classes 3–8 excl 8999:
|
||
// +1000 (3001) − 400 (4010) = 600. 1930 (class 1) and 8999 excluded.
|
||
expect(report.net_total).toBe(600)
|
||
expect(report.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||
})
|
||
|
||
it('drops the untagged column when every krona is tagged', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||
dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }],
|
||
dimension_values: [{ data: [{ code: 'P001', name: 'Villa Almgren' }], error: null }],
|
||
journal_entry_lines: [
|
||
{
|
||
data: [
|
||
{ id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 1000, dimensions: { '6': 'P001' } },
|
||
],
|
||
error: null,
|
||
},
|
||
],
|
||
}
|
||
mockTrialBalance.mockResolvedValue(
|
||
tb([tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 })]),
|
||
)
|
||
|
||
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6')
|
||
|
||
expect(report.columns).toEqual([{ code: 'P001', name: 'Villa Almgren' }])
|
||
expect(report.groups[0].rows[0].values).toEqual([1000])
|
||
expect(report.net_per_column).toEqual([1000])
|
||
expect(report.net_total).toBe(1000)
|
||
})
|
||
|
||
it('falls back to seeded dimension names when the registry has no row', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||
dimensions: [{ data: null, error: null }],
|
||
journal_entry_lines: [
|
||
{
|
||
data: [
|
||
{ id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 100, dimensions: { '1': 'KS01' } },
|
||
],
|
||
error: null,
|
||
},
|
||
],
|
||
}
|
||
mockTrialBalance.mockResolvedValue(
|
||
tb([tbRow({ account_number: '3001', account_class: 3, closing_credit: 100 })]),
|
||
)
|
||
|
||
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '1')
|
||
|
||
expect(report.dimension.name).toBe('Kostnadsställe')
|
||
// Code column without a registry name.
|
||
expect(report.columns[0]).toEqual({ code: 'KS01', name: null })
|
||
})
|
||
|
||
it('passes the caller date range to the trial balance (Totalt parity with resultatrapport)', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||
dimensions: [{ data: null, error: null }],
|
||
journal_entry_lines: [{ data: [], error: null }],
|
||
}
|
||
mockTrialBalance.mockResolvedValue(tb([]))
|
||
|
||
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6', {
|
||
toDate: '2026-06-30',
|
||
})
|
||
|
||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
|
||
toDate: '2026-06-30',
|
||
})
|
||
// The label reflects actual coverage: cumulative from period_start.
|
||
expect(report.period).toEqual({ start: '2026-01-01', end: '2026-06-30' })
|
||
})
|
||
|
||
it('handles fully untagged periods — one residual column carrying the whole result', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||
dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }],
|
||
dimension_values: [{ data: [], error: null }],
|
||
journal_entry_lines: [{ data: [], error: null }],
|
||
}
|
||
mockTrialBalance.mockResolvedValue(
|
||
tb([
|
||
tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }),
|
||
tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 250 }),
|
||
]),
|
||
)
|
||
|
||
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6')
|
||
|
||
expect(report.columns).toEqual([{ code: null, name: null }])
|
||
expect(report.groups.find((g) => g.class === 3)?.rows[0].values).toEqual([1000])
|
||
expect(report.groups.find((g) => g.class === 4)?.rows[0].values).toEqual([-250])
|
||
expect(report.net_per_column).toEqual([750])
|
||
expect(report.net_total).toBe(750)
|
||
})
|
||
|
||
it('rejects a non-numeric dimension number (PostgREST path guard)', async () => {
|
||
await expect(
|
||
generateDimensionPnl(supabase, 'company-1', 'period-1', '6,is.null'),
|
||
).rejects.toThrow('positive SIE dimension number')
|
||
})
|
||
|
||
it('throws when the fiscal period does not exist', async () => {
|
||
mockResults = { fiscal_periods: [{ data: null, error: null }] }
|
||
await expect(generateDimensionPnl(supabase, 'company-1', 'missing', '6')).rejects.toThrow(
|
||
'Fiscal period not found',
|
||
)
|
||
})
|
||
})
|