feat(dimensions): PR4 reports — dimension-filtered P&L + Resultat per projekt/kostnadsställe (#862)
* feat(dimensions): PR4 reports — dimension-filtered P&L everywhere + Resultat per projekt/kostnadsställe The Project P&L milestone of the dimensions plan (dev_docs §7 PR4). One choke point lights up everything: generateTrialBalance gains options.dimensions (SIE dim → code map) pushed down as jsonb containment (dimensions @>, served by idx_jel_dimensions_gin) on both line queries, with company-wide opening balances dropped when filtered (they cannot be dimension-scoped; P&L-safe by whitelist). Resultatrapport, resultaträkning, huvudbok, monthly-breakdown and the TB drill-down inherit the filter; the KPI route filters only its P&L-side inputs (income statement, months, expense composition) — never cash/VAT. New report lib/reports/dimension-pnl.ts — "Resultat per projekt/ kostnadsställe" (Fortnox Resultatrapport projekt): value-as-column matrix over one dimension with an explicit "(Utan dimension)" bucket computed as the residual against the same trial-balance pass resultatrapport uses, so every row and the Totalt column reconcile with the unfiltered resultatrapport by construction. Registered in REPORT_CATALOG (visible only when dimensions_enabled), slug-routed view + xlsx export. UI: DimensionFilter (dimension + value picker, persistent "Filtrerad — ej fullständig rapport" chip) mounts in FocusedReport for catalog entries flagged dimensions: true; huvudbok rows show line dim codes. Statutory exclusion pinned by TEST, not convention: lib/reports/__tests__/dimension-statutory-guard.test.ts fails if the filter parser leaks into balance sheet, balansrapport, kassaflöde, VAT, SIE or full-archive routes/generators, or if the catalog whitelist widens. MCP: new gnubok_get_dimension_pnl (reports:read); dimensions filter arg on get_trial_balance/get_income_statement/get_general_ledger with resolve-don't-select (names → registry codes, resolution echoes); query_journal totals fixed to aggregate the FULL match set (was silently slice-scoped while claiming otherwise) with an honest totals_scope field, plus group_by / group_by_dimension aggregation. Also: voucher-detail dim-6 badge now uses the registry name instead of the non-standard "PR" abbreviation (#859 review follow-up). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dimensions): address #862 review — export disclosure, prior-column suppression, period-label honesty, route hardening - Filtered XLSX/PDF exports now carry the partial-view disclosure past the file boundary (BFNAR 2013:2): filename suffix (-dim6-p001), a "Filtrerad … — ej fullständig rapport" row on every sheet, and a header note/title line in the PDFs. - Resultatrapport drops the prior-year column when a dimension filter is active — project codes are time-limited under K2/K3, so "this code last year" may be a different project (same rule as narrowed date ranges). - dimension-pnl no longer accepts fromDate: the matrix is cumulative from period_start by design (closing-balance semantics), and the period label now states exactly that instead of echoing a lower bound that was never applied. Routes/MCP tool updated to toDate-only. - dimension-pnl routes 404 on an unknown/foreign period id and cap dim_no to 4 digits (matching the MCP tool's PostgREST-path guard, which the generator now also enforces itself). - Statutory-guard test's generateTrialBalance call-site scan is paren-aware instead of a 300-char window; added fully-untagged and injection-guard test cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
86071334cb
commit
01dbef4015
@@ -0,0 +1,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)
|
||||
|
||||
Reference in New Issue
Block a user