Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m13s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 49s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 31s
# Conflicts: # pitch-deck/components/slides/MilestonesSlide.tsx # pitch-deck/lib/finanzplan/engine.ts
214 lines
9.4 KiB
TypeScript
214 lines
9.4 KiB
TypeScript
/**
|
|
* 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<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 = 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<MonthlyValues> {
|
|
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
|
|
}
|