Files
Sharang Parnerkar f30ac73b79
Build pitch-deck / build-push-deploy (push) Successful in 1m34s
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 30s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 28s
fix(pitch-print): cover layout, Finanzplan data source, target_date
Three critical fixes after reviewing the rendered PDF:

Cover (was: indigo block collapsed to top, white content stacked below):
- The .print-page class in print.css forces flex-direction: column !important,
  which broke the horizontal split. Wrap the cover content in a single grid
  container — the column-flex parent then has only one child so direction is
  irrelevant. Indigo block now runs full-height on the left.
- Title reduced 88pt -> 60pt so "BreakPilot ComplAI." fits without wrapping.
- Funding amount formatter now handles sub-€1M cases (€200k vs €0.2M).

Finanzplan (was: "nicht verfügbar" on both pages 20-21):
- page.tsx was querying the legacy pitch_fm_results table which isn't populated
  by the current pipeline. The interactive deck reads from fp_* tables.
- Wire in lib/finanzplan/adapter.ts (finanzplanToFMResults) which bridges the
  live fp_* tables to FMResult[] — same source the interactive deck uses.
- Fall back to live default fp_scenario if the version snapshot's fm_scenarios
  is empty.
- adapter.ts: populate total_customers + new_customers from fp_kunden_summary
  (was hardcoded 0).

The Ask:
- target_date was rendering as raw ISO timestamp "2026-08-01T00:00:00.000Z";
  now formatted as "Aug 2026" (locale-aware).
- Hero funding amount uses same sub-€1M formatter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:01:53 +02:00

139 lines
5.6 KiB
TypeScript

/**
* 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, kundenRes] = 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]),
pool.query("SELECT * FROM fp_kunden_summary WHERE scenario_id = $1 AND row_label = 'Bestandskunden gesamt' LIMIT 1", [sid]),
])
const personal = personalRes.rows
const liquid = liquidRes.rows
const betrieb = betriebRes.rows
const customersByMonth = (kundenRes.rows[0]?.values as MonthlyValues) || emptyMonthly()
// 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 — 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
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: Math.max((customersByMonth[`m${m}`] || 0) - prevCustomers, 0),
churned_customers: 0,
total_customers: Math.round(customersByMonth[`m${m}`] || 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),
})
prevCustomers = customersByMonth[`m${m}`] || 0
}
// Summary
const lastMonth = results[MONTHS - 1]
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
return {
scenario_id: sid as string,
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)),
},
}
}