* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
295 lines
8.9 KiB
TypeScript
295 lines
8.9 KiB
TypeScript
/**
|
|
* Byrå cockpit Nyckeltal fetch layer (WL-16): cross-client KPI numbers.
|
|
*
|
|
* Read-first like the rest of the cockpit (WL-09): every query is a
|
|
* cross-membership READ that RLS already allows via user_company_ids().
|
|
* Per client company the numbers come from the get_kpi_report_aggregates
|
|
* RPC (SECURITY INVOKER, one SQL pass per fiscal period; the same fast
|
|
* path the in-company KPI route uses), so nothing here scans journal
|
|
* lines through PostgREST.
|
|
*
|
|
* One RPC call per (company, fiscal period): with the byrå scale this is
|
|
* designed for (~10 clients, 1-2 periods per range) that is a couple of
|
|
* dozen parallel round trips. Past ~15 clients the right move is a new
|
|
* RPC taking uuid[] (see DECISIONS.md).
|
|
*/
|
|
|
|
import { chunk as chunked } from '@/lib/utils'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import {
|
|
fetchClientOverview,
|
|
type ClientOverview,
|
|
} from '@/lib/clients/fetch-client-overview'
|
|
import type { ClientDeadline } from '@/lib/clients/aggregate'
|
|
import {
|
|
fetchKpiAggregates,
|
|
buildOpeningBalances,
|
|
buildTrialBalanceRows,
|
|
} from '@/lib/reports/kpi-aggregates'
|
|
import { calculateCashPosition, calculateVatLiability } from '@/lib/reports/kpi'
|
|
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
|
import {
|
|
filterBucketsToRange,
|
|
mergeMonthlySeries,
|
|
periodsInRange,
|
|
pickCurrentPeriod,
|
|
resolvePeriodPreset,
|
|
resultMargin,
|
|
sumBuckets,
|
|
type FiscalPeriodLite,
|
|
type KpiPeriodPreset,
|
|
type KpiRange,
|
|
type MonthBucket,
|
|
} from '@/lib/byra/kpi-aggregate'
|
|
|
|
const log = createLogger('byra:kpi-overview')
|
|
|
|
/** Max ids per PostgREST .in() filter (mirrors lib/clients/fetch-client-overview.ts). */
|
|
const IN_CLAUSE_CHUNK = 150
|
|
|
|
export interface ByraKpiClientRow {
|
|
companyId: string
|
|
name: string
|
|
orgNumber: string | null
|
|
revenue: number
|
|
expenses: number
|
|
result: number
|
|
/** Net margin in percent, null when revenue is 0. */
|
|
margin: number | null
|
|
/** Closing balance of 19xx accounts for the current fiscal period. */
|
|
cash: number
|
|
/** Net 26xx position, positive = att betala (mirrors ruta 49). */
|
|
vatLiability: number
|
|
unbookedCount: number
|
|
inboxCount: number
|
|
nextDeadline: ClientDeadline | null
|
|
/** True when this client's numbers could not be fetched; render dashes, not zeros. */
|
|
failed: boolean
|
|
}
|
|
|
|
export interface ByraKpiOverview {
|
|
team: { id: string; name: string }
|
|
role: ClientOverview['role']
|
|
preset: KpiPeriodPreset
|
|
range: KpiRange
|
|
/** Full roster for the company filter chips (unfiltered). */
|
|
allClients: Array<{ companyId: string; name: string }>
|
|
/** Ids the current filter selects (equals the full roster when unfiltered). */
|
|
selectedIds: string[]
|
|
/** One row per selected client, revenue descending. */
|
|
rows: ByraKpiClientRow[]
|
|
/** Merged monthly income/expense series across the selected clients. */
|
|
months: MonthlyDataPoint[]
|
|
}
|
|
|
|
interface CompanyKpiNumbers {
|
|
revenue: number
|
|
expenses: number
|
|
result: number
|
|
margin: number | null
|
|
cash: number
|
|
vatLiability: number
|
|
failed: boolean
|
|
buckets: MonthBucket[]
|
|
}
|
|
|
|
const FAILED_NUMBERS: CompanyKpiNumbers = {
|
|
revenue: 0,
|
|
expenses: 0,
|
|
result: 0,
|
|
margin: null,
|
|
cash: 0,
|
|
vatLiability: 0,
|
|
failed: true,
|
|
buckets: [],
|
|
}
|
|
|
|
/**
|
|
* Fetch one company's numbers: monthly P&L buckets clipped to the range,
|
|
* plus cash and VAT from the current fiscal period's closing balances.
|
|
* Account names are irrelevant here (nothing displays them), so the trial
|
|
* balance rows are built with an empty account map.
|
|
*/
|
|
async function fetchCompanyNumbers(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
periods: FiscalPeriodLite[],
|
|
range: KpiRange,
|
|
todayIso: string,
|
|
): Promise<CompanyKpiNumbers> {
|
|
const current = pickCurrentPeriod(periods, todayIso)
|
|
const inRange = periodsInRange(periods, range)
|
|
|
|
const needed = new Map<string, FiscalPeriodLite>()
|
|
for (const p of inRange) needed.set(p.id, p)
|
|
if (current) needed.set(current.id, current)
|
|
if (needed.size === 0) {
|
|
return { ...FAILED_NUMBERS, failed: false }
|
|
}
|
|
|
|
const [aggEntries, priorResult] = await Promise.all([
|
|
Promise.all(
|
|
[...needed.values()].map(async (period) => ({
|
|
period,
|
|
agg: await fetchKpiAggregates(
|
|
supabase,
|
|
companyId,
|
|
period.id,
|
|
period.opening_balance_entry_id,
|
|
),
|
|
})),
|
|
),
|
|
// Opening balances without an OB entry fall back to the server-side
|
|
// prior-period aggregate, exactly like the in-company KPI route.
|
|
current && !current.opening_balance_entry_id
|
|
? supabase.rpc('compute_prior_opening_balances', {
|
|
p_company_id: companyId,
|
|
p_period_start: current.period_start,
|
|
})
|
|
: Promise.resolve(null),
|
|
])
|
|
|
|
if (priorResult?.error) {
|
|
throw new Error(priorResult.error.message)
|
|
}
|
|
|
|
const inRangeIds = new Set(inRange.map((p) => p.id))
|
|
const buckets: MonthBucket[] = []
|
|
for (const { period, agg } of aggEntries) {
|
|
if (!inRangeIds.has(period.id)) continue
|
|
for (const m of agg.monthly) {
|
|
buckets.push({ year: m.year, month0: m.month - 1, income: m.income, expenses: m.expenses })
|
|
}
|
|
}
|
|
const rangeBuckets = filterBucketsToRange(buckets, range)
|
|
const sums = sumBuckets(rangeBuckets)
|
|
|
|
let cash = 0
|
|
let vatLiability = 0
|
|
if (current) {
|
|
const currentAgg = aggEntries.find((e) => e.period.id === current.id)!.agg
|
|
const openingBalances = buildOpeningBalances(
|
|
currentAgg,
|
|
current.opening_balance_entry_id ? null : (priorResult?.data ?? []),
|
|
)
|
|
const tbRows = buildTrialBalanceRows(openingBalances, currentAgg.tb, new Map())
|
|
cash = calculateCashPosition(tbRows)
|
|
vatLiability = calculateVatLiability(tbRows)
|
|
}
|
|
|
|
return {
|
|
...sums,
|
|
margin: resultMargin(sums.revenue, sums.result),
|
|
cash,
|
|
vatLiability,
|
|
failed: false,
|
|
buckets: rangeBuckets,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aggregate the Nyckeltal overview for the caller's byrå team. Returns null
|
|
* when the user is not a byrå team member (the page redirects). A failing
|
|
* client is marked `failed` and zero-weighted rather than failing the page.
|
|
*/
|
|
export async function fetchByraKpiOverview(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
opts: { preset?: string; companyIds?: string[]; today?: Date } = {},
|
|
): Promise<ByraKpiOverview | null> {
|
|
const overview = await fetchClientOverview(supabase, userId)
|
|
if (!overview) return null
|
|
|
|
const today = opts.today ?? new Date()
|
|
const { preset, range } = resolvePeriodPreset(opts.preset, today)
|
|
const todayIso = range.toDate
|
|
|
|
const roster = overview.clients
|
|
const requested = new Set(opts.companyIds ?? [])
|
|
const validRequested = roster.filter((c) => requested.has(c.companyId))
|
|
const selected = validRequested.length > 0 ? validRequested : roster
|
|
|
|
const base: ByraKpiOverview = {
|
|
team: overview.team,
|
|
role: overview.role,
|
|
preset,
|
|
range,
|
|
allClients: roster.map((c) => ({ companyId: c.companyId, name: c.name })),
|
|
selectedIds: selected.map((c) => c.companyId),
|
|
rows: [],
|
|
months: [],
|
|
}
|
|
if (selected.length === 0) return base
|
|
|
|
const selectedIds = base.selectedIds
|
|
const periodRows: FiscalPeriodLite[] = []
|
|
for (const chunk of chunked(selectedIds, IN_CLAUSE_CHUNK)) {
|
|
const rows = await fetchAllRows<FiscalPeriodLite>(({ from, to }) =>
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, company_id, period_start, period_end, opening_balance_entry_id')
|
|
.in('company_id', chunk)
|
|
.order('id', { ascending: true })
|
|
.range(from, to),
|
|
)
|
|
periodRows.push(...rows)
|
|
}
|
|
const periodsByCompany = new Map<string, FiscalPeriodLite[]>()
|
|
for (const p of periodRows) {
|
|
const list = periodsByCompany.get(p.company_id)
|
|
if (list) list.push(p)
|
|
else periodsByCompany.set(p.company_id, [p])
|
|
}
|
|
|
|
const numbers = await Promise.all(
|
|
selected.map(async (client) => {
|
|
try {
|
|
return await fetchCompanyNumbers(
|
|
supabase,
|
|
client.companyId,
|
|
periodsByCompany.get(client.companyId) ?? [],
|
|
range,
|
|
todayIso,
|
|
)
|
|
} catch (err) {
|
|
log.error('byra-kpi client fetch failed', err, {
|
|
companyId: client.companyId,
|
|
operation: 'byra.kpi_overview',
|
|
})
|
|
return FAILED_NUMBERS
|
|
}
|
|
}),
|
|
)
|
|
|
|
const rows: ByraKpiClientRow[] = selected.map((client, i) => {
|
|
const n = numbers[i]
|
|
return {
|
|
companyId: client.companyId,
|
|
name: client.name,
|
|
orgNumber: client.orgNumber,
|
|
revenue: n.revenue,
|
|
expenses: n.expenses,
|
|
result: n.result,
|
|
margin: n.margin,
|
|
cash: n.cash,
|
|
vatLiability: n.vatLiability,
|
|
unbookedCount: client.unbookedCount,
|
|
inboxCount: client.inboxCount,
|
|
nextDeadline: client.nextDeadline,
|
|
failed: n.failed,
|
|
}
|
|
})
|
|
rows.sort((a, b) => b.revenue - a.revenue || a.name.localeCompare(b.name, 'sv'))
|
|
|
|
return {
|
|
...base,
|
|
rows,
|
|
months: mergeMonthlySeries(
|
|
numbers.flatMap((n) => n.buckets),
|
|
range,
|
|
),
|
|
}
|
|
}
|