a8801430f4
* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side
Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.
Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:
1. fetch matching journal_entries (id + caller-selected columns)
filtered by company_id / fiscal_period_id / status / entry_date /
source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
(line.journal_entries = {...}, aliasable) and sort lines by id
ascending to preserve the old .order('id') semantics.
Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.
Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): stop echoing raw error messages from the general-ledger route
The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
261 lines
9.7 KiB
TypeScript
261 lines
9.7 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { roundOre } from '@/lib/money'
|
||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||
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 fetchEntryLines<{
|
||
id: string
|
||
account_number: string
|
||
debit_amount: number
|
||
credit_amount: number
|
||
dimensions: Record<string, string>
|
||
}>({
|
||
supabase,
|
||
lineColumns: 'id, account_number, debit_amount, credit_amount, dimensions',
|
||
filterEntries: (q: EntryLinesQuery) => {
|
||
let query = q
|
||
.eq('company_id', companyId)
|
||
.eq('fiscal_period_id', fiscalPeriodId)
|
||
.in('status', ['posted', 'reversed'])
|
||
|
||
if (options?.toDate) {
|
||
query = query.lte('entry_date', options.toDate)
|
||
}
|
||
|
||
return query
|
||
},
|
||
// Key-existence via the extracted text field: dims 1/6 ride the partial
|
||
// expression indexes (idx_jel_dimensions_dim1/dim6).
|
||
filterLines: (q: EntryLinesQuery) => q.not(`dimensions->>${sieDimNo}`, 'is', null),
|
||
})
|
||
|
||
// 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)
|
||
}
|