Merge remote-tracking branch 'gitea/main'
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
This commit is contained in:
Benjamin Admin
2026-04-27 13:14:54 +02:00
21 changed files with 624 additions and 354 deletions

View File

@@ -4,40 +4,38 @@
* Dependency order:
* Personalkosten (independent inputs)
* Investitionen (independent inputs)
* Kunden Umsatzerlöse Materialaufwand
* Kunden -> Umsatzerloese -> Materialaufwand
* Betriebliche Aufwendungen (needs Personal + Invest)
* Sonst. betr. Erträge (independent)
* Liquidität (aggregates all above)
* Sonst. betr. Ertraege (independent)
* Liquiditaet (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
* 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,
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
emptyMonthly, FPBetrieblicheAufwendungen,
FPLiquiditaet, FPComputeResult,
} from './types'
import {
computePersonalkosten, computeInvestitionen,
sumField, computeHeadcount,
} from './engine-sheets'
import { computeBetrieblicheAufwendungen } from './engine-betrieb'
import { computeLiquiditaet } from './engine-liquiditaet'
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 sheet calculators for direct consumers
// Re-export pure calculators for direct use by tests / scripts
export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
// Import types used inline
// 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 ---
@@ -61,8 +59,12 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
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)
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',
@@ -82,84 +84,70 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
)
}
// 4. Umsatzerlöse (quantity × price) + Materialaufwand
// 4. Umsatzerloese + Materialaufwand
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[]
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
}
}
}
const totalBestandskunden = await loadBestandskunden(pool, scenarioId)
// 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
}
// 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, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, {
const { totalSonstige } = await computeBetrieblicheAufwendungen(pool, betrieb, {
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
headcount, totalBestandskunden,
})
// 7. Liquidität
// 7. Liquiditaet (first pass)
const liquid = liquidRows.rows as FPLiquiditaet[]
const { endstand } = await computeLiquiditaet(pool, liquid, {
await computeLiquiditaet(pool, liquid, {
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
})
// 8. GuV
// 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: totalGesamt },
liquiditaet: { rows: liquid, endstand },
betriebliche: { total_sonstige: totalSonstige, total_gesamt: gesamtBetrieb?.values || emptyMonthly() },
liquiditaet: { rows: liquid, endstand: liquiditaetRow?.values || emptyMonthly() },
guv,
}
}
// --- Revenue & Material helpers (kept here to avoid circular deps) ---
// --- 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)
@@ -167,8 +155,7 @@ async function computeRevenueAndMaterial(
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)
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])
}
@@ -181,7 +168,7 @@ async function computeRevenueAndMaterial(
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
}
// Materialaufwand
// 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()
@@ -192,8 +179,7 @@ async function computeRevenueAndMaterial(
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)
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])
}
@@ -208,3 +194,20 @@ async function computeRevenueAndMaterial(
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
}