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>
211 lines
8.7 KiB
TypeScript
211 lines
8.7 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|