/** * Finanzplan Compute Engine — Orchestrator * * Dependency order: * Personalkosten (independent inputs) * Investitionen (independent inputs) * Kunden -> Umsatzerloese -> Materialaufwand * Betriebliche Aufwendungen (needs Personal + Invest) * Sonst. betr. Ertraege (independent) * Liquiditaet (aggregates all above) * GuV (annual summary) * * Each computation step is delegated to a companion module: * engine-sheets.ts — pure computation (Personal, Invest, aggregation) * engine-betrieb.ts — formula-based opex + category sums * engine-liquiditaet.ts — rolling cash balance * engine-guv.ts — annual P&L + taxes */ import { Pool } from 'pg' import { MonthlyValues, MONTHS, FOUNDING_MONTH, emptyMonthly, FPBetrieblicheAufwendungen, FPLiquiditaet, FPComputeResult, } from './types' import { computePersonalkosten, computeInvestitionen, sumField } from './engine-sheets' import { computeBetrieblicheAufwendungen, computeCloudHosting } from './engine-betrieb' import { computeLiquiditaet, computeRollingBalance } from './engine-liquiditaet' import { computeGuV } from './engine-guv' // Re-export pure calculators for direct use by tests / scripts export { computePersonalkosten, computeInvestitionen } from './engine-sheets' // Import types used only inside this file type FPUmsatzerloese = import('./types').FPUmsatzerloese type FPMaterialaufwand = import('./types').FPMaterialaufwand type FPPersonalkosten = import('./types').FPPersonalkosten type FPInvestitionen = import('./types').FPInvestitionen // --- Main Engine --- export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise { // 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 = emptyMonthly() for (let m = 1; m <= MONTHS; m++) { headcount[`m${m}`] = personal.filter(p => (p.values_total[`m${m}`] || 0) > 0).length } // Write computed values back to DB 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. Umsatzerloese + Materialaufwand const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial( pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[], ) // 5. Bestandskunden (for formula-based costs) const totalBestandskunden = await loadBestandskunden(pool, scenarioId) // 5b. Cloud-Hosting formula in Materialaufwand await computeCloudHosting(pool, materialRows.rows as FPMaterialaufwand[], totalBestandskunden) // 6. Betriebliche Aufwendungen const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[] const { totalSonstige } = await computeBetrieblicheAufwendungen(pool, betrieb, { totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial, headcount, totalBestandskunden, }) // 7. Liquiditaet (first pass) const liquid = liquidRows.rows as FPLiquiditaet[] await computeLiquiditaet(pool, liquid, { totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest, }) // 8. GuV — compute annual values + taxes (writes tax rows to Liquiditaet) const guv = await computeGuV(pool, scenarioId, liquid, { totalRevenue, totalMaterial, totalBrutto, totalSozial, totalPersonal, totalAfa, totalSonstige, }) // 9. Second pass: tax rows were written after rolling balance above. // Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance // so taxes are included even on the first engine run. await computeRollingBalance(pool, liquid) // 10. Build result const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche')) const liquiditaetRow = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT')) 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: gesamtBetrieb?.values || emptyMonthly() }, liquiditaet: { rows: liquid, endstand: liquiditaetRow?.values || emptyMonthly() }, guv, } } // --- Revenue & Material helpers (kept in orchestrator — tightly coupled to DB writes) --- async function computeRevenueAndMaterial( pool: Pool, umsatzAllRows: FPUmsatzerloese[], materialAllRows: FPMaterialaufwand[], ): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> { // Umsatzerloese (quantity x price) 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++) { rev.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0)) } 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 (quantity x unit_cost) 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++) { cost.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0)) } 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 } } async function loadBestandskunden(pool: Pool, scenarioId: string): Promise { 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 } } } return totalBestandskunden }