Files
breakpilot-core/pitch-deck/lib/finanzplan/engine.ts
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:09:30 +02:00

211 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Finanzplan Compute Engine — Orchestrator
*
* Dependency order:
* Personalkosten (independent inputs)
* Investitionen (independent inputs)
* Kunden → Umsatzerlöse → Materialaufwand
* Betriebliche Aufwendungen (needs Personal + Invest)
* Sonst. betr. Erträge (independent)
* Liquidität (aggregates all above)
* GuV (annual summary)
*
* Split into modules:
* engine-sheets.ts — pure calculators (no DB)
* engine-betrieb.ts — betriebliche aufwendungen
* engine-liquiditaet.ts — liquidity / cash flow
* engine-guv.ts — GuV / P&L + taxes
*/
import { Pool } from 'pg'
import {
MonthlyValues, MONTHS, FOUNDING_MONTH,
emptyMonthly,
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
FPLiquiditaet, FPComputeResult,
} from './types'
import {
computePersonalkosten, computeInvestitionen,
sumField, computeHeadcount,
} from './engine-sheets'
import { computeBetrieblicheAufwendungen } from './engine-betrieb'
import { computeLiquiditaet } from './engine-liquiditaet'
import { computeGuV } from './engine-guv'
// Re-export sheet calculators for direct consumers
export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
// Import types used inline
type FPUmsatzerloese = import('./types').FPUmsatzerloese
type FPMaterialaufwand = import('./types').FPMaterialaufwand
// --- Main Engine ---
export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise<FPComputeResult> {
// 1. Load all editable data from DB
const [
personalRows, investRows, betriebRows, liquidRows,
kundenSummary, umsatzRows, materialRows,
] = await Promise.all([
pool.query('SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_liquiditaet WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_kunden_summary WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_umsatzerloese WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_materialaufwand WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
])
// 2. Compute Personalkosten
const personal = computePersonalkosten(personalRows.rows as FPPersonalkosten[])
const totalBrutto = sumField(personal as any, 'values_brutto')
const totalSozial = sumField(personal as any, 'values_sozial')
const totalPersonal = sumField(personal as any, 'values_total')
const headcount = computeHeadcount(personal)
for (const p of personal) {
await pool.query(
'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4',
[JSON.stringify(p.values_brutto), JSON.stringify(p.values_sozial), JSON.stringify(p.values_total), p.id]
)
}
// 3. Compute Investitionen
const invest = computeInvestitionen(investRows.rows as FPInvestitionen[])
const totalInvest = sumField(invest as any, 'values_invest')
const totalAfa = sumField(invest as any, 'values_afa')
for (const i of invest) {
await pool.query(
'UPDATE fp_investitionen SET values_invest = $1, values_afa = $2 WHERE id = $3',
[JSON.stringify(i.values_invest), JSON.stringify(i.values_afa), i.id]
)
}
// 4. Umsatzerlöse (quantity × price) + Materialaufwand
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[]
)
// 5. Bestandskunden (for formula-based costs)
const kundenRows = await pool.query(
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
[scenarioId]
)
const totalBestandskunden = emptyMonthly()
for (const row of kundenRows.rows) {
const rl = (row as { row_label?: string }).row_label || ''
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
for (let m = 1; m <= MONTHS; m++) {
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
}
}
}
// Cloud-Hosting in Materialaufwand
const matRows = materialRows.rows as FPMaterialaufwand[]
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
if (cloudRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const kunden = totalBestandskunden[`m${m}`] || 0
const extraKunden = Math.max(0, kunden - 10)
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
cloudRow.values = computed
}
// 6. Betriebliche Aufwendungen
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
const { totalSonstige, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, {
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
headcount, totalBestandskunden,
})
// 7. Liquidität
const liquid = liquidRows.rows as FPLiquiditaet[]
const { endstand } = await computeLiquiditaet(pool, liquid, {
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
})
// 8. GuV
const guv = await computeGuV(pool, scenarioId, liquid, {
totalRevenue, totalMaterial, totalBrutto, totalSozial,
totalPersonal, totalAfa, totalSonstige,
})
return {
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
umsatzerloese: { total: totalRevenue },
materialaufwand: { total: totalMaterial },
betriebliche: { total_sonstige: totalSonstige, total_gesamt: totalGesamt },
liquiditaet: { rows: liquid, endstand },
guv,
}
}
// --- Revenue & Material helpers (kept here to avoid circular deps) ---
async function computeRevenueAndMaterial(
pool: Pool,
umsatzAllRows: FPUmsatzerloese[],
materialAllRows: FPMaterialaufwand[],
): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> {
const prices = umsatzAllRows.filter(r => r.section === 'price')
const quantities = umsatzAllRows.filter(r => r.section === 'quantity')
const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue')
const totalRevenue = emptyMonthly()
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
for (const rev of revenueRows) {
if (rev.row_label === 'GESAMTUMSATZ') continue
const tier = extractTier(rev.row_label)
const qty = quantities.find(q => extractTier(q.row_label) === tier) || quantities.find(q => q.row_label === rev.row_label)
const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label)
if (qty && price) {
for (let m = 1; m <= MONTHS; m++) {
const v = (qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0)
rev.values[`m${m}`] = Math.round(v)
}
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
}
for (let m = 1; m <= MONTHS; m++) {
totalRevenue[`m${m}`] += rev.values[`m${m}`] || 0
}
}
const gesamtUmsatz = revenueRows.find(r => r.row_label === 'GESAMTUMSATZ')
if (gesamtUmsatz) {
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
}
// Materialaufwand
const matCosts = materialAllRows.filter(r => r.section === 'cost')
const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost')
const totalMaterial = emptyMonthly()
for (const cost of matCosts) {
if (cost.row_label === 'SUMME') continue
const uc = matUnitCosts.find(u => u.row_label === cost.row_label)
const qty = quantities.find(q => q.row_label === cost.row_label)
if (uc && qty) {
for (let m = 1; m <= MONTHS; m++) {
const v = (qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0)
cost.values[`m${m}`] = Math.round(v)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
}
for (let m = 1; m <= MONTHS; m++) {
totalMaterial[`m${m}`] += cost.values[`m${m}`] || 0
}
}
const matSumme = matCosts.find(r => r.row_label === 'SUMME')
if (matSumme) {
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), matSumme.id])
}
return { totalRevenue, totalMaterial }
}