fix(pitch-print): cover layout, Finanzplan data source, target_date
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
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
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>
This commit is contained in:
@@ -10,93 +10,103 @@ export function PrintCoverPage({ company, funding, lang, versionName }: { compan
|
||||
const instrument = funding?.instrument || 'Pre-Seed'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
const tagline = de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')
|
||||
const amountLabel = amount >= 1_000_000
|
||||
? '€' + (amount / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
: '€' + Math.round(amount / 1_000) + 'k'
|
||||
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page" style={{ width: '297mm', height: '210mm', background: '#ffffff', color: COLORS.slate900, fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", display: 'flex', boxSizing: 'border-box', margin: '0 auto 24px', boxShadow: '0 4px 24px rgba(15,23,42,0.10)', overflow: 'hidden', padding: 0 }}>
|
||||
<div className="print-page" style={{ width: '297mm', height: '210mm', background: '#ffffff', color: COLORS.slate900, fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", boxSizing: 'border-box', margin: '0 auto 24px', boxShadow: '0 4px 24px rgba(15,23,42,0.10)', overflow: 'hidden', padding: 0 }}>
|
||||
{/*
|
||||
The .print-page class in print.css forces flex-direction: column !important,
|
||||
which would collapse a horizontal flex split. We sidestep that by putting a
|
||||
single full-size grid container as the only child — the column-flex parent
|
||||
has just one item so direction no longer matters.
|
||||
*/}
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: '95mm 1fr' }}>
|
||||
|
||||
{/* LEFT INDIGO BLOCK */}
|
||||
<div style={{
|
||||
width: '95mm',
|
||||
background: COLORS.indigo600,
|
||||
color: '#ffffff',
|
||||
padding: '16mm 12mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)' }}>
|
||||
{de ? 'Investor Brief' : 'Investor Brief'}
|
||||
{/* LEFT INDIGO BLOCK */}
|
||||
<div style={{
|
||||
background: COLORS.indigo600,
|
||||
color: '#ffffff',
|
||||
padding: '16mm 12mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.75)' }}>
|
||||
{de ? 'Investor Brief' : 'Investor Brief'}
|
||||
</div>
|
||||
<div style={{ marginTop: '6mm', height: '1px', background: 'rgba(255,255,255,0.35)', width: '32mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '6mm', fontSize: '11pt', color: '#ffffff', lineHeight: 1.5, fontWeight: 500 }}>
|
||||
{de
|
||||
? 'DSGVO-konforme KI-Plattform für kontinuierliche Code-Security und automatisierte Compliance. Souverän gehostet, integriert in europäische Workflows.'
|
||||
: 'GDPR-compliant AI platform for continuous code security and automated compliance. Sovereign-hosted, integrated into European workflows.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '6mm', height: '1px', background: 'rgba(255,255,255,0.3)', width: '32mm' }} />
|
||||
<div style={{ marginTop: '6mm', fontSize: '11pt', color: 'rgba(255,255,255,0.85)', lineHeight: 1.55, fontWeight: 500 }}>
|
||||
{de
|
||||
? 'DSGVO-konforme KI-Plattform für kontinuierliche Code-Security und automatisierte Compliance. Souverän gehostet, integriert in europäische Workflows.'
|
||||
: 'GDPR-compliant AI platform for continuous code security and automated compliance. Sovereign-hosted, integrated into European workflows.'}
|
||||
|
||||
{/* Mid stats */}
|
||||
<div style={{ paddingTop: '8mm', borderTop: '1px solid rgba(255,255,255,0.25)' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.75)', marginBottom: '4mm' }}>{de ? 'Auf einen Blick' : 'At a glance'}</div>
|
||||
<div style={{ fontSize: '9.5pt', color: '#ffffff', lineHeight: 1.7, fontWeight: 500 }}>
|
||||
{de ? '25 000 + atomare Prüfaspekte' : '25 000 + atomic audit aspects'}<br />
|
||||
{de ? '380 + Regularien · 10 Branchen' : '380 + regulations · 10 industries'}<br />
|
||||
{de ? '500 K + Lines of Code · 45 Container' : '500 K + lines of code · 45 containers'}<br />
|
||||
{de ? '100 % EU-Hosting · BSI Cloud DE' : '100 % EU hosting · BSI cloud DE'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.6)', fontWeight: 700 }}>{versionName}</div>
|
||||
<div style={{ marginTop: '2mm', fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.6)', fontWeight: 700 }}>{de ? 'Vertraulich · Nur Investoren' : 'Confidential · Investors only'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero stat */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)' }}>
|
||||
{de ? '25 000 + atomare Prüfaspekte' : '25 000 + atomic audit aspects'}
|
||||
{/* RIGHT WHITE PANE */}
|
||||
<div style={{ padding: '18mm 16mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minWidth: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '10pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.16em' }}>
|
||||
{instrument} · Q4 2026
|
||||
</div>
|
||||
<h1 style={{ fontSize: '60pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.95, letterSpacing: '-0.03em', margin: '8mm 0 4mm' }}>
|
||||
{company?.name || 'BreakPilot'}<span style={{ color: COLORS.indigo600 }}>.</span>
|
||||
</h1>
|
||||
<div style={{ height: '2px', width: '40mm', background: COLORS.indigo600, marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<p style={{ fontSize: '15pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, maxWidth: '170mm', margin: 0, letterSpacing: '-0.008em' }}>
|
||||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)', marginTop: '1.5mm' }}>
|
||||
{de ? '380 + Regularien · 10 Branchen' : '380 + regulations · 10 industries'}
|
||||
</div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)', marginTop: '1.5mm' }}>
|
||||
{de ? '500 K + Lines of Code · 45 Container' : '500 K + lines of code · 45 containers'}
|
||||
|
||||
{/* Key terms */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm', paddingBottom: '2mm', borderBottom: `1px solid ${COLORS.slate200}` }}>
|
||||
{de ? 'Key Terms' : 'Key terms'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm' }}>
|
||||
{([
|
||||
[de ? 'Funding' : 'Funding', amountLabel],
|
||||
[de ? 'Pre-Money' : 'Pre-money', '€4.0M'],
|
||||
[de ? 'Instrument' : 'Instrument', instrument],
|
||||
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman'],
|
||||
] as [string, string][]).map(([label, val]) => (
|
||||
<div key={label}>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{label}</div>
|
||||
<div style={{ fontSize: '16pt', fontWeight: 800, color: COLORS.slate900, marginTop: '1.5mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em', lineHeight: 1.1 }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '5mm', fontSize: '8pt', color: COLORS.slate500, lineHeight: 1.5 }}>
|
||||
{de
|
||||
? 'Gründerteam Benjamin Bönisch (CEO) und Sharang Parnerkar (CTO). Markeneintragung DPMA · EUIPO-Anmeldung in Bearbeitung · GmbH-Gründung August 2026.'
|
||||
: 'Founding team Benjamin Bönisch (CEO) and Sharang Parnerkar (CTO). Trademark DPMA registered · EUIPO filing in progress · GmbH incorporation August 2026.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: version + confidential */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)', fontWeight: 600 }}>{versionName}</div>
|
||||
<div style={{ marginTop: '2mm', fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)', fontWeight: 600 }}>{de ? 'Vertraulich · Nur Investoren' : 'Confidential · Investors only'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT WHITE PANE */}
|
||||
<div style={{ flex: 1, padding: '16mm 16mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '10pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.16em' }}>
|
||||
{instrument} · Q4 2026
|
||||
</div>
|
||||
<h1 style={{ fontSize: '88pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.92, letterSpacing: '-0.035em', margin: '8mm 0 6mm' }}>
|
||||
{company?.name || 'BreakPilot'}<span style={{ color: COLORS.indigo600 }}>.</span>
|
||||
</h1>
|
||||
<div style={{ height: '2px', width: '40mm', background: COLORS.indigo600, marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<p style={{ fontSize: '16pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, maxWidth: '160mm', margin: 0, letterSpacing: '-0.008em' }}>
|
||||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key terms */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm', paddingBottom: '2mm', borderBottom: `1px solid ${COLORS.slate200}` }}>
|
||||
{de ? 'Key Terms' : 'Key terms'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6mm' }}>
|
||||
{([
|
||||
[de ? 'Funding' : 'Funding', '€' + (amount / 1_000_000).toFixed(1) + 'M'],
|
||||
[de ? 'Pre-Money' : 'Pre-money', '€4.0M'],
|
||||
[de ? 'Instrument' : 'Instrument', instrument],
|
||||
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman'],
|
||||
] as [string, string][]).map(([label, val]) => (
|
||||
<div key={label}>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{label}</div>
|
||||
<div style={{ fontSize: '18pt', fontWeight: 800, color: COLORS.slate900, marginTop: '1.5mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '5mm', fontSize: '8pt', color: COLORS.slate500, lineHeight: 1.5 }}>
|
||||
{de
|
||||
? 'Gründerteam Benjamin Bönisch (CEO) und Sharang Parnerkar (CTO). Markeneintragung DPMA · EUIPO-Anmeldung in Bearbeitung · GmbH-Gründung August 2026.'
|
||||
: 'Founding team Benjamin Bönisch (CEO) and Sharang Parnerkar (CTO). Trademark DPMA registered · EUIPO filing in progress · GmbH incorporation August 2026.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +217,25 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
|
||||
|
||||
/* ===== THE ASK ===== */
|
||||
|
||||
function formatTargetDate(raw: string | undefined, de: boolean): string {
|
||||
if (!raw) return de ? 'Q3 2026' : 'Q3 2026'
|
||||
// Accept ISO timestamps, ISO dates, or already-formatted strings.
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return raw
|
||||
const months = de
|
||||
? ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
function formatFunding(amount: number): string {
|
||||
if (amount >= 1_000_000) {
|
||||
const m = amount / 1_000_000
|
||||
return '€' + (m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)) + 'M'
|
||||
}
|
||||
return '€' + Math.round(amount / 1_000) + 'k'
|
||||
}
|
||||
|
||||
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
@@ -230,7 +249,7 @@ export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionNam
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="14" section={de ? 'THE ASK' : 'THE ASK'} title={de ? `Pre-Seed: ${fmtEur(amount, true)} via ${instrument}.` : `Pre-Seed: ${fmtEur(amount, false)} via ${instrument.toLowerCase()}.`} subtitle={de ? '18 Monate Runway zur Profitabilität. Use of Funds in 5 Buckets. INVEST-Zuschuss 20% rückzahlbar.' : '18-month runway to profitability. Use of funds across 5 buckets. 20% INVEST grant eligible.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="14" section={de ? 'THE ASK' : 'THE ASK'} title={de ? `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument}.` : `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument.toLowerCase()}.`} subtitle={de ? '18 Monate Runway zur Profitabilität. Use of Funds in 5 Buckets. INVEST-Zuschuss 20% rückzahlbar.' : '18-month runway to profitability. Use of funds across 5 buckets. 20% INVEST grant eligible.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
{/* Hero amount */}
|
||||
@@ -238,9 +257,9 @@ export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionNam
|
||||
<div style={{ borderLeft: `3px solid ${COLORS.indigo600}`, paddingLeft: '5mm', marginBottom: '6mm' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{de ? 'Funding' : 'Funding'}</div>
|
||||
<div style={{ fontSize: '54pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, letterSpacing: '-0.03em', fontVariantNumeric: 'tabular-nums', marginTop: '2mm' }}>
|
||||
€{(amount / 1_000_000).toFixed(1)}M
|
||||
{formatFunding(amount)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10pt', color: COLORS.slate600, marginTop: '3mm' }}>{instrument} · {funding?.round_name || 'Pre-Seed'} · {de ? 'Zielabschluss' : 'Target close'}: {funding?.target_date || 'Q3 2026'}</div>
|
||||
<div style={{ fontSize: '10pt', color: COLORS.slate600, marginTop: '3mm' }}>{instrument} · {funding?.round_name || 'Pre-Seed'} · {de ? 'Zielabschluss' : 'Target close'}: {formatTargetDate(funding?.target_date, de)}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm' }}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PitchCompetitor, PitchFeature, PitchMilestone, PitchMetric, PitchFunding, PitchProduct,
|
||||
FpScenarioRef, FMResult, FMAssumption,
|
||||
} from '@/lib/types'
|
||||
import { finanzplanToFMResults } from '@/lib/finanzplan/adapter'
|
||||
import PrintDeck from './_components/PrintDeck'
|
||||
|
||||
interface Ctx {
|
||||
@@ -68,17 +69,29 @@ export default async function PitchPrintPage({ params, searchParams }: Ctx) {
|
||||
// Always fetch FM results + assumptions so the standard PDF can render the
|
||||
// annex-finanzplan slide. The `financial` flag only adds the extra detail
|
||||
// P&L page and the cap-table page.
|
||||
//
|
||||
// Data source: the live `fp_*` tables (same as the interactive deck), bridged
|
||||
// to FMResult[] via finanzplanToFMResults. The legacy `pitch_fm_results` table
|
||||
// is no longer populated by the current pipeline.
|
||||
let fmResults: FMResult[] = []
|
||||
let fmAssumptions: FMAssumption[] = []
|
||||
|
||||
const scenarios = (map.fm_scenarios || []) as FpScenarioRef[]
|
||||
const defaultScenario = scenarios.find(s => s.is_default) ?? scenarios[0] ?? null
|
||||
if (defaultScenario?.id) {
|
||||
const resultsRes = await pool.query(
|
||||
`SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month`,
|
||||
[defaultScenario.id],
|
||||
)
|
||||
fmResults = resultsRes.rows as FMResult[]
|
||||
// Snapshot stores fp_scenario IDs under `fm_scenarios`; fall back to the live
|
||||
// default fp scenario if the snapshot is empty (older versions).
|
||||
let scenarioId: string | null = defaultScenario?.id ? String(defaultScenario.id) : null
|
||||
if (!scenarioId) {
|
||||
const liveRes = await pool.query(`SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1`)
|
||||
scenarioId = liveRes.rows[0]?.id ? String(liveRes.rows[0].id) : null
|
||||
}
|
||||
if (scenarioId) {
|
||||
try {
|
||||
const fpResponse = await finanzplanToFMResults(pool, scenarioId)
|
||||
fmResults = fpResponse.results
|
||||
} catch {
|
||||
fmResults = []
|
||||
}
|
||||
}
|
||||
|
||||
const rawAssumptions = (map.fm_assumptions || []) as Array<Record<string, unknown>>
|
||||
|
||||
@@ -17,18 +17,20 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
}
|
||||
|
||||
// Load computed data
|
||||
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes] = await Promise.all([
|
||||
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 {
|
||||
@@ -92,9 +94,9 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
month: m,
|
||||
year,
|
||||
month_in_year: month,
|
||||
new_customers: 0,
|
||||
new_customers: Math.max((customersByMonth[`m${m}`] || 0) - prevCustomers, 0),
|
||||
churned_customers: 0,
|
||||
total_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),
|
||||
@@ -113,6 +115,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
cash_balance_eur: Math.round(cash),
|
||||
cumulative_revenue_eur: Math.round(cumulativeRevenue),
|
||||
})
|
||||
prevCustomers = customersByMonth[`m${m}`] || 0
|
||||
}
|
||||
|
||||
// Summary
|
||||
|
||||
Reference in New Issue
Block a user