Files
accounted/lib/reports/kpi-aggregates.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

186 lines
6.4 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import type { TrialBalanceRow } from '@/types'
/**
* Client-side companion for the get_kpi_report_aggregates RPC
* (supabase/migrations/20260723180000_kpi_report_aggregates_rpc.sql).
*
* The KPI route used to scan every journal line of the fiscal period three
* times through PostgREST (unfiltered trial balance, income-statement trial
* balance with excludeYearEndClosing, monthly breakdown) and aggregate in
* JS. The RPC returns the three pre-summed shapes in one round trip; the
* builders here reproduce the exact merge/rounding semantics of the legacy
* generators so the report JSON stays identical.
*/
export interface AccountSums {
account_number: string
debit: number
credit: number
}
export interface KpiMonthlyBucket {
year: number
/** Calendar month 1-12 (SQL EXTRACT). Callers convert to 0-based before
* handing buckets to assembleMonthlyBreakdown. */
month: number
income: number
expenses: number
}
export interface KpiAggregates {
tb: AccountSums[]
tb_ex_year_end: AccountSums[]
ob: AccountSums[]
monthly: KpiMonthlyBucket[]
}
function toAccountSums(rows: unknown[] | null | undefined): AccountSums[] {
return (rows ?? []).map((r) => {
const row = r as { account_number?: unknown; debit?: unknown; credit?: unknown }
return {
account_number: String(row.account_number ?? ''),
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
}
})
}
/**
* Fetch the KPI aggregates in one round trip. Throws on RPC failure (the
* route surfaces it as a 500 via withRouteContext; matches the
* get_vat_declaration_totals precedent in lib/reports/vat-declaration.ts).
*/
export async function fetchKpiAggregates(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
obEntryId: string | null
): Promise<KpiAggregates> {
const { data, error } = await supabase.rpc('get_kpi_report_aggregates', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_ob_entry_id: obEntryId,
})
if (error) {
throw new Error(`get_kpi_report_aggregates failed: ${error.message}`)
}
const payload = (data ?? {}) as {
tb?: unknown[]
tb_ex_year_end?: unknown[]
ob?: unknown[]
monthly?: unknown[]
}
return {
tb: toAccountSums(payload.tb),
tb_ex_year_end: toAccountSums(payload.tb_ex_year_end),
ob: toAccountSums(payload.ob),
monthly: (payload.monthly ?? []).map((r) => {
const row = r as { year?: unknown; month?: unknown; income?: unknown; expenses?: unknown }
return {
year: Number(row.year) || 0,
month: Number(row.month) || 0,
income: Number(row.income) || 0,
expenses: Number(row.expenses) || 0,
}
}),
}
}
/**
* Build the opening-balance map for the fiscal period.
*
* Pass `priorRows: null` when the period has an opening-balance entry: the
* balances then come from the RPC's `ob` section, mirroring the OB-entry
* branch of getOpeningBalances (lib/reports/opening-balances.ts lines
* 57-62: additive accumulation with Number()||0 coercion). Otherwise pass
* the rows returned by the compute_prior_opening_balances RPC, mirroring
* the fallback branch (lines 77-86: per-row set with Number()||0).
*/
export function buildOpeningBalances(
agg: KpiAggregates,
priorRows:
| Array<{ account_number: string; debit: number | string; credit: number | string }>
| null
): Map<string, { debit: number; credit: number }> {
const balances = new Map<string, { debit: number; credit: number }>()
if (priorRows === null) {
for (const line of agg.ob) {
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit) || 0
existing.credit += Number(line.credit) || 0
balances.set(line.account_number, existing)
}
} else {
for (const row of priorRows) {
balances.set(row.account_number, {
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
})
}
}
return balances
}
/**
* Assemble TrialBalanceRow[] from pre-summed per-account period activity.
*
* Pinned to the row-building tail of generateTrialBalance
* (lib/reports/trial-balance.ts lines 287-322, which is read-only): merged
* key set of opening + period accounts, `Konto <n>` /
* `parseInt(n[0]) || 0` fallback for accounts missing from the chart,
* öre rounding on all six amount fields, and a final localeCompare sort.
* Rounding goes through roundOre (the antipattern guard forbids new raw
* Math.round(x * 100) / 100): identical to the source expression except
* within float epsilon of half-öre boundaries. Keep the two in sync if
* the source ever changes.
*/
export function buildTrialBalanceRows(
openingBalances: Map<string, { debit: number; credit: number }>,
periodSums: AccountSums[],
accountMap: Map<string, { name: string; class: number }>
): TrialBalanceRow[] {
const periodBalances = new Map<string, { debit: number; credit: number }>()
for (const sums of periodSums) {
const existing = periodBalances.get(sums.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(sums.debit) || 0
existing.credit += Number(sums.credit) || 0
periodBalances.set(sums.account_number, existing)
}
// Merge account numbers from both opening and period
const allAccountNumbers = new Set([...openingBalances.keys(), ...periodBalances.keys()])
// Build rows: IB + period = UB
const rows: TrialBalanceRow[] = []
for (const accountNumber of allAccountNumbers) {
const opening = openingBalances.get(accountNumber) || { debit: 0, credit: 0 }
const periodActivity = periodBalances.get(accountNumber) || { debit: 0, credit: 0 }
const accountInfo = accountMap.get(accountNumber) || {
name: `Konto ${accountNumber}`,
class: parseInt(accountNumber[0]) || 0,
}
rows.push({
account_number: accountNumber,
account_name: accountInfo.name,
account_class: accountInfo.class,
opening_debit: roundOre(opening.debit),
opening_credit: roundOre(opening.credit),
period_debit: roundOre(periodActivity.debit),
period_credit: roundOre(periodActivity.credit),
closing_debit: roundOre(opening.debit + periodActivity.debit),
closing_credit: roundOre(opening.credit + periodActivity.credit),
})
}
rows.sort((a, b) => a.account_number.localeCompare(b.account_number))
return rows
}