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
@@ -272,9 +272,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
// Include current entry in the chain for the visualization
|
||||
const fullChain = [entry, ...chain]
|
||||
|
||||
// SIE dimension badge prefixes — statutory Swedish abbreviations
|
||||
// (kostnadsställe/projekt); stays Swedish per .claude/rules/i18n.md.
|
||||
const DIM_BADGE_PREFIX: Record<string, string> = { '1': 'KS', '6': 'PR' }
|
||||
// SIE dimension badge prefixes. 'KS' is the market-standard abbreviation
|
||||
// for kostnadsställe; projekt has no standard abbreviation (Fortnox/Visma
|
||||
// show the dimension name, and 'PR' collides with prisnivå in some BAS
|
||||
// setups — flagged in the #859 compliance review), so dim 6 falls through
|
||||
// to the registry name below. Stays Swedish per .claude/rules/i18n.md.
|
||||
const DIM_BADGE_PREFIX: Record<string, string> = { '1': 'KS' }
|
||||
|
||||
// Display-only dimension badges for a line (e.g. 'KS: Butik', 'PR: P001').
|
||||
// Names resolve through the registry when loaded; raw codes otherwise.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { ReportLibrary } from '@/components/reports/ReportLibrary'
|
||||
import { RecentReportsShelf } from '@/components/reports/RecentReportsShelf'
|
||||
@@ -25,6 +26,7 @@ export default function ReportsPage() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [isLoadingInit, setIsLoadingInit] = useState(true)
|
||||
const { company } = useCompany()
|
||||
const { settings } = useCompanySettings()
|
||||
const t = useTranslations('reports')
|
||||
const { recents, pushRecent } = useRecentReports(company?.id)
|
||||
|
||||
@@ -85,6 +87,7 @@ export default function ReportsPage() {
|
||||
/>
|
||||
<ReportLibrary
|
||||
entityType={company?.entity_type}
|
||||
dimensionsEnabled={settings?.dimensions_enabled === true}
|
||||
onOpen={openReport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
|
||||
// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over one
|
||||
// SIE dimension. ?dim_no picks the dimension (default 6, projekt).
|
||||
export const GET = withRouteContext(
|
||||
'report.dimension_pnl',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const dimNo = searchParams.get('dim_no') ?? '6'
|
||||
|
||||
if (!periodId) {
|
||||
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
|
||||
}
|
||||
if (!/^[1-9]\d{0,3}$/.test(dimNo)) {
|
||||
return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = parseReportDateRange(searchParams, period)
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Only toDate — the matrix is cumulative from period_start by design
|
||||
// (closing-balance semantics; see lib/reports/dimension-pnl.ts).
|
||||
const data = await generateDimensionPnl(supabase, companyId!, periodId, dimNo, {
|
||||
toDate: parsed.range.toDate,
|
||||
})
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
log.error('dimension pnl generation failed', err as Error, { periodId, dimNo })
|
||||
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import {
|
||||
reportToWorkbook,
|
||||
textColumn,
|
||||
currencyColumn,
|
||||
xlsxFilename,
|
||||
} from '@/lib/reports/xlsx-export'
|
||||
import type { DimensionPnlReport } from '@/types'
|
||||
|
||||
// One row per account; the dimension values are dynamic columns, exactly as
|
||||
// the on-screen matrix renders. Column labels stay Swedish (report surface).
|
||||
type FlatRow = {
|
||||
group: string
|
||||
account_number: string
|
||||
account_name: string
|
||||
values: number[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'report.dimension_pnl_xlsx',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const dimNo = searchParams.get('dim_no') ?? '6'
|
||||
|
||||
if (!periodId) {
|
||||
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
|
||||
}
|
||||
if (!/^[1-9]\d{0,3}$/.test(dimNo)) {
|
||||
return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [{ data: companyRow }, { data: period }] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_name')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
])
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = parseReportDateRange(searchParams, period)
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Only toDate — the matrix is cumulative from period_start by design.
|
||||
const report: DimensionPnlReport = await generateDimensionPnl(
|
||||
supabase,
|
||||
companyId!,
|
||||
periodId,
|
||||
dimNo,
|
||||
{ toDate: parsed.range.toDate },
|
||||
)
|
||||
|
||||
const valueHeaders = report.columns.map((c) =>
|
||||
c.code === null ? '(Utan dimension)' : c.name ? `${c.code} ${c.name}` : c.code,
|
||||
)
|
||||
|
||||
const rows: FlatRow[] = []
|
||||
for (const g of report.groups) {
|
||||
for (const r of g.rows) {
|
||||
rows.push({
|
||||
group: g.class_label,
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
values: r.values,
|
||||
total: r.total,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
group: 'Resultat',
|
||||
account_number: '',
|
||||
account_name: 'Beräknat resultat',
|
||||
values: report.net_per_column,
|
||||
total: report.net_total,
|
||||
})
|
||||
|
||||
const workbook = reportToWorkbook<FlatRow>([
|
||||
{
|
||||
name: `Resultat per ${report.dimension.name}`.slice(0, 31),
|
||||
columns: [
|
||||
textColumn('Grupp'),
|
||||
textColumn('Konto'),
|
||||
textColumn('Benämning'),
|
||||
...valueHeaders.map((h) => currencyColumn(h)),
|
||||
currencyColumn('Totalt'),
|
||||
],
|
||||
rows,
|
||||
mapRow: (r) => [r.group, r.account_number, r.account_name, ...r.values, r.total],
|
||||
},
|
||||
])
|
||||
|
||||
const filename = xlsxFilename(
|
||||
'resultat-per-dimension',
|
||||
companyRow?.company_name ?? '',
|
||||
report.period.end,
|
||||
)
|
||||
|
||||
return new NextResponse(new Uint8Array(workbook), {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('dimension pnl xlsx failed', err as Error, { periodId, dimNo })
|
||||
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'report.general_ledger',
|
||||
@@ -17,8 +18,15 @@ export const GET = withRouteContext(
|
||||
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo)
|
||||
const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo, {
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
log.error('general ledger generation failed', err as Error, { periodId })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
import {
|
||||
reportToWorkbook,
|
||||
textColumn,
|
||||
@@ -54,8 +55,15 @@ export async function GET(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo)
|
||||
const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo, {
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
|
||||
// Flatten accounts + their lines into a single sheet. Each account contributes
|
||||
// an opening-balance row, its lines (with running balance), and a closing
|
||||
@@ -99,6 +107,24 @@ export async function GET(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Partial-view disclosure: a filtered huvudbok starts balance accounts
|
||||
// at zero IB (opening balances cannot be dimension-scoped) — the export
|
||||
// must say so or a project-filtered ledger reads as a full one.
|
||||
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
|
||||
if (disclosure) {
|
||||
rows.unshift({
|
||||
account_number: disclosure,
|
||||
account_name: '',
|
||||
date: null as unknown as Date,
|
||||
voucher: '',
|
||||
description: 'Ingående balanser ingår inte i filtrerad vy',
|
||||
source_type: '',
|
||||
debit: null as unknown as number,
|
||||
credit: null as unknown as number,
|
||||
balance: null as unknown as number,
|
||||
})
|
||||
}
|
||||
|
||||
const buffer = reportToWorkbook<FlatRow>([
|
||||
{
|
||||
name: 'Huvudbok',
|
||||
@@ -128,7 +154,7 @@ export async function GET(request: Request) {
|
||||
},
|
||||
])
|
||||
|
||||
const filename = xlsxFilename('huvudbok', companyRow?.company_name ?? '', report.period.end)
|
||||
const filename = xlsxFilename(`huvudbok${dimensionFilterFileSuffix(dimFilter.dimensions)}`, companyRow?.company_name ?? '', report.period.end)
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStat
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
|
||||
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
|
||||
// into three named blocks with subtotals:
|
||||
@@ -80,8 +81,16 @@ export async function GET(request: Request) {
|
||||
const effectiveStart = range.fromDate ?? period.period_start
|
||||
const effectiveEnd = range.toDate ?? period.period_end
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
|
||||
const report = await generateIncomeStatement(supabase, companyId, periodId, {
|
||||
...range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
report.period = { start: effectiveStart, end: effectiveEnd }
|
||||
|
||||
const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100
|
||||
@@ -196,7 +205,10 @@ export async function GET(request: Request) {
|
||||
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
FinancialStatementPDF({
|
||||
title: 'Resultaträkning',
|
||||
// Partial-view disclosure in the document title (BFNAR 2013:2).
|
||||
title: dimensionFilterDisclosure(dimFilter.dimensions)
|
||||
? `Resultaträkning — ${dimensionFilterDisclosure(dimFilter.dimensions)}`
|
||||
: 'Resultaträkning',
|
||||
groups,
|
||||
summary,
|
||||
period: report.period,
|
||||
@@ -207,7 +219,7 @@ export async function GET(request: Request) {
|
||||
|
||||
// "-utkast" suffix keeps the draft status visible even after the file
|
||||
// leaves the browser — complements the in-document ÅRL 2:7 disclaimer.
|
||||
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
|
||||
const filename = `resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}-utkast.pdf`
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'report.income_statement',
|
||||
@@ -34,8 +35,16 @@ export const GET = withRouteContext(
|
||||
range = parsed.range
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateIncomeStatement(supabase, companyId!, periodId, range)
|
||||
const result = await generateIncomeStatement(supabase, companyId!, periodId, {
|
||||
...range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
|
||||
if (period) {
|
||||
result.period = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
xlsxFilename,
|
||||
} from '@/lib/reports/xlsx-export'
|
||||
import type { IncomeStatementSection } from '@/types'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
|
||||
interface FlatRow {
|
||||
section: string
|
||||
@@ -92,8 +93,16 @@ export async function GET(request: Request) {
|
||||
const range = parsedRange.range
|
||||
const effectiveEnd = range.toDate ?? period.period_end
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
|
||||
const report = await generateIncomeStatement(supabase, companyId, periodId, {
|
||||
...range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
|
||||
const revenueRows = flatten(
|
||||
report.revenue_sections,
|
||||
@@ -137,6 +146,21 @@ export async function GET(request: Request) {
|
||||
]
|
||||
const mapRow = (r: FlatRow) => [r.section, r.account_number, r.account_name, r.amount]
|
||||
|
||||
// Partial-view disclosure on every sheet — any tab opened alone must
|
||||
// still identify the export as filtered (BFNAR 2013:2).
|
||||
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
|
||||
if (disclosure) {
|
||||
const note: FlatRow = {
|
||||
section: disclosure,
|
||||
account_number: '',
|
||||
account_name: '',
|
||||
amount: null as unknown as number,
|
||||
}
|
||||
for (const sheetRows of [revenueRows, expenseRows, financialRows, summaryRows]) {
|
||||
sheetRows.unshift(note)
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = reportToWorkbook<FlatRow>([
|
||||
{ name: 'Intäkter', columns, rows: revenueRows, mapRow },
|
||||
{ name: 'Kostnader', columns, rows: expenseRows, mapRow },
|
||||
@@ -145,7 +169,7 @@ export async function GET(request: Request) {
|
||||
])
|
||||
|
||||
const filename = xlsxFilename(
|
||||
'resultatrakning',
|
||||
`resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}`,
|
||||
companyRow?.company_name ?? '',
|
||||
effectiveEnd,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/reports/kpi'
|
||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -39,6 +40,17 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Dimension filter applies to the P&L-side KPIs only (net result, revenue/
|
||||
// expenses, months, expense composition). Balance-side KPIs (cash, VAT,
|
||||
// receivables) and supplier/invoice aggregates stay company-wide — a
|
||||
// dimension-scoped "cash position" would be silently wrong, not filtered.
|
||||
// The KPI view hides those tiles when a filter is active.
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
const dimensions = dimFilter.dimensions
|
||||
|
||||
// Load user preferences for account overrides
|
||||
const { data: prefsData } = await supabase
|
||||
.from('extension_data')
|
||||
@@ -59,11 +71,12 @@ export async function GET(request: Request) {
|
||||
monthlyBreakdown,
|
||||
paidInvoicesResult,
|
||||
topSuppliersResult,
|
||||
filteredTrialBalance,
|
||||
] = await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, periodId),
|
||||
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
|
||||
generateTrialBalance(supabase, companyId, periodId),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
@@ -77,6 +90,12 @@ export async function GET(request: Request) {
|
||||
.gte('invoice_date', period.period_start)
|
||||
.lte('invoice_date', period.period_end)
|
||||
.neq('status', 'credited'),
|
||||
// Second, dimension-scoped TB only when filtered — feeds the expense
|
||||
// composition (classes 4–7, P&L) without touching the unfiltered TB the
|
||||
// balance-side KPIs read.
|
||||
dimensions
|
||||
? generateTrialBalance(supabase, companyId, periodId, { dimensions })
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
// Cash position — use account overrides if set
|
||||
@@ -109,7 +128,7 @@ export async function GET(request: Request) {
|
||||
// normal balance, so amount = closing_debit - closing_credit. Negative
|
||||
// values (rare reclassifications) are clamped to 0 so the donut renders
|
||||
// sensibly.
|
||||
const expenseComposition = trialBalanceResult.rows.reduce(
|
||||
const expenseComposition = (filteredTrialBalance ?? trialBalanceResult).rows.reduce(
|
||||
(acc, r) => {
|
||||
if (r.account_class < 4 || r.account_class > 7) return acc
|
||||
const amount = r.closing_debit - r.closing_credit
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -20,8 +21,15 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await generateMonthlyBreakdown(supabase, companyId, periodId)
|
||||
const data = await generateMonthlyBreakdown(supabase, companyId, periodId, {
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
return NextResponse.json({ data })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-templat
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -55,18 +56,28 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateResultatrapport(supabase, companyId, periodId, parsedRange.range)
|
||||
const report = await generateResultatrapport(supabase, companyId, periodId, {
|
||||
...parsedRange.range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
ResultatrapportPDF({
|
||||
report,
|
||||
company: companyRow as CompanySettings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
// Partial-view disclosure in the document header (BFNAR 2013:2).
|
||||
filterNote: dimensionFilterDisclosure(dimFilter.dimensions) ?? undefined,
|
||||
})
|
||||
)
|
||||
|
||||
const filename = `resultatrapport-${report.period.start}--${report.period.end}.pdf`
|
||||
const filename = `resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}.pdf`
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -37,8 +38,16 @@ export async function GET(request: Request) {
|
||||
range = parsed.range
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateResultatrapport(supabase, companyId, periodId, range)
|
||||
const result = await generateResultatrapport(supabase, companyId, periodId, {
|
||||
...range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
import {
|
||||
reportToWorkbook,
|
||||
textColumn,
|
||||
@@ -58,8 +59,16 @@ export async function GET(request: Request) {
|
||||
range = parsed.range
|
||||
}
|
||||
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateResultatrapport(supabase, companyId, periodId, range)
|
||||
const report = await generateResultatrapport(supabase, companyId, periodId, {
|
||||
...range,
|
||||
dimensions: dimFilter.dimensions,
|
||||
})
|
||||
|
||||
const rows: FlatRow[] = []
|
||||
for (const g of report.groups) {
|
||||
@@ -88,6 +97,19 @@ export async function GET(request: Request) {
|
||||
prior_period: report.net_result_prior,
|
||||
})
|
||||
|
||||
// Partial-view disclosure survives the file boundary: a filtered export
|
||||
// must never be mistakable for the authoritative report (BFNAR 2013:2).
|
||||
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
|
||||
if (disclosure) {
|
||||
rows.unshift({
|
||||
group: disclosure,
|
||||
account_number: '',
|
||||
account_name: '',
|
||||
current_period: null as unknown as number,
|
||||
prior_period: null as unknown as number,
|
||||
})
|
||||
}
|
||||
|
||||
const buffer = reportToWorkbook<FlatRow>([
|
||||
{
|
||||
name: 'Resultatrapport',
|
||||
@@ -110,7 +132,7 @@ export async function GET(request: Request) {
|
||||
])
|
||||
|
||||
const filename = xlsxFilename(
|
||||
'resultatrapport',
|
||||
`resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}`,
|
||||
companyRow?.company_name ?? '',
|
||||
report.period.end,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,14 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
// Same filter as the parent report, so drill-down totals match the row the
|
||||
// user expanded (a filtered resultatrapport row must not expand to
|
||||
// unfiltered lines).
|
||||
const dimFilter = parseDimensionFilterParams(searchParams)
|
||||
if (!dimFilter.ok) {
|
||||
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Look up account name (and verify account belongs to the company)
|
||||
const { data: account } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
@@ -85,15 +94,17 @@ export async function GET(
|
||||
id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions: Record<string, string> | null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
journal_entries: any
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
id,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
dimensions,
|
||||
journal_entry_id,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
@@ -110,8 +121,14 @@ export async function GET(
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to), { dedupeBy: (r) => r.id })
|
||||
|
||||
if (dimFilter.dimensions) {
|
||||
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', dimFilter.dimensions)
|
||||
}
|
||||
|
||||
return query.order('id', { ascending: true }).range(from, to)
|
||||
}, { dedupeBy: (r) => r.id })
|
||||
|
||||
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
|
||||
// a final deterministic tiebreak for lines sharing a date and voucher number
|
||||
@@ -124,6 +141,9 @@ export async function GET(
|
||||
description: row.journal_entries.description || '',
|
||||
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
|
||||
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
|
||||
...(row.dimensions && Object.keys(row.dimensions).length > 0
|
||||
? { dimensions: row.dimensions }
|
||||
: {}),
|
||||
}))
|
||||
allMapped.sort((a, b) => {
|
||||
const dateComp = a.date.localeCompare(b.date)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
|
||||
|
||||
export type DimensionFilterValue = {
|
||||
/** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */
|
||||
dimNo: string
|
||||
/** Selected object code. */
|
||||
code: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: DimensionFilterValue | null
|
||||
onChange: (next: DimensionFilterValue | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-dimension value filter for the P&L-safe reports (resultatrapport,
|
||||
* resultaträkning, huvudbok, KPI). Mounted by FocusedReport next to
|
||||
* ReportDateRange, only for catalog entries flagged `dimensions: true`.
|
||||
*
|
||||
* Renders nothing unless company_settings.dimensions_enabled — companies
|
||||
* that never activated dimensions see literally nothing changed.
|
||||
*
|
||||
* When active it shows a persistent "Filtrerad … — ej fullständig rapport"
|
||||
* chip: a dimension-scoped view is a partial view and must never be read as
|
||||
* the complete report. Strings are hardcoded Swedish per the report-surface
|
||||
* convention (same as DimensionCombobox).
|
||||
*/
|
||||
export function DimensionFilter({ value, onChange }: Props) {
|
||||
const { settings } = useCompanySettings()
|
||||
const [dims, setDims] = useState<DimensionDto[]>([])
|
||||
// Which dimension the picker targets while no value is selected yet;
|
||||
// once a value is picked, `value.dimNo` is the source of truth.
|
||||
const [pendingDimNo, setPendingDimNo] = useState('6')
|
||||
const enabled = settings?.dimensions_enabled === true
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let cancelled = false
|
||||
fetchDimensions()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setDims(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: without the registry the filter simply doesn't render.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
if (!enabled || dims.length === 0) return null
|
||||
|
||||
const activeDimNo = value?.dimNo ?? pendingDimNo
|
||||
const activeDim = dims.find((d) => String(d.sie_dim_no) === activeDimNo)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Dimension
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={activeDimNo}
|
||||
onValueChange={(dimNo) => {
|
||||
// Switching dimension clears the picked value — codes are
|
||||
// namespaced per dimension.
|
||||
if (value) onChange(null)
|
||||
setPendingDimNo(dimNo)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[170px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dims.map((d) => (
|
||||
<SelectItem key={d.sie_dim_no} value={String(d.sie_dim_no)}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="w-[200px]">
|
||||
<DimensionCombobox
|
||||
sieDimNo={activeDimNo}
|
||||
value={value?.code ?? null}
|
||||
onChange={(code) =>
|
||||
onChange(code ? { dimNo: activeDimNo, code } : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onChange(null)}
|
||||
aria-label="Rensa dimensionsfilter"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{value && (
|
||||
<Badge variant="warning" className="w-fit">
|
||||
Filtrerad: {activeDim?.name ?? `Dim ${value.dimNo}`} {value.code} — ej fullständig rapport
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
|
||||
import { DATE_RANGE_SLUGS, getReport } from '@/lib/reports/catalog'
|
||||
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
|
||||
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
|
||||
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
|
||||
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
|
||||
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
GeneralLedgerView,
|
||||
JournalRegisterView,
|
||||
ARLedgerView,
|
||||
DimensionPnlView,
|
||||
} from '@/components/reports/views'
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,7 @@ function FocusedReportInner({ slug }: { slug: string }) {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({})
|
||||
const [dimensionFilter, setDimensionFilter] = useState<DimensionFilterValue | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
const report = getReport(slug)
|
||||
@@ -101,6 +104,10 @@ function FocusedReportInner({ slug }: { slug: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{DIMENSION_FILTER_SLUGS.has(slug) && selectedPeriod && (
|
||||
<DimensionFilter value={dimensionFilter} onChange={setDimensionFilter} />
|
||||
)}
|
||||
|
||||
{!isReady && !isPeriodless ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
@@ -114,6 +121,7 @@ function FocusedReportInner({ slug }: { slug: string }) {
|
||||
periodId={selectedPeriod}
|
||||
periodBounds={selectedPeriodBounds}
|
||||
dateRange={dateRange}
|
||||
dimensionFilter={dimensionFilter}
|
||||
accountFilter={accountFilter}
|
||||
isEnskildFirma={isEnskildFirma}
|
||||
isAktiebolag={isAktiebolag}
|
||||
@@ -136,6 +144,7 @@ function FocusedView({
|
||||
periodId,
|
||||
periodBounds,
|
||||
dateRange,
|
||||
dimensionFilter,
|
||||
accountFilter,
|
||||
isEnskildFirma,
|
||||
isAktiebolag,
|
||||
@@ -145,6 +154,7 @@ function FocusedView({
|
||||
periodId: string
|
||||
periodBounds: { start: string; end: string } | null
|
||||
dateRange: DateRangeValue
|
||||
dimensionFilter: DimensionFilterValue | null
|
||||
accountFilter: string | null
|
||||
isEnskildFirma: boolean
|
||||
isAktiebolag: boolean
|
||||
@@ -152,13 +162,15 @@ function FocusedView({
|
||||
}) {
|
||||
switch (slug) {
|
||||
case 'resultatrapport':
|
||||
return <ResultatrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
return <ResultatrapportView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'dimension-pnl':
|
||||
return <DimensionPnlView periodId={periodId} dateRange={dateRange} />
|
||||
case 'balansrapport':
|
||||
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'trial-balance':
|
||||
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'income-statement':
|
||||
return <IncomeStatementView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
return <IncomeStatementView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'balance-sheet':
|
||||
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'vat-declaration':
|
||||
@@ -170,7 +182,7 @@ function FocusedView({
|
||||
case 'ink2-declaration':
|
||||
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
|
||||
case 'huvudbok':
|
||||
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} />
|
||||
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} />
|
||||
case 'grundbok':
|
||||
return <JournalRegisterView periodId={periodId} />
|
||||
case 'kundreskontra':
|
||||
|
||||
@@ -20,14 +20,16 @@ import type { EntityType } from '@/types'
|
||||
export function ReportLibrary({
|
||||
entityType,
|
||||
hasEmployees,
|
||||
dimensionsEnabled,
|
||||
onOpen,
|
||||
}: {
|
||||
entityType?: EntityType
|
||||
hasEmployees?: boolean
|
||||
dimensionsEnabled?: boolean
|
||||
onOpen: (slug: string) => void
|
||||
}) {
|
||||
const t = useTranslations('reports')
|
||||
const sections = getLibrarySections(entityType, hasEmployees)
|
||||
const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -29,12 +29,14 @@ import type {
|
||||
} from '@/lib/reports/source-lines'
|
||||
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
||||
import type { DateRangeValue } from '@/components/common/ReportDateRange'
|
||||
import type { DimensionFilterValue } from '@/components/reports/DimensionFilter'
|
||||
import type {
|
||||
TrialBalanceRow,
|
||||
IncomeStatementReport,
|
||||
BalanceSheetReport,
|
||||
ResultatrapportReport,
|
||||
BalansrapportReport,
|
||||
DimensionPnlReport,
|
||||
VatDeclaration,
|
||||
VatPeriodType,
|
||||
} from '@/types'
|
||||
@@ -43,10 +45,18 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function reportQuery(periodId: string, range?: DateRangeValue): string {
|
||||
function reportQuery(
|
||||
periodId: string,
|
||||
range?: DateRangeValue,
|
||||
dimensionFilter?: DimensionFilterValue | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({ period_id: periodId })
|
||||
if (range?.fromDate) params.set('from_date', range.fromDate)
|
||||
if (range?.toDate) params.set('to_date', range.toDate)
|
||||
if (dimensionFilter) {
|
||||
params.set('dim_no', dimensionFilter.dimNo)
|
||||
params.set('dim_code', dimensionFilter.code)
|
||||
}
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
@@ -369,13 +379,13 @@ function TrialBalanceDetailedRow({
|
||||
</>
|
||||
)
|
||||
}
|
||||
export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||||
export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
|
||||
const [data, setData] = useState<IncomeStatementReport | null>(null)
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
|
||||
const [monthlyLoading, setMonthlyLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const reportQs = reportQuery(periodId, dateRange)
|
||||
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -399,7 +409,9 @@ export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }
|
||||
|
||||
// Monthly breakdown is full-period by design (it IS the per-month view),
|
||||
// so the date range only affects the headline numbers above the chart.
|
||||
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
|
||||
// The dimension filter DOES apply — a dimension-scoped view must not
|
||||
// silently chart company-wide months.
|
||||
fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`)
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.data?.months) {
|
||||
@@ -648,11 +660,11 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||||
export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
|
||||
const [data, setData] = useState<ResultatrapportReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const reportQs = reportQuery(periodId, dateRange)
|
||||
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -1775,6 +1787,7 @@ interface GeneralLedgerData {
|
||||
debit: number
|
||||
credit: number
|
||||
balance: number
|
||||
dimensions?: Record<string, string>
|
||||
}[]
|
||||
closing_balance: number
|
||||
total_debit: number
|
||||
@@ -1783,7 +1796,7 @@ interface GeneralLedgerData {
|
||||
period: { start: string; end: string }
|
||||
}
|
||||
|
||||
export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) {
|
||||
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null }) {
|
||||
const [data, setData] = useState<GeneralLedgerData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -1799,6 +1812,10 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
|
||||
const params = new URLSearchParams({ period_id: periodId })
|
||||
if (from) params.set('account_from', from)
|
||||
if (to) params.set('account_to', to)
|
||||
if (dimensionFilter) {
|
||||
params.set('dim_no', dimensionFilter.dimNo)
|
||||
params.set('dim_code', dimensionFilter.code)
|
||||
}
|
||||
const res = await fetch(`/api/reports/general-ledger?${params}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
@@ -1811,7 +1828,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [periodId, accountFrom, accountTo])
|
||||
}, [periodId, accountFrom, accountTo, dimensionFilter])
|
||||
|
||||
// When initialAccountFilter changes (drill-down from another report), apply it
|
||||
useEffect(() => {
|
||||
@@ -1822,7 +1839,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
|
||||
} else {
|
||||
fetchData()
|
||||
}
|
||||
}, [periodId, initialAccountFilter])
|
||||
}, [periodId, initialAccountFilter, dimensionFilter])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -1857,7 +1874,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?period_id=${periodId}` }]} />
|
||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, undefined, dimensionFilter)}` }]} />
|
||||
{/* Account range filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
@@ -1931,7 +1948,17 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1.5">{formatDate(line.date)}</td>
|
||||
<td className="py-1.5 truncate max-w-[200px]">{line.description}</td>
|
||||
<td className="py-1.5 max-w-[240px]">
|
||||
<span className="truncate block">{line.description}</span>
|
||||
{line.dimensions && Object.keys(line.dimensions).length > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{Object.entries(line.dimensions)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, code]) => code)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||||
</td>
|
||||
@@ -2505,3 +2532,214 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Resultat per projekt/kostnadsställe (dimension P&L matrix) ---
|
||||
|
||||
export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) {
|
||||
// Loading is DERIVED (result key ≠ current query string) instead of a
|
||||
// setState at effect start — keeps react-hooks/set-state-in-effect clean
|
||||
// and is race-safe when the pivot/date changes mid-flight.
|
||||
const [result, setResult] = useState<{
|
||||
qs: string
|
||||
data: DimensionPnlReport | null
|
||||
error: string | null
|
||||
} | null>(null)
|
||||
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
|
||||
const [dimNo, setDimNo] = useState('6')
|
||||
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
|
||||
|
||||
// Registered dimensions for the pivot picker (best-effort; the report
|
||||
// defaults to projekt if the registry read fails).
|
||||
useEffect(() => {
|
||||
fetch('/api/dimensions')
|
||||
.then((res) => res.json())
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload.data)) {
|
||||
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name })))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/api/reports/dimension-pnl?${reportQs}`)
|
||||
.then((res) => res.json())
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
if (payload.error) {
|
||||
setResult({
|
||||
qs: reportQs,
|
||||
data: null,
|
||||
error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten',
|
||||
})
|
||||
} else {
|
||||
setResult({ qs: reportQs, data: payload.data, error: null })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reportQs])
|
||||
|
||||
const loading = result?.qs !== reportQs
|
||||
const error = loading ? null : result?.error ?? null
|
||||
const data = loading ? null : result?.data ?? null
|
||||
|
||||
const pivotPicker = dims.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{dims.map((d) => {
|
||||
const active = String(d.sie_dim_no) === dimNo
|
||||
return (
|
||||
<button
|
||||
key={d.sie_dim_no}
|
||||
type="button"
|
||||
onClick={() => setDimNo(String(d.sie_dim_no))}
|
||||
className={
|
||||
active
|
||||
? 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-secondary border-border text-foreground'
|
||||
: 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||||
}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pivotPicker}
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar rapport...
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pivotPicker}
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.groups.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pivotPicker}
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga taggade intäkter eller kostnader i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnLabel = (c: DimensionPnlReport['columns'][number]) =>
|
||||
c.code === null ? '(Utan dimension)' : c.code
|
||||
|
||||
const colCount = 2 + data.columns.length + 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{pivotPicker || <span />}
|
||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/dimension-pnl/xlsx?${reportQs}` }]} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||||
{data.columns.map((c, i) => (
|
||||
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
|
||||
{columnLabel(c)}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Totalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.groups.map((group) => (
|
||||
<React.Fragment key={group.class}>
|
||||
<tr className="bg-muted/30">
|
||||
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||||
{group.class_label}
|
||||
</td>
|
||||
</tr>
|
||||
{group.rows.map((row) => (
|
||||
<tr key={row.account_number} className="border-b last:border-0">
|
||||
<td className="px-4 py-1.5">
|
||||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||||
</td>
|
||||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||||
{row.values.map((v, i) => (
|
||||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">
|
||||
{Math.abs(v) >= 0.005 ? formatAmount(v) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-1.5 text-right tabular-nums font-medium">{formatAmount(row.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-b font-medium">
|
||||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||||
Summa
|
||||
</td>
|
||||
{group.subtotals.map((v, i) => (
|
||||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">{formatAmount(v)}</td>
|
||||
))}
|
||||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_total)}</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-4 font-bold text-lg">Beräknat resultat</td>
|
||||
<td className="px-4" />
|
||||
{data.net_per_column.map((v, i) => (
|
||||
<td key={i} className={`px-4 text-right tabular-nums font-semibold w-32 ${v >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(v)}
|
||||
</td>
|
||||
))}
|
||||
<td className={`px-4 text-right tabular-nums font-bold text-lg w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_total)} kr
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -209,6 +209,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_list_dimensions: 'reports:read',
|
||||
gnubok_list_dimension_values: 'reports:read',
|
||||
gnubok_create_dimension_value: 'bookkeeping:write',
|
||||
gnubok_get_dimension_pnl: 'reports:read',
|
||||
// Document inbox
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { REPORT_CATALOG, DIMENSION_FILTER_SLUGS } from '../catalog'
|
||||
|
||||
// ============================================================
|
||||
// Statutory exclusion guard (dimensions PR4).
|
||||
//
|
||||
// A dimension-filtered statutory output is a WRONG output: a filtered
|
||||
// balance sheet doesn't balance, a filtered VAT declaration under-reports,
|
||||
// a filtered SIE export is not the company's bokföring. The whitelist of
|
||||
// filterable reports is therefore pinned by TEST, not by convention — this
|
||||
// suite fails when the filter leaks into a statutory report route or
|
||||
// generator, or when someone widens the catalog whitelist without touching
|
||||
// this file.
|
||||
// ============================================================
|
||||
|
||||
const ROOT = process.cwd()
|
||||
|
||||
/** The only reports allowed to accept the dimension value filter. */
|
||||
const FILTERABLE_SLUGS = ['resultatrapport', 'income-statement', 'huvudbok', 'kpi']
|
||||
|
||||
/** Routes allowed to import the route-side filter parser. */
|
||||
const ALLOWED_PARSER_IMPORTERS = new Set([
|
||||
'app/api/reports/resultatrapport/route.ts',
|
||||
'app/api/reports/resultatrapport/xlsx/route.ts',
|
||||
'app/api/reports/resultatrapport/pdf/route.ts',
|
||||
'app/api/reports/income-statement/route.ts',
|
||||
'app/api/reports/income-statement/xlsx/route.ts',
|
||||
'app/api/reports/income-statement/pdf/route.ts',
|
||||
'app/api/reports/general-ledger/route.ts',
|
||||
'app/api/reports/general-ledger/xlsx/route.ts',
|
||||
'app/api/reports/kpi/route.ts',
|
||||
'app/api/reports/monthly-breakdown/route.ts',
|
||||
'app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts',
|
||||
])
|
||||
|
||||
/** Statutory generators that must never gain a containment filter. */
|
||||
const STATUTORY_GENERATORS = [
|
||||
'lib/reports/balance-sheet.ts',
|
||||
'lib/reports/balansrapport.ts',
|
||||
'lib/reports/kassaflodesanalys.ts',
|
||||
'lib/reports/vat-declaration.ts',
|
||||
'lib/reports/sie-export.ts',
|
||||
'lib/reports/full-archive-export.ts',
|
||||
]
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry)
|
||||
if (statSync(full).isDirectory()) walk(full, out)
|
||||
else if (full.endsWith('.ts') || full.endsWith('.tsx')) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('dimension filter — statutory exclusion', () => {
|
||||
it('the catalog whitelist is exactly the four P&L-safe reports', () => {
|
||||
const flagged = REPORT_CATALOG.filter((r) => r.dimensions).map((r) => r.slug).sort()
|
||||
expect(flagged).toEqual([...FILTERABLE_SLUGS].sort())
|
||||
expect([...DIMENSION_FILTER_SLUGS].sort()).toEqual([...FILTERABLE_SLUGS].sort())
|
||||
})
|
||||
|
||||
it('the dimension-pnl report is gated on dimensions being enabled, never entity/employees', () => {
|
||||
const entry = REPORT_CATALOG.find((r) => r.slug === 'dimension-pnl')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry?.needsDimensions).toBe(true)
|
||||
// Free tier for everyone (founder decision 2026-07-02) — no other gate.
|
||||
expect(entry?.entityType).toBeUndefined()
|
||||
expect(entry?.needsEmployees).toBeUndefined()
|
||||
})
|
||||
|
||||
it('no statutory report route imports the dimension filter parser', () => {
|
||||
const reportRoutes = walk(join(ROOT, 'app/api/reports'))
|
||||
const importers = reportRoutes
|
||||
.filter((f) => readFileSync(f, 'utf8').includes('lib/reports/dimension-filter'))
|
||||
.map((f) => f.slice(ROOT.length + 1))
|
||||
.sort()
|
||||
|
||||
// Exactly the P&L-safe routes — nothing more (statutory leak), nothing
|
||||
// less (a whitelisted route silently dropping the filter would show an
|
||||
// unfiltered report under a "Filtrerad" chip).
|
||||
expect(importers).toEqual([...ALLOWED_PARSER_IMPORTERS].sort())
|
||||
})
|
||||
|
||||
it('statutory generators never apply a dimensions containment filter', () => {
|
||||
for (const rel of STATUTORY_GENERATORS) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8')
|
||||
expect(src, `${rel} must not filter on line dimensions`).not.toMatch(
|
||||
/contains\(\s*['"]dimensions['"]/,
|
||||
)
|
||||
expect(src, `${rel} must not accept a dimensionFilter/dimensions option`).not.toMatch(
|
||||
/dimensionFilter|options\?\.dimensions/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('statutory generators do not receive dimensions through generateTrialBalance', () => {
|
||||
// They may call generateTrialBalance, but never with a dimensions option.
|
||||
// The scan is paren-aware (walks to the call's closing paren), not a
|
||||
// fixed character window — a long options object cannot slip the key
|
||||
// past the guard (#862 review).
|
||||
for (const rel of STATUTORY_GENERATORS) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8')
|
||||
let idx = src.indexOf('generateTrialBalance(')
|
||||
while (idx !== -1) {
|
||||
const argsStart = idx + 'generateTrialBalance('.length
|
||||
let depth = 1
|
||||
let end = argsStart
|
||||
while (end < src.length && depth > 0) {
|
||||
if (src[end] === '(') depth++
|
||||
else if (src[end] === ')') depth--
|
||||
end++
|
||||
}
|
||||
const argList = src.slice(argsStart, end)
|
||||
expect(argList, `${rel} passes dimensions to generateTrialBalance`).not.toContain(
|
||||
'dimensions',
|
||||
)
|
||||
idx = src.indexOf('generateTrialBalance(', end)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ============================================================
|
||||
// Dimension-filtered trial balance (dimensions PR4).
|
||||
//
|
||||
// Table-keyed FIFO mock like trial-balance.test.ts, extended with
|
||||
// `contains`/`not` and per-table call capture so the jsonb containment
|
||||
// pushdown is assertable.
|
||||
// ============================================================
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
let mockResults: Record<string, MockResult[]>
|
||||
let containsCalls: { table: string; column: string; value: unknown }[]
|
||||
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'not', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.contains = vi.fn().mockImplementation((column: string, value: unknown) => {
|
||||
containsCalls.push({ table: tableName, column, value })
|
||||
return 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)),
|
||||
rpc: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
vi.mock('../opening-balances', () => ({
|
||||
getOpeningBalances: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateTrialBalance } from '../trial-balance'
|
||||
import { getOpeningBalances } from '../opening-balances'
|
||||
|
||||
const mockOpeningBalances = vi.mocked(getOpeningBalances)
|
||||
|
||||
let supabase: ReturnType<typeof makeClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResults = {}
|
||||
containsCalls = []
|
||||
supabase = makeClient()
|
||||
})
|
||||
|
||||
const PERIOD = { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null }
|
||||
|
||||
function seedCommon() {
|
||||
mockResults = {
|
||||
fiscal_periods: [{ data: PERIOD, error: null }],
|
||||
journal_entry_lines: [
|
||||
{
|
||||
data: [
|
||||
{ id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
{ id: 'l2', account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
||||
{ account_number: '3001', account_name: 'Försäljning', account_class: 3 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateTrialBalance — dimensions option', () => {
|
||||
it('pushes the filter down as jsonb containment on the line query', async () => {
|
||||
seedCommon()
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
expect(containsCalls).toEqual([
|
||||
{ table: 'journal_entry_lines', column: 'dimensions', value: { '6': 'P001' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not touch the query when no filter is passed (back-compat)', async () => {
|
||||
seedCommon()
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(containsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an empty filter object as no filter', async () => {
|
||||
seedCommon()
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', { dimensions: {} })
|
||||
|
||||
expect(containsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('drops company-wide opening balances when filtered — amounts are dimension-scoped activity only', async () => {
|
||||
seedCommon()
|
||||
// Company-wide IB on 1930 that CANNOT be dimension-scoped.
|
||||
mockOpeningBalances.mockResolvedValue({
|
||||
balances: new Map([['1930', { debit: 9000, credit: 0 }]]),
|
||||
obEntryId: null,
|
||||
})
|
||||
|
||||
const filtered = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
const bank = filtered.rows.find((r) => r.account_number === '1930')
|
||||
expect(bank?.opening_debit).toBe(0)
|
||||
expect(bank?.closing_debit).toBe(500) // period activity only
|
||||
|
||||
// Unfiltered keeps the IB (control).
|
||||
seedCommon()
|
||||
const unfiltered = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const bank2 = unfiltered.rows.find((r) => r.account_number === '1930')
|
||||
expect(bank2?.opening_debit).toBe(9000)
|
||||
expect(bank2?.closing_debit).toBe(9500)
|
||||
})
|
||||
|
||||
it('applies the filter to the IB roll-forward query too (fromDate sub-range)', async () => {
|
||||
seedCommon()
|
||||
// Roll-forward query consumes the first journal_entry_lines result; add a
|
||||
// second for the period query.
|
||||
mockResults.journal_entry_lines.push({ data: [], error: null })
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
fromDate: '2026-06-01',
|
||||
dimensions: { '1': 'KS01' },
|
||||
})
|
||||
|
||||
// Both line queries (roll-forward + period) carry the containment filter.
|
||||
expect(containsCalls).toHaveLength(2)
|
||||
expect(containsCalls.every((c) => c.column === 'dimensions')).toBe(true)
|
||||
expect(containsCalls.every((c) => JSON.stringify(c.value) === '{"1":"KS01"}')).toBe(true)
|
||||
})
|
||||
})
|
||||
+42
-3
@@ -59,6 +59,16 @@ export interface ReportDescriptor {
|
||||
* Used for reports that were never in the nav (KPI, payroll, archive…).
|
||||
*/
|
||||
libraryOnly?: boolean
|
||||
/**
|
||||
* Accepts the per-dimension value filter (?dim_no/&dim_code → jsonb @>).
|
||||
* P&L-safe reports ONLY — statutory outputs (balance sheet, balansrapport,
|
||||
* kassaflöde, årsredovisning, INK2, NE, VAT, SIE) must never carry this
|
||||
* flag; a filtered filing is a wrong filing. The whitelist is pinned by
|
||||
* lib/reports/__tests__/dimension-statutory-guard.test.ts.
|
||||
*/
|
||||
dimensions?: boolean
|
||||
/** Only shown when company_settings.dimensions_enabled is true. */
|
||||
needsDimensions?: boolean
|
||||
}
|
||||
|
||||
/** Categories shown in the legacy desktop rail, in order. */
|
||||
@@ -101,6 +111,18 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
category: 'interim',
|
||||
params: 'fiscal-range',
|
||||
exports: ['pdf', 'xlsx'],
|
||||
dimensions: true,
|
||||
},
|
||||
{
|
||||
// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over
|
||||
// one SIE dimension (Fortnox "Resultatrapport projekt").
|
||||
slug: 'dimension-pnl',
|
||||
labelKey: 'name_dimension_pnl',
|
||||
descKey: 'desc_dimension_pnl',
|
||||
category: 'interim',
|
||||
params: 'fiscal-range',
|
||||
exports: ['xlsx'],
|
||||
needsDimensions: true,
|
||||
},
|
||||
{
|
||||
slug: 'balansrapport',
|
||||
@@ -126,6 +148,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
params: 'fiscal',
|
||||
route: '/kpi',
|
||||
libraryOnly: true,
|
||||
dimensions: true,
|
||||
},
|
||||
|
||||
// --- Bokslut (year-end) ---
|
||||
@@ -147,6 +170,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
category: 'year_end',
|
||||
params: 'fiscal-range',
|
||||
exports: ['pdf', 'xlsx'],
|
||||
dimensions: true,
|
||||
},
|
||||
{
|
||||
slug: 'balance-sheet',
|
||||
@@ -215,6 +239,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
category: 'ledgers',
|
||||
params: 'fiscal',
|
||||
exports: ['xlsx'],
|
||||
dimensions: true,
|
||||
},
|
||||
{
|
||||
slug: 'grundbok',
|
||||
@@ -272,6 +297,11 @@ export const DATE_RANGE_SLUGS: ReadonlySet<string> = new Set(
|
||||
REPORT_CATALOG.filter((r) => r.params === 'fiscal-range').map((r) => r.slug),
|
||||
)
|
||||
|
||||
/** Reports that accept the per-dimension value filter (mounts DimensionFilter). */
|
||||
export const DIMENSION_FILTER_SLUGS: ReadonlySet<string> = new Set(
|
||||
REPORT_CATALOG.filter((r) => r.dimensions).map((r) => r.slug),
|
||||
)
|
||||
|
||||
export function getReport(slug: string): ReportDescriptor | undefined {
|
||||
return REPORT_CATALOG.find((r) => r.slug === slug)
|
||||
}
|
||||
@@ -280,9 +310,11 @@ function isVisible(
|
||||
r: ReportDescriptor,
|
||||
entityType?: EntityType,
|
||||
hasEmployees?: boolean,
|
||||
dimensionsEnabled?: boolean,
|
||||
): boolean {
|
||||
if (r.entityType && r.entityType !== entityType) return false
|
||||
if (r.needsEmployees && !hasEmployees) return false
|
||||
if (r.needsDimensions && !dimensionsEnabled) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -293,12 +325,18 @@ export interface ReportSection {
|
||||
}
|
||||
|
||||
/** Grouped reports for the legacy desktop rail (excludes library-only items). */
|
||||
export function getNavSections(entityType?: EntityType): ReportSection[] {
|
||||
export function getNavSections(
|
||||
entityType?: EntityType,
|
||||
dimensionsEnabled?: boolean,
|
||||
): ReportSection[] {
|
||||
return NAV_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
labelKey: CATEGORY_LABEL_KEY[category],
|
||||
items: REPORT_CATALOG.filter(
|
||||
(r) => r.category === category && !r.libraryOnly && isVisible(r, entityType),
|
||||
(r) =>
|
||||
r.category === category &&
|
||||
!r.libraryOnly &&
|
||||
isVisible(r, entityType, undefined, dimensionsEnabled),
|
||||
),
|
||||
})).filter((s) => s.items.length > 0)
|
||||
}
|
||||
@@ -307,12 +345,13 @@ export function getNavSections(entityType?: EntityType): ReportSection[] {
|
||||
export function getLibrarySections(
|
||||
entityType?: EntityType,
|
||||
hasEmployees?: boolean,
|
||||
dimensionsEnabled?: boolean,
|
||||
): ReportSection[] {
|
||||
return LIBRARY_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
labelKey: CATEGORY_LABEL_KEY[category],
|
||||
items: REPORT_CATALOG.filter(
|
||||
(r) => r.category === category && isVisible(r, entityType, hasEmployees),
|
||||
(r) => r.category === category && isVisible(r, entityType, hasEmployees, dimensionsEnabled),
|
||||
),
|
||||
})).filter((s) => s.items.length > 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { slugifyCompanyName } from './xlsx-export'
|
||||
|
||||
/**
|
||||
* Parse the report-route dimension filter pair (?dim_no=6&dim_code=P001)
|
||||
* into the `dimensions` option the report generators accept.
|
||||
*
|
||||
* Absent params are fine (unfiltered report). A half-provided pair or a
|
||||
* value that fails DimensionsBagSchema (SIE framing charset, length) is a
|
||||
* 400 — never silently ignored, or the user would read an unfiltered report
|
||||
* as a filtered one.
|
||||
*
|
||||
* IMPORTANT: only the P&L-safe report routes may import this helper
|
||||
* (resultatrapport, income-statement, general-ledger, kpi, dimension-pnl,
|
||||
* monthly-breakdown). Statutory outputs (balance sheet, balansrapport,
|
||||
* kassaflöde, årsredovisning, INK2, NE-bilaga, VAT declaration, SIE export)
|
||||
* must never accept a dimension filter — a filtered filing is a wrong
|
||||
* filing. The whitelist is pinned by lib/reports/__tests__/
|
||||
* dimension-statutory-guard.test.ts, which fails if this import shows up in
|
||||
* a statutory route.
|
||||
*/
|
||||
export function parseDimensionFilterParams(searchParams: URLSearchParams):
|
||||
| { ok: true; dimensions?: Record<string, string> }
|
||||
| { ok: false; error: string } {
|
||||
const dimNo = searchParams.get('dim_no')
|
||||
const dimCode = searchParams.get('dim_code')
|
||||
|
||||
if (dimNo === null && dimCode === null) {
|
||||
return { ok: true }
|
||||
}
|
||||
if (!dimNo || !dimCode) {
|
||||
return { ok: false, error: 'dim_no and dim_code must be provided together' }
|
||||
}
|
||||
|
||||
const parsed = DimensionsBagSchema.safeParse({ [dimNo]: dimCode })
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: 'Invalid dimension filter' }
|
||||
}
|
||||
return { ok: true, dimensions: parsed.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Filename suffix for a dimension-filtered export ('' when unfiltered).
|
||||
* A filtered file must not share its name with the authoritative report —
|
||||
* BFL 5 kap / BFNAR 2013:2: what a report covers must be identifiable.
|
||||
* Example: { "6": "P001" } → "-dim6-p001".
|
||||
*/
|
||||
export function dimensionFilterFileSuffix(dimensions?: Record<string, string>): string {
|
||||
if (!dimensions || Object.keys(dimensions).length === 0) return ''
|
||||
return Object.entries(dimensions)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([dimNo, code]) => {
|
||||
const slug = slugifyCompanyName(code)
|
||||
return slug === 'foretag' ? `-dim${dimNo}` : `-dim${dimNo}-${slug}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable partial-view disclosure for inside exported files, or null
|
||||
* when unfiltered. Swedish only — report surface.
|
||||
*/
|
||||
export function dimensionFilterDisclosure(dimensions?: Record<string, string>): string | null {
|
||||
if (!dimensions || Object.keys(dimensions).length === 0) return null
|
||||
const parts = Object.entries(dimensions)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([dimNo, code]) => `dimension ${dimNo}: ${code}`)
|
||||
return `Filtrerad (${parts.join(', ')}) — ej fullständig rapport`
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { generateTrialBalance } from './trial-balance'
|
||||
import type {
|
||||
DimensionPnlColumn,
|
||||
DimensionPnlGroup,
|
||||
DimensionPnlReport,
|
||||
DimensionPnlRow,
|
||||
TrialBalanceRow,
|
||||
} from '@/types'
|
||||
|
||||
// Same class labels as resultatrapport — the report is its per-dimension
|
||||
// sibling and must read identically. Stays-Swedish surface (report labels).
|
||||
const CLASS_LABELS: Record<number, string> = {
|
||||
3: '3 Rörelsens inkomster/intäkter',
|
||||
4: '4 Material- och varukostnader',
|
||||
5: '5 Övriga externa kostnader',
|
||||
6: '6 Övriga externa kostnader',
|
||||
7: '7 Personalkostnader',
|
||||
8: '8 Finansiella poster och bokslutsdispositioner',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resultat per projekt / kostnadsställe (Fortnox "Resultatrapport projekt").
|
||||
*
|
||||
* Value-as-column P&L matrix over ONE SIE dimension: every registered value
|
||||
* with activity becomes a column, plus an explicit "(Utan dimension)" bucket.
|
||||
*
|
||||
* Reconciliation is by construction, not by convention: the Totalt column
|
||||
* comes from the SAME unfiltered generateTrialBalance pass resultatrapport
|
||||
* uses (same options, same filterPnl scope, same sign convention), and the
|
||||
* untagged bucket is the residual Totalt − tagged columns. Columns therefore
|
||||
* always sum exactly to the unfiltered resultatrapport — including edge cases
|
||||
* the line pass cannot see (e.g. P&L opening remnants when a prior year was
|
||||
* never closed), which land in "(Utan dimension)" where they belong.
|
||||
*/
|
||||
export async function generateDimensionPnl(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
sieDimNo: string,
|
||||
// No fromDate: the matrix uses closing-balance semantics (cumulative from
|
||||
// period_start) to reconcile with resultatrapport, so a lower bound cannot
|
||||
// be honoured — accepting one and labelling the report with it would be a
|
||||
// lie (#862 review). toDate caps the window on both sides identically.
|
||||
options?: { toDate?: string }
|
||||
): Promise<DimensionPnlReport> {
|
||||
// The dim number is interpolated into a PostgREST jsonb path expression
|
||||
// below (`dimensions->>N`). Both entry points (route, MCP tool) validate,
|
||||
// but the generator is exported — guard here too so no future caller can
|
||||
// smuggle filter syntax through.
|
||||
if (!/^[1-9]\d{0,3}$/.test(sieDimNo)) {
|
||||
throw new Error('sieDimNo must be a positive SIE dimension number')
|
||||
}
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// ── Totalt column: identical inputs to resultatrapport ─────────
|
||||
const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
const pnlRows = filterPnl(tb.rows)
|
||||
const totalByAccount = new Map<string, TrialBalanceRow>()
|
||||
for (const r of pnlRows) totalByAccount.set(r.account_number, r)
|
||||
|
||||
// ── Registry names for column headers (read-only — never seeds) ─
|
||||
const { data: dimRow } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sie_dim_no', Number(sieDimNo))
|
||||
.maybeSingle()
|
||||
|
||||
const valueNames = new Map<string, string>()
|
||||
if (dimRow) {
|
||||
const values = await fetchAllRows<{ code: string; name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('dimension_values')
|
||||
.select('code, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('dimension_id', dimRow.id)
|
||||
.order('code', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
for (const v of values) valueNames.set(v.code, v.name)
|
||||
}
|
||||
|
||||
// ── Tagged lines: one pass over lines carrying this dimension ──
|
||||
// Mirrors trial-balance closing semantics: the fiscal_period_id join scopes
|
||||
// to the period and toDate caps the window — both sides of the matrix
|
||||
// cover period_start..toDate, so the buckets sum to the Totalt column.
|
||||
const taggedLines = await fetchAllRows<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions: Record<string, string>
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
'id, account_number, debit_amount, credit_amount, dimensions, journal_entries!inner(company_id, fiscal_period_id, status, entry_date)'
|
||||
)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
// Key-existence via the extracted text field — dims 1/6 ride the partial
|
||||
// expression indexes (idx_jel_dimensions_dim1/dim6).
|
||||
.not(`dimensions->>${sieDimNo}`, 'is', null)
|
||||
|
||||
if (options?.toDate) {
|
||||
query = query.lte('journal_entries.entry_date', options.toDate)
|
||||
}
|
||||
|
||||
// Stable total order on the line PK for correct paging (see fetch-all.ts).
|
||||
return query.order('id', { ascending: true }).range(from, to)
|
||||
}, { dedupeBy: (r) => r.id })
|
||||
|
||||
// Bucket raw amounts per (account, code). Only accounts present in the P&L
|
||||
// trial-balance scope count — anything else (balance accounts, 8999) is out.
|
||||
const buckets = new Map<string, Map<string, { debit: number; credit: number }>>()
|
||||
const codesSeen = new Set<string>()
|
||||
for (const line of taggedLines) {
|
||||
if (!totalByAccount.has(line.account_number)) continue
|
||||
const code = normalizeCode(line.dimensions?.[sieDimNo])
|
||||
if (!code) continue
|
||||
codesSeen.add(code)
|
||||
const byCode = buckets.get(line.account_number) ?? new Map()
|
||||
const agg = byCode.get(code) ?? { debit: 0, credit: 0 }
|
||||
agg.debit += Number(line.debit_amount) || 0
|
||||
agg.credit += Number(line.credit_amount) || 0
|
||||
byCode.set(code, agg)
|
||||
buckets.set(line.account_number, byCode)
|
||||
}
|
||||
|
||||
const codes = [...codesSeen].sort((a, b) => a.localeCompare(b, 'sv'))
|
||||
|
||||
// ── Matrix rows: tagged columns + untagged residual + Totalt ───
|
||||
// Per account: values[i] = round2(signed bucket), untagged = round2(total −
|
||||
// Σ rounded tagged) so the row sums exactly; total = signedAmount(tb row),
|
||||
// the very number resultatrapport renders for the account.
|
||||
type AccountRow = DimensionPnlRow & { account_class: number }
|
||||
const accountRows: AccountRow[] = []
|
||||
let anyUntagged = false
|
||||
|
||||
for (const tbRow of pnlRows) {
|
||||
const total = round2(signedAmount(tbRow))
|
||||
const byCode = buckets.get(tbRow.account_number)
|
||||
const tagged = codes.map((code) => {
|
||||
const agg = byCode?.get(code)
|
||||
return agg ? round2(agg.credit - agg.debit) : 0
|
||||
})
|
||||
const untagged = round2(total - tagged.reduce((s, v) => s + v, 0))
|
||||
if (Math.abs(untagged) >= 0.005) anyUntagged = true
|
||||
|
||||
const values = [...tagged, untagged]
|
||||
if (Math.abs(total) < 0.005 && values.every((v) => Math.abs(v) < 0.005)) continue
|
||||
|
||||
accountRows.push({
|
||||
account_number: tbRow.account_number,
|
||||
account_name: tbRow.account_name,
|
||||
account_class: tbRow.account_class,
|
||||
values,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
// Drop the untagged column when everything is tagged.
|
||||
const columnCount = codes.length + (anyUntagged ? 1 : 0)
|
||||
if (!anyUntagged) {
|
||||
for (const row of accountRows) row.values = row.values.slice(0, codes.length)
|
||||
}
|
||||
|
||||
const columns: DimensionPnlColumn[] = [
|
||||
...codes.map((code) => ({ code, name: valueNames.get(code) ?? null })),
|
||||
...(anyUntagged ? [{ code: null, name: null }] : []),
|
||||
]
|
||||
|
||||
// ── Groups by class, resultatrapport-style ──────────────────────
|
||||
const groups: DimensionPnlGroup[] = []
|
||||
for (const klass of [3, 4, 5, 6, 7, 8] as const) {
|
||||
const rows = accountRows
|
||||
.filter((r) => r.account_class === klass)
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
if (rows.length === 0) continue
|
||||
|
||||
const subtotals = Array.from({ length: columnCount }, (_, i) =>
|
||||
round2(rows.reduce((s, r) => s + r.values[i], 0))
|
||||
)
|
||||
groups.push({
|
||||
class: klass,
|
||||
class_label: CLASS_LABELS[klass],
|
||||
rows: rows.map(({ account_class: _klass, ...row }) => row),
|
||||
subtotals,
|
||||
subtotal_total: round2(rows.reduce((s, r) => s + r.total, 0)),
|
||||
})
|
||||
}
|
||||
|
||||
const netPerColumn = Array.from({ length: columnCount }, (_, i) =>
|
||||
round2(accountRows.reduce((s, r) => s + r.values[i], 0))
|
||||
)
|
||||
// Same aggregation as resultatrapport's net_result_current: sum of the
|
||||
// per-account rounded signed amounts over the filterPnl scope.
|
||||
const netTotal = round2(pnlRows.reduce((s, r) => s + round2(signedAmount(r)), 0))
|
||||
|
||||
return {
|
||||
dimension: {
|
||||
sie_dim_no: sieDimNo,
|
||||
name: dimRow?.name ?? defaultDimensionName(sieDimNo),
|
||||
},
|
||||
columns,
|
||||
groups,
|
||||
net_per_column: netPerColumn,
|
||||
net_total: netTotal,
|
||||
// The label reflects actual coverage: always cumulative from
|
||||
// period_start (closing-balance semantics), capped at toDate.
|
||||
period: {
|
||||
start: period.period_start,
|
||||
end: options?.toDate ?? period.period_end,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
|
||||
return rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8 && r.account_number !== '8999'
|
||||
)
|
||||
}
|
||||
|
||||
// credit − debit: revenue positive, expenses negative — resultatrapport's
|
||||
// exact sign convention, so cells compare 1:1 with that report.
|
||||
function signedAmount(row: TrialBalanceRow): number {
|
||||
return row.closing_credit - row.closing_debit
|
||||
}
|
||||
|
||||
// Canonical form matching normalizeLineDimensions: trimmed, non-empty.
|
||||
function normalizeCode(raw: string | undefined): string | null {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : ''
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function defaultDimensionName(sieDimNo: string): string {
|
||||
if (sieDimNo === '1') return 'Kostnadsställe'
|
||||
if (sieDimNo === '6') return 'Projekt'
|
||||
return `Dimension ${sieDimNo}`
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return roundOre(n)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface GeneralLedgerLine {
|
||||
debit: number
|
||||
credit: number
|
||||
balance: number
|
||||
/** SIE dim → code tags on the line; omitted when untagged. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GeneralLedgerAccount {
|
||||
@@ -49,8 +51,17 @@ export async function generateGeneralLedger(
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
accountFrom?: string,
|
||||
accountTo?: string
|
||||
accountTo?: string,
|
||||
options?: {
|
||||
/** SIE dim → code filter ({"6":"P001"}). Opening balances are dropped
|
||||
* when set — they are company-wide and cannot be dimension-scoped. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
): Promise<GeneralLedgerReport> {
|
||||
const dimensionFilter =
|
||||
options?.dimensions && Object.keys(options.dimensions).length > 0
|
||||
? options.dimensions
|
||||
: undefined
|
||||
|
||||
// Get fiscal period dates and opening_balance_entry_id
|
||||
const { data: period } = await supabase
|
||||
@@ -71,8 +82,10 @@ export async function generateGeneralLedger(
|
||||
|
||||
// Convert to net balance (debit - credit) for GL running balance
|
||||
const openingBalances = new Map<string, number>()
|
||||
for (const [accNum, { debit, credit }] of openingByAccount) {
|
||||
openingBalances.set(accNum, debit - credit)
|
||||
if (!dimensionFilter) {
|
||||
for (const [accNum, { debit, credit }] of openingByAccount) {
|
||||
openingBalances.set(accNum, debit - credit)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Period lines via joined query (excluding OB entry) ─────────
|
||||
@@ -88,6 +101,7 @@ export async function generateGeneralLedger(
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
journal_entry_id: string
|
||||
dimensions: Record<string, string> | null
|
||||
journal_entries: {
|
||||
entry_date: string
|
||||
voucher_number: number
|
||||
@@ -98,11 +112,16 @@ export async function generateGeneralLedger(
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)')
|
||||
.select('id, account_number, debit_amount, credit_amount, journal_entry_id, dimensions, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
if (dimensionFilter) {
|
||||
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', dimensionFilter)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('journal_entry_id', obEntryId)
|
||||
}
|
||||
@@ -144,6 +163,8 @@ export async function generateGeneralLedger(
|
||||
accountLines.set(accNum, [])
|
||||
}
|
||||
|
||||
const hasDims = line.dimensions && Object.keys(line.dimensions).length > 0
|
||||
|
||||
accountLines.get(accNum)!.push({
|
||||
date: entry.entry_date,
|
||||
voucher_series: entry.voucher_series || 'A',
|
||||
@@ -154,6 +175,7 @@ export async function generateGeneralLedger(
|
||||
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
|
||||
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
|
||||
balance: 0, // computed below
|
||||
...(hasDims ? { dimensions: line.dimensions as Record<string, string> } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ export async function generateIncomeStatement(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
options?: {
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
/** SIE dim → code filter ({"6":"P001"}). P&L-safe: see trial-balance.ts. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
): Promise<IncomeStatementReport> {
|
||||
// Exclude year-end closing entries: after closing, P&L accounts (3-8) are
|
||||
// zeroed by the closing verifikat (8999 → 2099). Including them collapses
|
||||
@@ -25,6 +30,7 @@ export async function generateIncomeStatement(
|
||||
excludeYearEndClosing: true,
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
dimensions: options?.dimensions,
|
||||
})
|
||||
|
||||
// Filter to income/expense accounts (class 3-8)
|
||||
|
||||
@@ -27,7 +27,12 @@ const MONTH_LABELS = [
|
||||
export async function generateMonthlyBreakdown(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
fiscalPeriodId: string,
|
||||
options?: {
|
||||
/** SIE dim → code filter ({"6":"P001"}). Without it a dimension-scoped
|
||||
* KPI view would silently chart company-wide months. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
): Promise<MonthlyBreakdown> {
|
||||
|
||||
// Get the fiscal period date range
|
||||
@@ -46,8 +51,8 @@ export async function generateMonthlyBreakdown(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let lines: any[]
|
||||
try {
|
||||
lines = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
lines = await fetchAllRows(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
@@ -63,10 +68,15 @@ export async function generateMonthlyBreakdown(
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (options?.dimensions && Object.keys(options.dimensions).length > 0) {
|
||||
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', options.dimensions)
|
||||
}
|
||||
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
return query.order('id', { ascending: true }).range(from, to)
|
||||
})
|
||||
} catch {
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
@@ -252,9 +252,11 @@ interface CommonHeaderProps {
|
||||
title: string
|
||||
company: CompanySettings
|
||||
period: { start: string; end: string }
|
||||
/** Partial-view disclosure (dimension-filtered exports, BFNAR 2013:2). */
|
||||
filterNote?: string
|
||||
}
|
||||
|
||||
function HeaderBlock({ title, company, period }: CommonHeaderProps) {
|
||||
function HeaderBlock({ title, company, period, filterNote }: CommonHeaderProps) {
|
||||
const companyDisplayName = company.company_name || ''
|
||||
const periodLabel = period.start && period.end
|
||||
? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}`
|
||||
@@ -269,6 +271,9 @@ function HeaderBlock({ title, company, period }: CommonHeaderProps) {
|
||||
{periodLabel && (
|
||||
<Text style={styles.period}>Period: {periodLabel}</Text>
|
||||
)}
|
||||
{filterNote && (
|
||||
<Text style={styles.period}>{filterNote}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{company.company_name && (
|
||||
@@ -307,15 +312,17 @@ interface ResultatrapportPDFProps {
|
||||
report: ResultatrapportReport
|
||||
company: CompanySettings
|
||||
generatedAt: string
|
||||
/** Partial-view disclosure line (dimension-filtered exports). */
|
||||
filterNote?: string
|
||||
}
|
||||
|
||||
export function ResultatrapportPDF({ report, company, generatedAt }: ResultatrapportPDFProps) {
|
||||
export function ResultatrapportPDF({ report, company, generatedAt, filterNote }: ResultatrapportPDFProps) {
|
||||
const hasPrior = report.prior_period !== null
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<HeaderBlock title="Resultatrapport" company={company} period={report.period} />
|
||||
<HeaderBlock title="Resultatrapport" company={company} period={report.period} filterNote={filterNote} />
|
||||
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
|
||||
|
||||
@@ -32,7 +32,12 @@ export async function generateResultatrapport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
options?: {
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
/** SIE dim → code filter ({"6":"P001"}). P&L-safe: see trial-balance.ts. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
): Promise<ResultatrapportReport> {
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -51,6 +56,7 @@ export async function generateResultatrapport(
|
||||
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
dimensions: options?.dimensions,
|
||||
})
|
||||
const currentRows = filterPnl(currentTb.rows)
|
||||
|
||||
@@ -58,9 +64,13 @@ export async function generateResultatrapport(
|
||||
// compared against a full prior year would be misleading; until we ship a
|
||||
// proper "same window, prior year" comparison the cleanest move is to
|
||||
// drop the prior column entirely when the user narrows the range.
|
||||
// Same rule for a dimension filter: project codes are time-limited under
|
||||
// K2/K3 (registry start/end dates), so "this code last year" may be a
|
||||
// different project entirely — drop the column rather than compare
|
||||
// unrelated activity (#862 review).
|
||||
let priorRows: TrialBalanceRow[] = []
|
||||
let priorPeriodInfo: { start: string; end: string } | null = null
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate && !options?.dimensions
|
||||
if (isFullPeriod) {
|
||||
// Prefer the explicit continuity chain; fall back to the period that ends
|
||||
// immediately before this one. The fallback keeps the comparison working
|
||||
|
||||
@@ -20,6 +20,8 @@ export type ReportSourceLine = {
|
||||
description: string
|
||||
debit: number
|
||||
credit: number
|
||||
/** SIE dim → code tags on the line; omitted when untagged. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,14 @@ import type { TrialBalanceRow } from '@/types'
|
||||
* period activity to `[fromDate, toDate]`. Defaults equal `period_start` and
|
||||
* `period_end` — identical to the no-options behaviour.
|
||||
*
|
||||
* When `dimensions` is passed (map of SIE dim number → object code, e.g.
|
||||
* `{"6":"P001"}`, AND across keys), both line queries filter with jsonb
|
||||
* containment (`dimensions @> …`, served by idx_jel_dimensions_gin). The
|
||||
* result is then a PARTIAL view: opening balances from year-end closing are
|
||||
* company-wide, so callers must only use the filter for P&L-style reports
|
||||
* (classes 3–8) where IB is immaterial — never for balance/statutory reports.
|
||||
* The catalog whitelist + statutory-guard test pin this.
|
||||
*
|
||||
* Uses joined queries with pagination to handle any number of entries.
|
||||
* Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows.
|
||||
*/
|
||||
@@ -28,6 +36,7 @@ export async function generateTrialBalance(
|
||||
excludeYearEndClosing?: boolean
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
): Promise<{
|
||||
rows: TrialBalanceRow[]
|
||||
@@ -44,10 +53,23 @@ export async function generateTrialBalance(
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const dimensionFilter =
|
||||
options?.dimensions && Object.keys(options.dimensions).length > 0
|
||||
? options.dimensions
|
||||
: undefined
|
||||
|
||||
// ── Opening balances (IB) at period_start ──────────────────────
|
||||
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
|
||||
const { balances: obBalances, obEntryId } = await getOpeningBalances(
|
||||
supabase, companyId, period
|
||||
)
|
||||
// A dimension-filtered view cannot use company-wide opening balances (the
|
||||
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
|
||||
// every reported amount is dimension-scoped activity — correct for the P&L
|
||||
// reports the filter is whitelisted for, and never fabricates balances if
|
||||
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
|
||||
const openingBalances = dimensionFilter
|
||||
? new Map<string, { debit: number; credit: number }>()
|
||||
: obBalances
|
||||
|
||||
// ── Roll IB forward from period_start up to fromDate ───────────
|
||||
// When the caller requests a sub-range starting after period_start, the
|
||||
@@ -74,6 +96,11 @@ export async function generateTrialBalance(
|
||||
.gte('journal_entries.entry_date', period.period_start)
|
||||
.lt('journal_entries.entry_date', options.fromDate)
|
||||
|
||||
if (dimensionFilter) {
|
||||
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', dimensionFilter)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('journal_entry_id', obEntryId)
|
||||
}
|
||||
@@ -127,6 +154,11 @@ export async function generateTrialBalance(
|
||||
query = query.lte('journal_entries.entry_date', options.toDate)
|
||||
}
|
||||
|
||||
if (dimensionFilter) {
|
||||
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', dimensionFilter)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('journal_entry_id', obEntryId)
|
||||
}
|
||||
|
||||
@@ -4211,6 +4211,7 @@
|
||||
"name_kpi": "Key figures",
|
||||
"name_sie_export": "SIE export",
|
||||
"desc_resultatrapport": "Revenue less costs for the period",
|
||||
"desc_dimension_pnl": "Income statement per dimension — one column per project or cost centre",
|
||||
"desc_balansrapport": "Assets, liabilities and equity by account",
|
||||
"desc_trial_balance": "All accounts with opening and closing balances",
|
||||
"desc_kpi": "Margin, liquidity and other key figures",
|
||||
@@ -4236,6 +4237,7 @@
|
||||
"group_ledgers": "Ledgers",
|
||||
"group_reconciliation": "Reconciliation",
|
||||
"name_resultatrapport": "Income statement (interim)",
|
||||
"name_dimension_pnl": "Profit & loss by project/cost centre",
|
||||
"name_balansrapport": "Balance sheet (interim)",
|
||||
"name_trial_balance": "Trial balance",
|
||||
"name_income_statement": "Income statement",
|
||||
|
||||
@@ -4211,6 +4211,7 @@
|
||||
"name_kpi": "Nyckeltal",
|
||||
"name_sie_export": "SIE-export",
|
||||
"desc_resultatrapport": "Intäkter minus kostnader för perioden",
|
||||
"desc_dimension_pnl": "Resultatrapport per dimension — en kolumn per projekt eller kostnadsställe",
|
||||
"desc_balansrapport": "Tillgångar, skulder och eget kapital per konto",
|
||||
"desc_trial_balance": "Alla konton med ingående och utgående saldo",
|
||||
"desc_kpi": "Marginal, likviditet och andra nyckeltal",
|
||||
@@ -4236,6 +4237,7 @@
|
||||
"group_ledgers": "Huvudböcker",
|
||||
"group_reconciliation": "Avstämning",
|
||||
"name_resultatrapport": "Resultatrapport",
|
||||
"name_dimension_pnl": "Resultat per projekt/kostnadsställe",
|
||||
"name_balansrapport": "Balansrapport",
|
||||
"name_trial_balance": "Saldobalans",
|
||||
"name_income_statement": "Resultaträkning",
|
||||
|
||||
@@ -1651,6 +1651,39 @@ export interface ResultatrapportReport {
|
||||
prior_period: { start: string; end: string } | null
|
||||
}
|
||||
|
||||
// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over one
|
||||
// SIE dimension. `code: null` marks the "(Utan dimension)" residual bucket,
|
||||
// which is computed as Totalt − tagged columns so every row sums exactly to
|
||||
// its resultatrapport counterpart.
|
||||
export interface DimensionPnlColumn {
|
||||
code: string | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export interface DimensionPnlRow {
|
||||
account_number: string
|
||||
account_name: string
|
||||
values: number[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DimensionPnlGroup {
|
||||
class: number
|
||||
class_label: string
|
||||
rows: DimensionPnlRow[]
|
||||
subtotals: number[]
|
||||
subtotal_total: number
|
||||
}
|
||||
|
||||
export interface DimensionPnlReport {
|
||||
dimension: { sie_dim_no: string; name: string }
|
||||
columns: DimensionPnlColumn[]
|
||||
groups: DimensionPnlGroup[]
|
||||
net_per_column: number[]
|
||||
net_total: number
|
||||
period: { start: string; end: string }
|
||||
}
|
||||
|
||||
export interface BalansrapportRow {
|
||||
account_number: string
|
||||
account_name: string
|
||||
|
||||
Reference in New Issue
Block a user