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
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:
@@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen')
|
||||
const afa = afaRow?.values || emptyMonthly()
|
||||
|
||||
// Liquidität endstand
|
||||
const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET')
|
||||
// Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels
|
||||
const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT'))
|
||||
const cashBalance = liqEndRow?.values || emptyMonthly()
|
||||
|
||||
// Headcount
|
||||
@@ -120,7 +120,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
|
||||
|
||||
return {
|
||||
scenario_id: sid,
|
||||
scenario_id: sid as string,
|
||||
results,
|
||||
summary: {
|
||||
final_arr: lastMonth?.arr_eur || 0,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Betriebliche Aufwendungen — formula-based rows + category sums
|
||||
*
|
||||
* Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.),
|
||||
* Gewerbesteuer, category sums, and Gesamtkosten.
|
||||
* Gewerbesteuer, marketing, Berufsgenossenschaft, category sums, and Gesamtkosten.
|
||||
* Also computes Cloud-Hosting formula in Materialaufwand.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
} from './types'
|
||||
import { sumRows } from './engine-sheets'
|
||||
|
||||
type FPMaterialaufwand = import('./types').FPMaterialaufwand
|
||||
|
||||
export interface BetriebContext {
|
||||
totalBrutto: MonthlyValues
|
||||
totalPersonal: MonthlyValues
|
||||
@@ -23,6 +26,27 @@ export interface BetriebContext {
|
||||
totalBestandskunden: MonthlyValues
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute Cloud-Hosting formula in Materialaufwand.
|
||||
*/
|
||||
export async function computeCloudHosting(
|
||||
pool: Pool,
|
||||
matRows: FPMaterialaufwand[],
|
||||
totalBestandskunden: MonthlyValues,
|
||||
): Promise<void> {
|
||||
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) // first 10 included in base
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all formula-based betriebliche aufwendungen rows and sums.
|
||||
* Writes computed values back to DB.
|
||||
@@ -41,6 +65,7 @@ export async function computeBetrieblicheAufwendungen(
|
||||
// Formula-based rows: derive from headcount (excl. founders) or customers
|
||||
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
|
||||
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
|
||||
// KFZ costs are manual (from Jan 2028), not formula-based
|
||||
{ label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount },
|
||||
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden },
|
||||
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount },
|
||||
@@ -58,7 +83,7 @@ export async function computeBetrieblicheAufwendungen(
|
||||
}
|
||||
}
|
||||
|
||||
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll
|
||||
// Berufsgenossenschaft (VBG IT/Buero): ~0.5% of total brutto payroll
|
||||
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
|
||||
if (bgRow) {
|
||||
const computed = emptyMonthly()
|
||||
@@ -95,6 +120,7 @@ export async function computeBetrieblicheAufwendungen(
|
||||
}
|
||||
|
||||
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
|
||||
// Monthly profit = Revenue - Material - Personnel - AfA - other opex (excl. taxes)
|
||||
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
|
||||
if (gewStRow) {
|
||||
const nonTaxOpex = betrieb.filter(r =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* GuV (Gewinn- und Verlustrechnung) — annual P&L computation
|
||||
*
|
||||
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Körperschaftsteuer)
|
||||
* with Verlustvortrag, and writes tax amounts back to Liquidität.
|
||||
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Koerperschaftsteuer)
|
||||
* with Verlustvortrag, and writes tax amounts back to Liquiditaet.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
@@ -23,7 +23,7 @@ export interface GuvContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute GuV annual values, taxes, and write tax values to Liquidität.
|
||||
* Compute GuV annual values, taxes, and write tax values to Liquiditaet.
|
||||
* Returns EBIT annual values.
|
||||
*/
|
||||
export async function computeGuV(
|
||||
@@ -85,7 +85,7 @@ export async function computeGuV(
|
||||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
|
||||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
|
||||
|
||||
// Write taxes to Liquidität (monthly = 1/12 of annual amount)
|
||||
// Write taxes to Liquiditaet (monthly = 1/12 of annual amount)
|
||||
await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer)
|
||||
await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer)
|
||||
|
||||
@@ -95,8 +95,8 @@ export async function computeGuV(
|
||||
// --- Tax helpers ---
|
||||
|
||||
// Stockach 78333: Hebesatz 350%
|
||||
// Gewerbesteuer = 3,5% × 3,5 = 12,25%
|
||||
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825%
|
||||
// Gewerbesteuer = 3,5% x 3,5 = 12,25%
|
||||
// Koerperschaftsteuer = 15% + 5,5% Soli = 15,825%
|
||||
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
|
||||
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
|
||||
|
||||
@@ -112,14 +112,16 @@ function computeTaxes(ebit: AnnualValues) {
|
||||
const gewinn = ebit[k] || 0
|
||||
|
||||
if (gewinn <= 0) {
|
||||
// Verlust: keine Steuern, Verlustvortrag aufbauen
|
||||
verlustvortrag += Math.abs(gewinn)
|
||||
gewerbesteuer[k] = 0
|
||||
koerperschaftsteuer[k] = 0
|
||||
steuernGesamt[k] = 0
|
||||
ergebnisNachSteuern[k] = Math.round(gewinn)
|
||||
} else {
|
||||
// Gewinn: Verlustvortrag verrechnen
|
||||
// Bis 1 Mio EUR: 100% verrechenbar
|
||||
// Über 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
|
||||
// Ueber 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
|
||||
let verrechenbar = 0
|
||||
if (verlustvortrag > 0) {
|
||||
if (gewinn <= 1000000) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Liquiditaet — rolling cash balance computation
|
||||
*
|
||||
* Computes operative Einzahlungen/Auszahlungen sums,
|
||||
* Überschuss vor Investitionen/Entnahmen, and rolling Kontostand.
|
||||
* Computes Einzahlungen/Auszahlungen sums (dynamic row_type-based),
|
||||
* Ueberschuss vor Investitionen/Entnahmen, and rolling Kontostand/Liquiditaet.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
@@ -58,52 +58,57 @@ export async function computeLiquiditaet(
|
||||
liqInvest.values = ctx.totalInvest
|
||||
}
|
||||
|
||||
// Compute sums and rolling balance
|
||||
const sumEin = findLiq('Summe EINZAHLUNGEN')
|
||||
const sumAus = findLiq('Summe AUSZAHLUNGEN')
|
||||
const uebVorInv = findLiq('ÜBERSCHUSS VOR INVESTITIONEN')
|
||||
const uebVorEnt = findLiq('ÜBERSCHUSS VOR ENTNAHMEN')
|
||||
const ueberschuss = findLiq('ÜBERSCHUSS')
|
||||
const kontostand = findLiq('Kontostand zu Beginn des Monats')
|
||||
const liquiditaet = findLiq('LIQUIDITÄT')
|
||||
// Compute sums and rolling balance — dynamic row_type-based (handles any label conventions)
|
||||
await computeRollingBalance(pool, liquid)
|
||||
|
||||
// Dynamically categorize rows by row_type
|
||||
const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen']
|
||||
const finanzierungRows = liquid.filter(r =>
|
||||
r.row_type === 'einzahlung' &&
|
||||
!einzahlungenOperativ.includes(r.row_label) &&
|
||||
!r.row_label.includes('Summe')
|
||||
)
|
||||
const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer']
|
||||
const finanzAuszahlungRows = liquid.filter(r =>
|
||||
r.row_type === 'auszahlung' &&
|
||||
!auszahlungenOperativ.includes(r.row_label) &&
|
||||
!r.row_label.includes('Summe')
|
||||
)
|
||||
return { endstand: liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))?.values || emptyMonthly() }
|
||||
}
|
||||
|
||||
// Summe EINZAHLUNGEN = nur operativ
|
||||
/**
|
||||
* Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance.
|
||||
* Called both in initial pass and after tax values are written from GuV.
|
||||
*/
|
||||
export async function computeRollingBalance(
|
||||
pool: Pool,
|
||||
liquid: FPLiquiditaet[],
|
||||
): Promise<void> {
|
||||
const findLiqMatch = (options: string[]) => liquid.find(r => options.includes(r.row_label))
|
||||
|
||||
const sumEin = findLiqMatch(['Summe ERTRÄGE', 'Summe EINZAHLUNGEN'])
|
||||
const sumAus = findLiqMatch(['Summe AUSZAHLUNGEN'])
|
||||
const uebVorInv = findLiqMatch(['ÜBERSCHUSS VOR INVESTITIONEN', 'UEBERSCHUSS VOR INVESTITIONEN'])
|
||||
const uebVorEnt = findLiqMatch(['ÜBERSCHUSS VOR ENTNAHMEN', 'UEBERSCHUSS VOR ENTNAHMEN'])
|
||||
const ueberschuss = findLiqMatch(['ÜBERSCHUSS', 'UEBERSCHUSS'])
|
||||
const liqInvest = liquid.find(r => r.row_label === 'Investitionen')
|
||||
// Kontostand: label varies per scenario (with/without parentheses)
|
||||
const kontostand = liquid.find(r => r.row_type === 'kontostand' && !r.row_label.includes('LIQUIDIT'))
|
||||
const liquiditaet = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))
|
||||
|
||||
// Summe ERTRAEGE = ALL einzahlungen (dynamic — works regardless of how many rows exist)
|
||||
if (sumEin) {
|
||||
const s = emptyMonthly()
|
||||
for (const label of einzahlungenOperativ) {
|
||||
const row = findLiq(label)
|
||||
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||
for (const row of liquid) {
|
||||
if (row.row_type === 'einzahlung' && row.id !== sumEin.id) {
|
||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||
}
|
||||
}
|
||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
|
||||
sumEin.values = s
|
||||
}
|
||||
|
||||
// Summe AUSZAHLUNGEN = nur operativ
|
||||
// Summe AUSZAHLUNGEN = ALL auszahlungen (dynamic)
|
||||
if (sumAus) {
|
||||
const s = emptyMonthly()
|
||||
for (const label of auszahlungenOperativ) {
|
||||
const row = findLiq(label)
|
||||
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||
for (const row of liquid) {
|
||||
if (row.row_type === 'auszahlung' && row.id !== sumAus.id) {
|
||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||
}
|
||||
}
|
||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
|
||||
sumAus.values = s
|
||||
}
|
||||
|
||||
// OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN
|
||||
// UEBERSCHUSS VOR INVESTITIONEN = Summe ERTRAEGE - Summe AUSZAHLUNGEN (total cashflow)
|
||||
if (uebVorInv && sumEin && sumAus) {
|
||||
const s = emptyMonthly()
|
||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
|
||||
@@ -111,7 +116,7 @@ export async function computeLiquiditaet(
|
||||
uebVorInv.values = s
|
||||
}
|
||||
|
||||
// ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen
|
||||
// UEBERSCHUSS VOR ENTNAHMEN = UEBERSCHUSS VOR INVESTITIONEN - Investitionen
|
||||
if (uebVorEnt && uebVorInv && liqInvest) {
|
||||
const s = emptyMonthly()
|
||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
|
||||
@@ -119,8 +124,8 @@ export async function computeLiquiditaet(
|
||||
uebVorEnt.values = s
|
||||
}
|
||||
|
||||
// ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen
|
||||
const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen')
|
||||
// UEBERSCHUSS = UEBERSCHUSS VOR ENTNAHMEN - Kapitalentnahmen
|
||||
const entnahmen = findLiqMatch(['Kapitalentnahmen/Ausschüttungen', 'Kapitalentnahmen/Ausschuettungen'])
|
||||
if (ueberschuss && uebVorEnt && entnahmen) {
|
||||
const s = emptyMonthly()
|
||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
|
||||
@@ -128,27 +133,18 @@ export async function computeLiquiditaet(
|
||||
ueberschuss.values = s
|
||||
}
|
||||
|
||||
// Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung
|
||||
// Rolling balance: LIQUIDITAET[m] = LIQUIDITAET[m-1] + UEBERSCHUSS[m]
|
||||
// UEBERSCHUSS now includes ALL cash flows (operative + financing + repayments)
|
||||
if (kontostand && liquiditaet && ueberschuss) {
|
||||
const finCF = emptyMonthly()
|
||||
for (const row of finanzierungRows) {
|
||||
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||
}
|
||||
for (const row of finanzAuszahlungRows) {
|
||||
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] -= Math.round(row.values[`m${m}`] || 0)
|
||||
}
|
||||
|
||||
const ks = emptyMonthly()
|
||||
const lq = emptyMonthly()
|
||||
for (let m = 1; m <= MONTHS; m++) {
|
||||
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
|
||||
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0) + (finCF[`m${m}`] || 0))
|
||||
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0))
|
||||
}
|
||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
|
||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
|
||||
kontostand.values = ks
|
||||
liquiditaet.values = lq
|
||||
}
|
||||
|
||||
return { endstand: liquiditaet?.values || emptyMonthly() }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
MonthlyValues, MONTHS, FOUNDING_MONTH,
|
||||
MonthlyValues, MONTHS,
|
||||
emptyMonthly, dateToMonth, monthToDate,
|
||||
FPPersonalkosten, FPInvestitionen,
|
||||
} from './types'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user