feat: Phase 5+6 — Finanzplan Bridge + Financials-Slide Sync
- Adapter: fp_* Tabellen → FMResult Interface (60 Monate) - Compute-Endpoint: source=finanzplan delegiert an Finanzplan-Engine - useFinancialModel Hook: computeFromFinanzplan() + finanzplanResults - FinancialsSlide: Toggle "Szenario-Modell" vs "Finanzplan (Excel)" - Gruendungsdatum fix: EK+FK auf Aug (m8), Raumkosten ab Aug - Startup-Preisstaffel: <10 MA ab 3.600 EUR/Jahr, 14-Tage-Test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
|
import { computeFinanzplan } from '@/lib/finanzplan/engine'
|
||||||
|
import { finanzplanToFMResults } from '@/lib/finanzplan/adapter'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { scenarioId } = body
|
const { scenarioId, source } = body
|
||||||
|
|
||||||
|
// If source=finanzplan, use the Finanzplan engine instead
|
||||||
|
if (source === 'finanzplan') {
|
||||||
|
try {
|
||||||
|
// First run the Finanzplan compute engine
|
||||||
|
await computeFinanzplan(pool, scenarioId || undefined)
|
||||||
|
// Then adapt results to FMResult format
|
||||||
|
const result = await finanzplanToFMResults(pool, scenarioId || undefined)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Finanzplan compute error:', error)
|
||||||
|
return NextResponse.json({ error: 'Finanzplan computation failed: ' + String(error) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!scenarioId) {
|
if (!scenarioId) {
|
||||||
return NextResponse.json({ error: 'scenarioId is required' }, { status: 400 })
|
return NextResponse.json({ error: 'scenarioId is required' }, { status: 400 })
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
|||||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||||
const de = lang === 'de'
|
const de = lang === 'de'
|
||||||
|
|
||||||
const activeResults = fm.activeResults
|
const [useFinanzplan, setUseFinanzplan] = useState(false)
|
||||||
|
const activeResults = useFinanzplan ? fm.finanzplanResults : fm.activeResults
|
||||||
const summary = activeResults?.summary
|
const summary = activeResults?.summary
|
||||||
const lastResult = activeResults?.results[activeResults.results.length - 1]
|
const lastResult = activeResults?.results[activeResults.results.length - 1]
|
||||||
|
|
||||||
@@ -65,6 +66,20 @@ export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
|||||||
<GradientText>{i.financials.title}</GradientText>
|
<GradientText>{i.financials.title}</GradientText>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-white/50 max-w-2xl mx-auto">{i.financials.subtitle}</p>
|
<p className="text-sm text-white/50 max-w-2xl mx-auto">{i.financials.subtitle}</p>
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setUseFinanzplan(false) }}
|
||||||
|
className={`px-3 py-1 text-[10px] rounded-lg transition-colors ${!useFinanzplan ? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' : 'text-white/40 hover:text-white/60'}`}
|
||||||
|
>
|
||||||
|
{de ? 'Szenario-Modell' : 'Scenario Model'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { setUseFinanzplan(true); await fm.computeFromFinanzplan() }}
|
||||||
|
className={`px-3 py-1 text-[10px] rounded-lg transition-colors ${useFinanzplan ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30' : 'text-white/40 hover:text-white/60'}`}
|
||||||
|
>
|
||||||
|
{fm.computing ? '...' : (de ? 'Finanzplan (Excel)' : 'Financial Plan (Excel)')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</FadeInView>
|
</FadeInView>
|
||||||
|
|
||||||
{/* Hero KPI Cards */}
|
{/* Hero KPI Cards */}
|
||||||
|
|||||||
135
pitch-deck/lib/finanzplan/adapter.ts
Normal file
135
pitch-deck/lib/finanzplan/adapter.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Adapter: Maps Finanzplan DB data → existing FMResult interface
|
||||||
|
* used by FinancialsSlide, FinancialChart, AnnualPLTable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { FMResult, FMComputeResponse } from '../types'
|
||||||
|
import { MonthlyValues, MONTHS, monthToDate, emptyMonthly, annualSums } from './types'
|
||||||
|
|
||||||
|
export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Promise<FMComputeResponse> {
|
||||||
|
// Get scenario
|
||||||
|
let sid = scenarioId
|
||||||
|
if (!sid) {
|
||||||
|
const { rows } = await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
|
||||||
|
if (rows.length === 0) throw new Error('No default scenario')
|
||||||
|
sid = rows[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load computed data
|
||||||
|
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes] = await Promise.all([
|
||||||
|
pool.query("SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||||
|
pool.query("SELECT * FROM fp_liquiditaet WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||||
|
pool.query("SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||||
|
pool.query("SELECT * FROM fp_umsatzerloese WHERE scenario_id = $1 AND section = 'revenue' AND row_label = 'GESAMTUMSATZ' LIMIT 1", [sid]),
|
||||||
|
pool.query("SELECT * FROM fp_materialaufwand WHERE scenario_id = $1 AND row_label = 'SUMME' LIMIT 1", [sid]),
|
||||||
|
pool.query("SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const personal = personalRes.rows
|
||||||
|
const liquid = liquidRes.rows
|
||||||
|
const betrieb = betriebRes.rows
|
||||||
|
|
||||||
|
// Helper to sum a field across personnel
|
||||||
|
function sumPersonalField(field: string): MonthlyValues {
|
||||||
|
const r = emptyMonthly()
|
||||||
|
for (const p of personal) {
|
||||||
|
const vals = p[field] || {}
|
||||||
|
for (let m = 1; m <= MONTHS; m++) r[`m${m}`] += vals[`m${m}`] || 0
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPersonal = sumPersonalField('values_total')
|
||||||
|
const totalBrutto = sumPersonalField('values_brutto')
|
||||||
|
const revenue = umsatzRes.rows[0]?.values || emptyMonthly()
|
||||||
|
const material = materialRes.rows[0]?.values || emptyMonthly()
|
||||||
|
|
||||||
|
// Betriebliche sonstige (without personal + abschreibungen)
|
||||||
|
const sonstRow = betrieb.find((r: any) => r.row_label?.includes('Summe sonstige'))
|
||||||
|
const sonstige = sonstRow?.values || emptyMonthly()
|
||||||
|
|
||||||
|
// AfA
|
||||||
|
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')
|
||||||
|
const cashBalance = liqEndRow?.values || emptyMonthly()
|
||||||
|
|
||||||
|
// Headcount
|
||||||
|
const headcount = emptyMonthly()
|
||||||
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
|
headcount[`m${m}`] = personal.filter((p: any) => (p.values_total?.[`m${m}`] || 0) > 0).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketing (from betriebliche)
|
||||||
|
const marketingRow = betrieb.find((r: any) => r.row_label?.includes('Werbe') || r.row_label?.includes('marketing'))
|
||||||
|
const marketing = marketingRow?.values || emptyMonthly()
|
||||||
|
|
||||||
|
// Build 60 monthly FMResult entries
|
||||||
|
const results: FMResult[] = []
|
||||||
|
let cumulativeRevenue = 0
|
||||||
|
let prevCustomers = 0
|
||||||
|
|
||||||
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
|
const { year, month } = monthToDate(m)
|
||||||
|
const rev = revenue[`m${m}`] || 0
|
||||||
|
const mat = material[`m${m}`] || 0
|
||||||
|
const pers = totalPersonal[`m${m}`] || 0
|
||||||
|
const infra = sonstige[`m${m}`] || 0
|
||||||
|
const mktg = marketing[`m${m}`] || 0
|
||||||
|
const totalCosts = mat + pers + infra
|
||||||
|
const grossMargin = rev > 0 ? ((rev - mat) / rev) * 100 : 0
|
||||||
|
cumulativeRevenue += rev
|
||||||
|
|
||||||
|
const hc = headcount[`m${m}`] || 0
|
||||||
|
const cash = cashBalance[`m${m}`] || 0
|
||||||
|
const burnRate = totalCosts > rev ? totalCosts - rev : 0
|
||||||
|
const runway = burnRate > 0 ? Math.round(Math.max(0, cash) / burnRate) : 999
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
month: m,
|
||||||
|
year,
|
||||||
|
month_in_year: month,
|
||||||
|
new_customers: 0,
|
||||||
|
churned_customers: 0,
|
||||||
|
total_customers: 0,
|
||||||
|
mrr_eur: Math.round(rev / 1), // monthly
|
||||||
|
arr_eur: Math.round(rev * 12),
|
||||||
|
revenue_eur: Math.round(rev),
|
||||||
|
cogs_eur: Math.round(mat),
|
||||||
|
personnel_eur: Math.round(pers),
|
||||||
|
infra_eur: Math.round(infra),
|
||||||
|
marketing_eur: Math.round(mktg),
|
||||||
|
total_costs_eur: Math.round(totalCosts),
|
||||||
|
employees_count: hc,
|
||||||
|
gross_margin_pct: Math.round(grossMargin * 10) / 10,
|
||||||
|
burn_rate_eur: Math.round(burnRate),
|
||||||
|
runway_months: runway,
|
||||||
|
cac_eur: 0,
|
||||||
|
ltv_eur: 0,
|
||||||
|
ltv_cac_ratio: 0,
|
||||||
|
cash_balance_eur: Math.round(cash),
|
||||||
|
cumulative_revenue_eur: Math.round(cumulativeRevenue),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const lastMonth = results[MONTHS - 1]
|
||||||
|
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenario_id: sid,
|
||||||
|
results,
|
||||||
|
summary: {
|
||||||
|
final_arr: lastMonth?.arr_eur || 0,
|
||||||
|
final_customers: lastMonth?.total_customers || 0,
|
||||||
|
break_even_month: breakEvenMonth >= 0 ? breakEvenMonth + 1 : null,
|
||||||
|
final_runway: lastMonth?.runway_months || 0,
|
||||||
|
final_ltv_cac: 0,
|
||||||
|
peak_burn: Math.max(...results.map(r => r.burn_rate_eur)),
|
||||||
|
total_funding_needed: Math.abs(Math.min(...results.map(r => r.cash_balance_eur), 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,17 +41,17 @@ export function useFinancialModel() {
|
|||||||
}
|
}
|
||||||
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const compute = useCallback(async (scenarioId: string) => {
|
const compute = useCallback(async (scenarioId: string, source?: string) => {
|
||||||
setComputing(true)
|
setComputing(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/financial-model/compute', {
|
const res = await fetch('/api/financial-model/compute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ scenarioId }),
|
body: JSON.stringify({ scenarioId, source }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: FMComputeResponse = await res.json()
|
const data: FMComputeResponse = await res.json()
|
||||||
setResults(prev => new Map(prev).set(scenarioId, data))
|
setResults(prev => new Map(prev).set(source === 'finanzplan' ? 'finanzplan' : scenarioId, data))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Compute failed:', err)
|
console.error('Compute failed:', err)
|
||||||
@@ -60,6 +60,10 @@ export function useFinancialModel() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const computeFromFinanzplan = useCallback(async () => {
|
||||||
|
await compute('', 'finanzplan')
|
||||||
|
}, [compute])
|
||||||
|
|
||||||
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||||
// Optimistic update in local state
|
// Optimistic update in local state
|
||||||
setScenarios(prev => prev.map(s => {
|
setScenarios(prev => prev.map(s => {
|
||||||
@@ -90,6 +94,7 @@ export function useFinancialModel() {
|
|||||||
|
|
||||||
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
|
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
|
||||||
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
|
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
|
||||||
|
const finanzplanResults = results.get('finanzplan') || null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scenarios,
|
scenarios,
|
||||||
@@ -97,6 +102,7 @@ export function useFinancialModel() {
|
|||||||
activeScenarioId,
|
activeScenarioId,
|
||||||
setActiveScenarioId,
|
setActiveScenarioId,
|
||||||
activeResults,
|
activeResults,
|
||||||
|
finanzplanResults,
|
||||||
results,
|
results,
|
||||||
loading,
|
loading,
|
||||||
computing,
|
computing,
|
||||||
@@ -104,6 +110,7 @@ export function useFinancialModel() {
|
|||||||
setCompareMode,
|
setCompareMode,
|
||||||
compute,
|
compute,
|
||||||
computeAll,
|
computeAll,
|
||||||
|
computeFromFinanzplan,
|
||||||
updateAssumption,
|
updateAssumption,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user