feat: Finanzplan Phase 1-4 — DB + Engine + API + Spreadsheet-UI

Phase 1: DB-Schema (12 fp_* Tabellen) + Excel-Import (332 Zeilen importiert)
Phase 2: Compute Engine (Personal, Invest, Umsatz, Material, Betrieblich, Liquiditaet, GuV)
Phase 3: API (/api/finanzplan/ — GET sheets, PUT cells, POST compute)
Phase 4: Spreadsheet-UI (FinanzplanSlide als Annex mit Tab-Leiste, editierbarem Grid, Jahres-Navigation)

Zusaetzlich:
- Gruendungsdatum verschoben: Feb→Aug 2026 (DB + Personalkosten)
- Neue Preisstaffel: Startup/<10 MA ab 3.600 EUR/Jahr (14-Tage-Test, Kreditkarte)
- Competition-Slide: Pricing-Tiers aktualisiert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 19:26:46 +01:00
parent f514667ef9
commit a58cd16f01
16 changed files with 4589 additions and 5 deletions

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
const TABLE_MAP: Record<string, string> = {
kunden: 'fp_kunden',
kunden_summary: 'fp_kunden_summary',
umsatzerloese: 'fp_umsatzerloese',
materialaufwand: 'fp_materialaufwand',
personalkosten: 'fp_personalkosten',
betriebliche: 'fp_betriebliche_aufwendungen',
investitionen: 'fp_investitionen',
sonst_ertraege: 'fp_sonst_ertraege',
liquiditaet: 'fp_liquiditaet',
guv: 'fp_guv',
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> }
) {
const { sheetName } = await params
const table = TABLE_MAP[sheetName]
if (!table) {
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
}
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
try {
let query = `SELECT * FROM ${table}`
const params: string[] = []
if (scenarioId) {
query += ' WHERE scenario_id = $1'
params.push(scenarioId)
} else {
query += ' WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)'
}
query += ' ORDER BY sort_order'
const { rows } = await pool.query(query, params)
return NextResponse.json({ sheet: sheetName, rows })
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 })
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> }
) {
const { sheetName } = await params
const table = TABLE_MAP[sheetName]
if (!table) {
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
}
try {
const body = await request.json()
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values
if (!rowId) {
return NextResponse.json({ error: 'rowId required' }, { status: 400 })
}
// Check if updating monthly values (JSONB) or scalar fields
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1))))
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1))))
if (monthlyKeys.length > 0) {
// Update specific months in the values JSONB
const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ')
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
// Use jsonb_set for each key
let updateSql = `UPDATE ${table} SET `
const setClauses: string[] = []
for (const k of monthlyKeys) {
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`)
}
setClauses.push(`updated_at = NOW()`)
updateSql += setClauses.join(', ') + ` WHERE id = $1`
await pool.query(updateSql, [rowId])
}
if (scalarKeys.length > 0) {
// Update scalar columns directly
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
await pool.query(
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
[rowId, ...scalarKeys.map(k => updates[k])]
)
}
// Return updated row
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
return NextResponse.json({ updated: rows[0] })
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { computeFinanzplan } from '@/lib/finanzplan/engine'
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const scenarioId = body.scenarioId
// Get scenario ID
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) {
return NextResponse.json({ error: 'No default scenario found' }, { status: 404 })
}
sid = rows[0].id
}
const result = await computeFinanzplan(pool, sid)
return NextResponse.json({
scenarioId: sid,
computed: true,
summary: {
total_revenue_y2026: result.umsatzerloese.total.m1 !== undefined
? Object.values(result.umsatzerloese.total).reduce((a, b) => a + b, 0)
: 0,
total_costs_y2026: Object.values(result.betriebliche.total_gesamt).reduce((a, b) => a + b, 0),
headcount_m60: result.personalkosten.headcount.m60 || 0,
cash_balance_m60: result.liquiditaet.endstand.m60 || 0,
},
})
} catch (error) {
console.error('Finanzplan compute error:', error)
return NextResponse.json({ error: String(error) }, { status: 500 })
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
import { SHEET_LIST } from '@/lib/finanzplan/types'
export async function GET() {
try {
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
// Get row counts per sheet
const sheets = await Promise.all(
SHEET_LIST.map(async (s) => {
const tableName = `fp_${s.name}`
try {
const { rows } = await pool.query(
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
)
return { ...s, rows: parseInt(rows[0]?.total || '0'), editable_rows: parseInt(rows[0]?.editable || '0') }
} catch {
return s
}
})
)
return NextResponse.json({
sheets,
scenarios: scenarios.rows,
months: { start: '2026-01', end: '2030-12', count: 60, founding: '2026-08' },
})
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 })
}
}

View File

@@ -41,6 +41,7 @@ import RegulatorySlide from './slides/RegulatorySlide'
import EngineeringSlide from './slides/EngineeringSlide'
import AIPipelineSlide from './slides/AIPipelineSlide'
import SDKDemoSlide from './slides/SDKDemoSlide'
import FinanzplanSlide from './slides/FinanzplanSlide'
interface PitchDeckProps {
lang: Language
@@ -163,6 +164,8 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
return <AIPipelineSlide lang={lang} />
case 'annex-sdk-demo':
return <SDKDemoSlide lang={lang} />
case 'annex-finanzplan':
return <FinanzplanSlide lang={lang} />
default:
return null
}

View File

@@ -280,7 +280,8 @@ const PRICING_COMPARISON: CompetitorPricing[] = [
model: 'Cloud (BSI DE / OVH FR)',
publicPricing: true,
tiers: [
{ name: { de: '<50 MA', en: '<50 emp.' }, price: 'ab €1.250/mo', annual: 'ab €15.000/yr', notes: { de: 'Cloud, modular, 84 Regularien', en: 'Cloud, modular, 84 regulations' } },
{ name: { de: 'Startup/<10', en: 'Startup/<10' }, price: 'ab €300/mo', annual: 'ab €3.600/yr', notes: { de: '14-Tage-Test, Kreditkarte', en: '14-day trial, credit card' } },
{ name: { de: '10-50 MA', en: '10-50 emp.' }, price: 'ab €1.250/mo', annual: 'ab €15.000/yr', notes: { de: 'Cloud, modular, 84 Regularien', en: 'Cloud, modular, 84 regulations' } },
{ name: { de: '50-250 MA', en: '50-250 emp.' }, price: 'ab €2.500/mo', annual: 'ab €30.000/yr', notes: { de: 'Cloud, alle Module, Priority', en: 'Cloud, all modules, priority' } },
{ name: { de: '250+ MA', en: '250+ emp.' }, price: 'ab €3.500/mo', annual: 'ab €40.000/yr', notes: { de: 'Cloud, Enterprise, Dedicated', en: 'Cloud, enterprise, dedicated' } },
],

View File

@@ -0,0 +1,251 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import { RefreshCw, Download, ChevronLeft, ChevronRight } from 'lucide-react'
interface FinanzplanSlideProps {
lang: Language
}
interface SheetMeta {
name: string
label_de: string
label_en: string
rows: number
}
interface SheetRow {
id: number
row_label?: string
person_name?: string
item_name?: string
category?: string
section?: string
is_editable?: boolean
is_sum_row?: boolean
values?: Record<string, number>
values_total?: Record<string, number>
values_brutto?: Record<string, number>
brutto_monthly?: number
position?: string
start_date?: string
purchase_amount?: number
}
const MONTH_LABELS = [
'Jan', 'Feb', 'Mrz', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
]
function getLabel(row: SheetRow): string {
return row.row_label || row.person_name || row.item_name || '—'
}
function getValues(row: SheetRow): Record<string, number> {
return row.values || row.values_total || row.values_brutto || {}
}
function formatCell(v: number | undefined): string {
if (v === undefined || v === null) return ''
if (v === 0) return '—'
if (Math.abs(v) >= 1000) return v.toLocaleString('de-DE', { maximumFractionDigits: 0 })
return v.toLocaleString('de-DE', { maximumFractionDigits: 2 })
}
export default function FinanzplanSlide({ lang }: FinanzplanSlideProps) {
const [sheets, setSheets] = useState<SheetMeta[]>([])
const [activeSheet, setActiveSheet] = useState<string>('personalkosten')
const [rows, setRows] = useState<SheetRow[]>([])
const [loading, setLoading] = useState(false)
const [computing, setComputing] = useState(false)
const [yearOffset, setYearOffset] = useState(0) // 0=2026, 1=2027, ...
const de = lang === 'de'
// Load sheet list
useEffect(() => {
fetch('/api/finanzplan')
.then(r => r.json())
.then(data => setSheets(data.sheets || []))
.catch(() => {})
}, [])
// Load sheet data
const loadSheet = useCallback(async (name: string) => {
setLoading(true)
try {
const r = await fetch(`/api/finanzplan/${name}`)
const data = await r.json()
setRows(data.rows || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadSheet(activeSheet) }, [activeSheet, loadSheet])
// Compute
const handleCompute = async () => {
setComputing(true)
try {
await fetch('/api/finanzplan/compute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
await loadSheet(activeSheet)
} catch { /* ignore */ }
setComputing(false)
}
// Cell edit
const handleCellEdit = async (rowId: number, monthKey: string, newValue: string) => {
const numVal = parseFloat(newValue.replace(/[^\d.-]/g, ''))
if (isNaN(numVal)) return
try {
await fetch(`/api/finanzplan/${activeSheet}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rowId, updates: { [monthKey]: numVal } }),
})
await loadSheet(activeSheet)
} catch { /* ignore */ }
}
const currentYear = 2026 + yearOffset
const monthStart = yearOffset * 12 + 1
const monthEnd = monthStart + 11
return (
<div className="max-w-[95vw] mx-auto">
<FadeInView className="text-center mb-4">
<h2 className="text-3xl md:text-4xl font-bold mb-1">
<GradientText>{de ? 'Finanzplan' : 'Financial Plan'}</GradientText>
</h2>
<p className="text-sm text-white/40">{de ? '20262030 · Monatliche Granularitaet · Editierbar' : '20262030 · Monthly Granularity · Editable'}</p>
</FadeInView>
{/* Tab Bar */}
<div className="flex items-center gap-1 mb-3 overflow-x-auto pb-1">
{sheets.map(s => (
<button
key={s.name}
onClick={() => setActiveSheet(s.name)}
className={`px-3 py-1.5 text-[10px] font-medium rounded-lg whitespace-nowrap transition-colors ${
activeSheet === s.name
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'text-white/40 hover:text-white/70 hover:bg-white/[0.05]'
}`}
>
{de ? s.label_de : s.label_en}
{s.rows > 0 && <span className="ml-1 text-[8px] opacity-50">({s.rows})</span>}
</button>
))}
<div className="flex-1" />
<button
onClick={handleCompute}
disabled={computing}
className="flex items-center gap-1 px-3 py-1.5 text-[10px] bg-emerald-500/20 text-emerald-300 rounded-lg hover:bg-emerald-500/30 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${computing ? 'animate-spin' : ''}`} />
{de ? 'Berechnen' : 'Compute'}
</button>
</div>
{/* Year Navigation */}
<div className="flex items-center justify-center gap-4 mb-2">
<button onClick={() => setYearOffset(Math.max(0, yearOffset - 1))} disabled={yearOffset === 0}
className="p-1 text-white/40 hover:text-white/70 disabled:opacity-20">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-bold text-white">{currentYear}</span>
<button onClick={() => setYearOffset(Math.min(4, yearOffset + 1))} disabled={yearOffset === 4}
className="p-1 text-white/40 hover:text-white/70 disabled:opacity-20">
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Data Grid */}
<GlassCard hover={false} className="p-2 overflow-x-auto">
{loading ? (
<div className="text-center py-8 text-white/30 text-sm">{de ? 'Lade...' : 'Loading...'}</div>
) : (
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/40 font-medium sticky left-0 bg-slate-900/90 backdrop-blur min-w-[160px]">
{de ? 'Position' : 'Item'}
</th>
<th className="text-right py-1.5 px-2 text-white/40 font-medium min-w-[70px]">
{currentYear}
</th>
{MONTH_LABELS.map((label, idx) => (
<th key={idx} className="text-right py-1.5 px-1.5 text-white/30 font-normal min-w-[55px]">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => {
const values = getValues(row)
const label = getLabel(row)
const isSumRow = row.is_sum_row || label.includes('GESAMT') || label.includes('Summe') || label.includes('UEBERSCHUSS') || label.includes('LIQUIDITAET')
const isEditable = row.is_editable
// Annual sum for visible year
let annual = 0
for (let m = monthStart; m <= monthEnd; m++) annual += values[`m${m}`] || 0
return (
<tr
key={row.id}
className={`border-b border-white/[0.03] ${isSumRow ? 'bg-white/[0.03]' : ''} hover:bg-white/[0.02]`}
>
<td className={`py-1 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isSumRow ? 'font-bold text-white/80' : 'text-white/60'}`}>
<div className="flex items-center gap-1">
{isEditable && <span className="w-1 h-1 rounded-full bg-indigo-400 flex-shrink-0" />}
<span className="truncate">{label}</span>
{row.position && <span className="text-white/20 ml-1">({row.position})</span>}
{row.section && <span className="text-white/20 ml-1">[{row.section}]</span>}
</div>
</td>
<td className={`text-right py-1 px-2 font-medium ${annual < 0 ? 'text-red-400' : isSumRow ? 'text-white/80' : 'text-white/50'}`}>
{formatCell(annual)}
</td>
{Array.from({ length: 12 }, (_, idx) => {
const mKey = `m${monthStart + idx}`
const v = values[mKey] || 0
return (
<td
key={idx}
className={`text-right py-1 px-1.5 ${
v < 0 ? 'text-red-400/70' : v > 0 ? (isSumRow ? 'text-white/70' : 'text-white/50') : 'text-white/15'
} ${isEditable ? 'cursor-pointer hover:bg-indigo-500/10' : ''}`}
onDoubleClick={() => {
if (!isEditable) return
const input = prompt(`${label}${MONTH_LABELS[idx]} ${currentYear}`, String(v))
if (input !== null) handleCellEdit(row.id, mKey, input)
}}
>
{formatCell(v)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
)}
</GlassCard>
<p className="text-center text-[9px] text-white/20 mt-2">
{de
? 'Doppelklick auf blaue Zellen zum Bearbeiten · Gruendung: 01.08.2026'
: 'Double-click blue cells to edit · Founding: 01.08.2026'}
</p>
</div>
)
}

View File

@@ -27,7 +27,8 @@ const MODULES = [
]
const PRICING_TIERS = [
{ employees: '< 50', priceDe: 'ab 15.000 EUR/Jahr', priceEn: 'from EUR 15,000/yr', highlight: false },
{ employees: 'Startup / < 10', priceDe: 'ab 3.600 EUR/Jahr', priceEn: 'from EUR 3,600/yr', highlight: false, noteDe: '14 Tage Test (Kreditkarte)', noteEn: '14-day trial (credit card)' },
{ employees: '10 50', priceDe: 'ab 15.000 EUR/Jahr', priceEn: 'from EUR 15,000/yr', highlight: false },
{ employees: '50 250', priceDe: 'ab 30.000 EUR/Jahr', priceEn: 'from EUR 30,000/yr', highlight: false },
{ employees: '250+', priceDe: 'ab 40.000 EUR/Jahr', priceEn: 'from EUR 40,000/yr', highlight: true },
]
@@ -78,9 +79,14 @@ export default function ProductSlide({ lang }: ProductSlideProps) {
<span className="text-xs text-white/70 font-medium">{tier.employees}</span>
<span className="text-[10px] text-white/40 ml-1">{de ? 'Mitarbeiter' : 'employees'}</span>
</div>
<span className={`text-xs font-bold ${tier.highlight ? 'text-indigo-300' : 'text-white/70'}`}>
{de ? tier.priceDe : tier.priceEn}
</span>
<div className="text-right">
<span className={`text-xs font-bold ${tier.highlight ? 'text-indigo-300' : 'text-white/70'}`}>
{de ? tier.priceDe : tier.priceEn}
</span>
{tier.noteDe && (
<p className="text-[8px] text-white/30">{de ? tier.noteDe : tier.noteEn}</p>
)}
</div>
</div>
))}
</div>

View File

@@ -0,0 +1,412 @@
/**
* Finanzplan Compute Engine
*
* 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)
*/
import { Pool } from 'pg'
import {
MonthlyValues, AnnualValues, MONTHS, FOUNDING_MONTH,
emptyMonthly, sumMonthly, annualSums, dateToMonth, monthToDate,
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
FPLiquiditaet, FPComputeResult
} from './types'
// --- Sheet Calculators ---
export function computePersonalkosten(positions: FPPersonalkosten[]): FPPersonalkosten[] {
return positions.map(p => {
const brutto = emptyMonthly()
const sozial = emptyMonthly()
const total = emptyMonthly()
if (!p.start_date || !p.brutto_monthly) return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
const startDate = new Date(p.start_date)
const startM = dateToMonth(startDate.getFullYear(), startDate.getMonth() + 1)
const endM = p.end_date
? dateToMonth(new Date(p.end_date).getFullYear(), new Date(p.end_date).getMonth() + 1)
: MONTHS
for (let m = Math.max(1, startM); m <= Math.min(MONTHS, endM); m++) {
const { year } = monthToDate(m)
const yearsFromStart = year - startDate.getFullYear()
const raise = Math.pow(1 + (p.annual_raise_pct || 0) / 100, yearsFromStart)
const monthlyBrutto = Math.round(p.brutto_monthly * raise * 100) / 100
brutto[`m${m}`] = monthlyBrutto
sozial[`m${m}`] = Math.round(monthlyBrutto * (p.ag_sozial_pct || 20.425) / 100 * 100) / 100
total[`m${m}`] = brutto[`m${m}`] + sozial[`m${m}`]
}
return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
})
}
export function computeInvestitionen(items: FPInvestitionen[]): FPInvestitionen[] {
return items.map(item => {
const invest = emptyMonthly()
const afa = emptyMonthly()
if (!item.purchase_date || !item.purchase_amount) return { ...item, values_invest: invest, values_afa: afa }
const d = new Date(item.purchase_date)
const purchaseM = dateToMonth(d.getFullYear(), d.getMonth() + 1)
if (purchaseM >= 1 && purchaseM <= MONTHS) {
invest[`m${purchaseM}`] = item.purchase_amount
}
// AfA (linear depreciation)
if (item.afa_years && item.afa_years > 0) {
const afaMonths = item.afa_years * 12
const monthlyAfa = Math.round(item.purchase_amount / afaMonths * 100) / 100
for (let m = purchaseM; m < purchaseM + afaMonths && m <= MONTHS; m++) {
if (m >= 1) afa[`m${m}`] = monthlyAfa
}
} else {
// GWG: full depreciation in purchase month
if (purchaseM >= 1 && purchaseM <= MONTHS) {
afa[`m${purchaseM}`] = item.purchase_amount
}
}
return { ...item, values_invest: invest, values_afa: afa }
})
}
function sumRows(rows: { values: MonthlyValues }[]): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += row.values[`m${m}`] || 0
}
}
return result
}
function sumField(rows: { [key: string]: MonthlyValues }[], field: string): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
const v = row[field] as MonthlyValues
if (!v) continue
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += v[`m${m}`] || 0
}
}
return result
}
// --- 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 = 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',
[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)
const prices = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'price')
const quantities = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'quantity')
const revenueRows = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'revenue')
const totalRevenue = emptyMonthly()
// Revenue = quantity × price for each module
for (const rev of revenueRows) {
if (rev.row_label === 'GESAMTUMSATZ') continue
const qty = quantities.find(q => q.row_label === rev.row_label)
const price = 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 * 100) / 100
totalRevenue[`m${m}`] += rev.values[`m${m}`]
}
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
}
}
// Update GESAMTUMSATZ
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])
}
// 5. Materialaufwand (quantity × unit_cost) — simplified
const matCosts = (materialRows.rows as FPMaterialaufwand[]).filter(r => r.section === 'cost')
const matUnitCosts = (materialRows.rows as FPMaterialaufwand[]).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 * 100) / 100
totalMaterial[`m${m}`] += cost.values[`m${m}`]
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
}
}
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])
}
// 6. Betriebliche Aufwendungen — compute sum rows
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
// Update Personalkosten row
const persBetrieb = betrieb.find(r => r.row_label === 'Personalkosten')
if (persBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), persBetrieb.id])
persBetrieb.values = totalPersonal
}
// Update Abschreibungen row
const abrBetrieb = betrieb.find(r => r.row_label === 'Abschreibungen')
if (abrBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalAfa), abrBetrieb.id])
abrBetrieb.values = totalAfa
}
// Compute category sums
const categories = ['steuern', 'versicherungen', 'besondere', 'marketing', 'sonstige']
for (const cat of categories) {
const sumRow = betrieb.find(r => r.category === cat && r.is_sum_row)
const detailRows = betrieb.filter(r => r.category === cat && !r.is_sum_row)
if (sumRow && detailRows.length > 0) {
const s = sumRows(detailRows)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sumRow.id])
sumRow.values = s
}
}
// Summe sonstige (ohne Personal, Abschreibungen)
const sonstSumme = betrieb.find(r => r.row_label.includes('Summe sonstige'))
if (sonstSumme) {
const nonPersonNonAbr = betrieb.filter(r =>
r.row_label !== 'Personalkosten' && r.row_label !== 'Abschreibungen' &&
!r.row_label.includes('Summe sonstige') && !r.row_label.includes('Gesamtkosten') &&
!r.is_sum_row && r.category !== 'personal' && r.category !== 'abschreibungen'
)
const s = sumRows(nonPersonNonAbr)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sonstSumme.id])
sonstSumme.values = s
}
// Gesamtkosten
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten'))
const totalSonstige = sonstSumme?.values || emptyMonthly()
if (gesamtBetrieb) {
const g = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
g[`m${m}`] = (totalPersonal[`m${m}`] || 0) + (totalAfa[`m${m}`] || 0) + (totalSonstige[`m${m}`] || 0)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(g), gesamtBetrieb.id])
gesamtBetrieb.values = g
}
// 7. Liquidität
const liquid = liquidRows.rows as FPLiquiditaet[]
const findLiq = (label: string) => liquid.find(r => r.row_label === label)
// Computed rows
const liqUmsatz = findLiq('Umsatzerloese')
if (liqUmsatz) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), liqUmsatz.id])
liqUmsatz.values = totalRevenue
}
const liqMaterial = findLiq('Materialaufwand')
if (liqMaterial) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), liqMaterial.id])
liqMaterial.values = totalMaterial
}
const liqPersonal = findLiq('Personalkosten')
if (liqPersonal) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), liqPersonal.id])
liqPersonal.values = totalPersonal
}
const liqSonstige = findLiq('Sonstige Kosten')
if (liqSonstige) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalSonstige), liqSonstige.id])
liqSonstige.values = totalSonstige
}
const liqInvest = findLiq('Investitionen')
if (liqInvest) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalInvest), liqInvest.id])
liqInvest.values = totalInvest
}
// Compute sums and rolling balance
const sumEin = findLiq('Summe EINZAHLUNGEN')
const sumAus = findLiq('Summe AUSZAHLUNGEN')
const uebVorInv = findLiq('UEBERSCHUSS VOR INVESTITIONEN')
const uebVorEnt = findLiq('UEBERSCHUSS VOR ENTNAHMEN')
const ueberschuss = findLiq('UEBERSCHUSS')
const kontostand = findLiq('Kontostand zu Beginn des Monats')
const liquiditaet = findLiq('LIQUIDITAET')
const einzahlungen = ['Umsatzerloese', 'Sonst. betriebl. Ertraege', 'Anzahlungen', 'Neuer Eigenkapitalzugang', 'Erhaltenes Fremdkapital']
const auszahlungen = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Kreditrueckzahlungen', 'Umsatzsteuer', 'Gewerbesteuer', 'Koerperschaftsteuer']
if (sumEin) {
const s = emptyMonthly()
for (const label of einzahlungen) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += 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
}
if (sumAus) {
const s = emptyMonthly()
for (const label of auszahlungen) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += 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
}
// Überschüsse und Kontostand
if (uebVorInv && sumEin && sumAus) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = (sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0)
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorInv.id])
uebVorInv.values = s
}
if (uebVorEnt && uebVorInv && liqInvest) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = (uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0)
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorEnt.id])
uebVorEnt.values = s
}
const entnahmen = findLiq('Kapitalentnahmen/Ausschuettungen')
if (ueberschuss && uebVorEnt && entnahmen) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = (uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0)
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), ueberschuss.id])
ueberschuss.values = s
}
// Rolling Kontostand
if (kontostand && liquiditaet && ueberschuss) {
const ks = emptyMonthly()
const lq = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
ks[`m${m}`] = m === 1 ? 0 : lq[`m${m - 1}`]
lq[`m${m}`] = 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
}
// 8. GuV — compute annual values
const guv: AnnualValues[] = []
const umsatzAnnual = annualSums(totalRevenue)
const materialAnnual = annualSums(totalMaterial)
const personalBruttoAnnual = annualSums(totalBrutto)
const personalSozialAnnual = annualSums(totalSozial)
const personalAnnual = annualSums(totalPersonal)
const afaAnnual = annualSums(totalAfa)
const sonstigeAnnual = annualSums(totalSonstige)
// Write GuV rows
const guvUpdates: { label: string; values: AnnualValues }[] = [
{ label: 'Umsatzerloese', values: umsatzAnnual },
{ label: 'Gesamtleistung', values: umsatzAnnual },
{ label: 'Summe Materialaufwand', values: materialAnnual },
{ label: 'Loehne und Gehaelter', values: personalBruttoAnnual },
{ label: 'Soziale Abgaben', values: personalSozialAnnual },
{ label: 'Summe Personalaufwand', values: personalAnnual },
{ label: 'Abschreibungen', values: afaAnnual },
{ label: 'Sonst. betriebl. Aufwendungen', values: sonstigeAnnual },
]
for (const { label, values } of guvUpdates) {
await pool.query(
'UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3',
[JSON.stringify(values), scenarioId, label]
)
}
// EBIT
const ebit: AnnualValues = {}
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
ebit[k] = (umsatzAnnual[k] || 0) - (materialAnnual[k] || 0) - (personalAnnual[k] || 0) - (afaAnnual[k] || 0) - (sonstigeAnnual[k] || 0)
}
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'EBIT'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'Ergebnis nach Steuern'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'Jahresueberschuss'])
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: gesamtBetrieb?.values || emptyMonthly() },
liquiditaet: { rows: liquid, endstand: liquiditaet?.values || emptyMonthly() },
guv: [ebit],
}
}
// Import to fix type errors
type FPUmsatzerloese = import('./types').FPUmsatzerloese
type FPMaterialaufwand = import('./types').FPMaterialaufwand

View File

@@ -0,0 +1,210 @@
/**
* Finanzplan Types — mirrors the fp_* database tables.
* Monthly values stored as Record<string, number> with keys m1..m60.
* Annual values stored with keys y2026..y2030.
*/
export type MonthlyValues = Record<string, number> // m1..m60
export type AnnualValues = Record<string, number> // y2026..y2030
export const MONTHS = 60
export const START_YEAR = 2026
export const END_YEAR = 2030
export const FOUNDING_MONTH = 8 // August 2026 = m8
// Month index (1-based) to year/month
export function monthToDate(m: number): { year: number; month: number } {
const year = START_YEAR + Math.floor((m - 1) / 12)
const month = ((m - 1) % 12) + 1
return { year, month }
}
// Year/month to month index (1-based)
export function dateToMonth(year: number, month: number): number {
return (year - START_YEAR) * 12 + month
}
export function emptyMonthly(): MonthlyValues {
const v: MonthlyValues = {}
for (let m = 1; m <= MONTHS; m++) v[`m${m}`] = 0
return v
}
export function sumMonthly(values: MonthlyValues, fromM: number, toM: number): number {
let s = 0
for (let m = fromM; m <= toM; m++) s += values[`m${m}`] || 0
return s
}
export function annualSums(values: MonthlyValues): AnnualValues {
const r: AnnualValues = {}
for (let y = START_YEAR; y <= END_YEAR; y++) {
const startM = dateToMonth(y, 1)
const endM = dateToMonth(y, 12)
r[`y${y}`] = sumMonthly(values, startM, endM)
}
return r
}
// --- DB row types ---
export interface FPScenario {
id: string
name: string
description?: string
is_default: boolean
}
export interface FPPersonalkosten {
id: number
scenario_id: string
person_name: string
person_nr?: string
position?: string
start_date?: string
end_date?: string
brutto_monthly: number
annual_raise_pct: number
ag_sozial_pct: number
values_brutto: MonthlyValues
values_sozial: MonthlyValues
values_total: MonthlyValues
sort_order: number
}
export interface FPBetrieblicheAufwendungen {
id: number
scenario_id: string
category: string
row_label: string
row_index: number
is_editable: boolean
is_sum_row: boolean
formula_desc?: string
values: MonthlyValues
sort_order: number
}
export interface FPInvestitionen {
id: number
scenario_id: string
item_name: string
category?: string
purchase_amount: number
purchase_date?: string
afa_years?: number
afa_end_date?: string
values_invest: MonthlyValues
values_afa: MonthlyValues
sort_order: number
}
export interface FPLiquiditaet {
id: number
scenario_id: string
row_label: string
row_type: string
is_editable: boolean
formula_desc?: string
values: MonthlyValues
sort_order: number
}
export interface FPGuV {
id: number
scenario_id: string
row_label: string
row_index: number
is_sum_row: boolean
formula_desc?: string
values: AnnualValues
sort_order: number
}
export interface FPKunden {
id: number
scenario_id: string
segment_name: string
segment_index: number
row_label: string
row_index: number
percentage?: number
formula_type?: string
is_editable: boolean
values: MonthlyValues
sort_order: number
}
export interface FPUmsatzerloese {
id: number
scenario_id: string
section: string // 'revenue', 'quantity', 'price'
row_label: string
row_index: number
is_editable: boolean
values: MonthlyValues
sort_order: number
}
export interface FPMaterialaufwand {
id: number
scenario_id: string
section: string // 'cost', 'quantity', 'unit_cost'
row_label: string
row_index: number
is_editable: boolean
values: MonthlyValues
sort_order: number
}
// --- Compute result ---
export interface FPComputeResult {
personalkosten: {
total_brutto: MonthlyValues
total_sozial: MonthlyValues
total: MonthlyValues
positions: FPPersonalkosten[]
headcount: MonthlyValues
}
investitionen: {
total_invest: MonthlyValues
total_afa: MonthlyValues
items: FPInvestitionen[]
}
umsatzerloese: {
total: MonthlyValues
}
materialaufwand: {
total: MonthlyValues
}
betriebliche: {
total_sonstige: MonthlyValues
total_gesamt: MonthlyValues
}
liquiditaet: {
rows: FPLiquiditaet[]
endstand: MonthlyValues
}
guv: AnnualValues[]
}
export interface SheetMeta {
name: string
label_de: string
label_en: string
rows: number
editable_rows: number
}
export const SHEET_LIST: SheetMeta[] = [
{ name: 'kunden', label_de: 'Kunden', label_en: 'Customers', rows: 0, editable_rows: 0 },
{ name: 'umsatzerloese', label_de: 'Umsatzerloese', label_en: 'Revenue', rows: 0, editable_rows: 0 },
{ name: 'materialaufwand', label_de: 'Materialaufwand', label_en: 'Material Costs', rows: 0, editable_rows: 0 },
{ name: 'personalkosten', label_de: 'Personalkosten', label_en: 'Personnel', rows: 0, editable_rows: 0 },
{ name: 'betriebliche', label_de: 'Betriebliche Aufwendungen', label_en: 'Operating Expenses', rows: 0, editable_rows: 0 },
{ name: 'investitionen', label_de: 'Investitionen', label_en: 'Investments', rows: 0, editable_rows: 0 },
{ name: 'sonst_ertraege', label_de: 'Sonst. Ertraege', label_en: 'Other Income', rows: 0, editable_rows: 0 },
{ name: 'liquiditaet', label_de: 'Liquiditaet', label_en: 'Cash Flow', rows: 0, editable_rows: 0 },
{ name: 'guv', label_de: 'GuV', label_en: 'P&L', rows: 0, editable_rows: 0 },
]

View File

@@ -31,6 +31,7 @@ const translations = {
'Anhang: Engineering',
'Anhang: KI-Pipeline',
'Anhang: SDK Demo',
'Anhang: Finanzplan',
],
executiveSummary: {
title: 'Executive Summary',
@@ -321,6 +322,7 @@ const translations = {
'Appendix: Engineering',
'Appendix: AI Pipeline',
'Appendix: SDK Demo',
'Appendix: Financial Plan',
],
executiveSummary: {
title: 'Executive Summary',

View File

@@ -24,6 +24,7 @@ export const SLIDE_ORDER: SlideId[] = [
'annex-engineering',
'annex-aipipeline',
'annex-sdk-demo',
'annex-finanzplan',
]
export const TOTAL_SLIDES = SLIDE_ORDER.length

View File

@@ -224,3 +224,4 @@ export type SlideId =
| 'annex-engineering'
| 'annex-aipipeline'
| 'annex-sdk-demo'
| 'annex-finanzplan'

6
pitch-deck/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

2692
pitch-deck/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
-- ============================================================================
-- BreakPilot ComplAI — Finanzplan Database Schema
-- Mirrors Excel: "Breakpilot ComplAI Finanzplan.xlsm" (10 Reiter)
-- Monthly granularity: Jan 2026 Dec 2030 (60 months)
-- Values stored as JSONB: {"m1": ..., "m2": ..., "m60": ...}
-- ============================================================================
-- Scenarios (extends existing pitch_fm_scenarios)
CREATE TABLE IF NOT EXISTS fp_scenarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL DEFAULT 'Base Case',
description TEXT,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert default scenario
INSERT INTO fp_scenarios (name, description, is_default)
VALUES ('Base Case', 'Basisdaten aus Excel-Import', true)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- KUNDEN (6 Segmente × ~20 Zeilen = ~120 Datenzeilen)
-- Each segment has: base customer count (editable) + module percentages
-- Formulas: module_count = ROUNDDOWN(base_count * percentage)
-- ============================================================================
CREATE TABLE fp_kunden (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
segment_name TEXT NOT NULL, -- 'Care (Privat)', 'Horse (Händler)', etc.
segment_index INT NOT NULL, -- 1-6
row_label TEXT NOT NULL, -- 'Modul 1', 'Modul 2', ...
row_index INT NOT NULL, -- position within segment
percentage NUMERIC(5,3), -- multiplier (e.g. 0.9, 0.25)
formula_type TEXT, -- 'literal', 'roundup_pct', 'rounddown_pct', 'cumulative', null
is_editable BOOLEAN DEFAULT false, -- true for base inputs (Modul 1 per segment)
values JSONB NOT NULL DEFAULT '{}', -- {m1: 0, m2: 0, ... m60: 0}
excel_row INT, -- original Excel row number
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Summary rows (aggregated across all 6 segments)
CREATE TABLE fp_kunden_summary (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
row_label TEXT NOT NULL, -- 'Modul 1', 'Modul 2', ...
row_index INT NOT NULL,
values JSONB NOT NULL DEFAULT '{}', -- computed: sum of 6 segments
excel_row INT,
sort_order INT NOT NULL
);
-- ============================================================================
-- UMSATZERLOESE (Revenue = Quantity × Price)
-- Section 1 (rows 3-23): Computed revenue per module
-- Section 2 (rows 27-46): Quantity (from Kunden)
-- Section 3 (rows 49-73): Prices (editable VK excl. MwSt.)
-- ============================================================================
CREATE TABLE fp_umsatzerloese (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
section TEXT NOT NULL, -- 'revenue', 'quantity', 'price'
row_label TEXT NOT NULL, -- 'Modul 1', 'Modul 2', ...
row_index INT NOT NULL,
is_editable BOOLEAN DEFAULT false, -- only prices are editable
values JSONB NOT NULL DEFAULT '{}',
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- MATERIALAUFWAND (sehr einfach: nur Mac Mini + Mac Studio)
-- Cost = Quantity × Unit Cost (EK)
-- Mac Mini EK: 3.200 EUR, VK: 4.800 EUR (50% Aufschlag)
-- Mac Studio EK: 13.000 EUR, VK: 19.500 EUR (50% Aufschlag)
-- ============================================================================
CREATE TABLE fp_materialaufwand (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
section TEXT NOT NULL, -- 'cost', 'quantity', 'unit_cost'
row_label TEXT NOT NULL,
row_index INT NOT NULL,
is_editable BOOLEAN DEFAULT false, -- only unit costs are editable
values JSONB NOT NULL DEFAULT '{}',
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- PERSONALKOSTEN (20 Positionen)
-- Structured: Name, Position, Start, End, Brutto, Raise%
-- Computed: monthly salary × AG-Sozialversicherung
-- ============================================================================
CREATE TABLE fp_personalkosten (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
person_name TEXT NOT NULL,
person_nr TEXT, -- '001', '002', ...
position TEXT, -- 'GF', 'Vertrieb', 'Entwicklung', ...
start_date DATE,
end_date DATE, -- null = permanent
brutto_monthly NUMERIC(10,2), -- Bruttogehalt/Monat
annual_raise_pct NUMERIC(5,2) DEFAULT 3.0,
ag_sozial_pct NUMERIC(5,2) DEFAULT 20.425, -- AG-Anteil Sozialversicherung
is_editable BOOLEAN DEFAULT true,
-- Computed monthly values
values_brutto JSONB NOT NULL DEFAULT '{}', -- monthly brutto
values_sozial JSONB NOT NULL DEFAULT '{}', -- monthly AG-Sozial
values_total JSONB NOT NULL DEFAULT '{}', -- brutto + sozial
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- BETRIEBLICHE AUFWENDUNGEN (~40 Kostenposten)
-- Most are fixed monthly values (editable)
-- Some are computed (Summen, Abschreibungen)
-- ============================================================================
CREATE TABLE fp_betriebliche_aufwendungen (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
category TEXT NOT NULL, -- 'raumkosten', 'versicherungen', 'marketing', etc.
row_label TEXT NOT NULL,
row_index INT NOT NULL,
is_editable BOOLEAN DEFAULT true,
is_sum_row BOOLEAN DEFAULT false, -- true for category subtotals
formula_desc TEXT, -- e.g. 'SUM(rows 9-18)', '=Personalkosten!Y4'
values JSONB NOT NULL DEFAULT '{}',
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- INVESTITIONEN (Anlagegüter mit AfA)
-- Each item: name, amount, purchase date, useful life, depreciation
-- ============================================================================
CREATE TABLE fp_investitionen (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
item_name TEXT NOT NULL,
category TEXT, -- 'gwg', 'ausstattung', etc.
purchase_amount NUMERIC(12,2) NOT NULL,
purchase_date DATE,
afa_years INT, -- useful life in years
afa_end_date DATE, -- computed end date
is_editable BOOLEAN DEFAULT true,
-- Computed: monthly investment amount (in purchase month) and depreciation
values_invest JSONB NOT NULL DEFAULT '{}', -- investment amount per month
values_afa JSONB NOT NULL DEFAULT '{}', -- monthly depreciation
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- SONST. BETRIEBLICHE ERTRAEGE (6 Kategorien × 3 Zeilen)
-- ============================================================================
CREATE TABLE fp_sonst_ertraege (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
category TEXT NOT NULL, -- '1-Interne Kostenstellen', '2-Entwicklung National', etc.
row_label TEXT,
row_index INT NOT NULL,
is_editable BOOLEAN DEFAULT true,
is_sum_row BOOLEAN DEFAULT false,
values JSONB NOT NULL DEFAULT '{}',
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- LIQUIDITAET (computed from all above)
-- ============================================================================
CREATE TABLE fp_liquiditaet (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
row_label TEXT NOT NULL,
row_type TEXT NOT NULL, -- 'einzahlung', 'auszahlung', 'ueberschuss', 'kontostand'
is_editable BOOLEAN DEFAULT false, -- only Eigenkapital, Fremdkapital, Entnahmen editable
formula_desc TEXT,
values JSONB NOT NULL DEFAULT '{}',
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- GUV JAHRESABSCHLUSS (annual summary, 5 years)
-- ============================================================================
CREATE TABLE fp_guv (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
row_label TEXT NOT NULL,
row_index INT NOT NULL,
is_sum_row BOOLEAN DEFAULT false,
formula_desc TEXT,
values JSONB NOT NULL DEFAULT '{}', -- {y2026: ..., y2027: ..., y2030: ...}
excel_row INT,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- CELL OVERRIDES (for scenario-specific manual edits)
-- ============================================================================
CREATE TABLE fp_cell_overrides (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
sheet_name TEXT NOT NULL, -- 'kunden', 'personalkosten', etc.
row_id INT NOT NULL, -- references the id in the sheet table
month_key TEXT NOT NULL, -- 'm1', 'm2', ... 'm60' or 'y2026', etc.
override_value NUMERIC,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(scenario_id, sheet_name, row_id, month_key)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX idx_fp_kunden_scenario ON fp_kunden(scenario_id);
CREATE INDEX idx_fp_kunden_summary_scenario ON fp_kunden_summary(scenario_id);
CREATE INDEX idx_fp_umsatz_scenario ON fp_umsatzerloese(scenario_id);
CREATE INDEX idx_fp_material_scenario ON fp_materialaufwand(scenario_id);
CREATE INDEX idx_fp_personal_scenario ON fp_personalkosten(scenario_id);
CREATE INDEX idx_fp_betrieb_scenario ON fp_betriebliche_aufwendungen(scenario_id);
CREATE INDEX idx_fp_invest_scenario ON fp_investitionen(scenario_id);
CREATE INDEX idx_fp_sonst_scenario ON fp_sonst_ertraege(scenario_id);
CREATE INDEX idx_fp_liquid_scenario ON fp_liquiditaet(scenario_id);
CREATE INDEX idx_fp_guv_scenario ON fp_guv(scenario_id);
CREATE INDEX idx_fp_overrides_lookup ON fp_cell_overrides(scenario_id, sheet_name, row_id);

View File

@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""
Import BreakPilot Finanzplan Excel into PostgreSQL fp_* tables.
Usage: python3 scripts/import-finanzplan.py <path-to-xlsm>
Requires: pip3 install openpyxl psycopg2-binary
"""
import sys
import json
import os
from datetime import date, datetime
import openpyxl
import psycopg2
from psycopg2.extras import Json
# --- Config ---
DB_URL = os.environ.get('DATABASE_URL', 'postgresql://breakpilot:breakpilot@localhost:5432/breakpilot_db')
MONTHS = 60 # Jan 2026 Dec 2030
# Excel columns: D=4 (month 1) through BQ=69 (month 60, approx)
# Actually: D=m1(Jan2026), E=m2(Feb2026), ..., O=m12(Dec2026),
# Q=m13(Jan2027), ..., AB=m24(Dec2027), etc.
# Year-column (C, P, AC, AP, BC) = annual sums, skip those
# Monthly columns per year: D-O (12), Q-AB (12), AD-AO (12), AQ-BB (12), BD-BO (12)
# Map: month_index (1-60) -> Excel column index (1-based)
def build_month_columns():
"""Build mapping from month 1-60 to Excel column index."""
cols = []
# Year 1 (2026): cols D(4) - O(15) = 12 months
for c in range(4, 16):
cols.append(c)
# Year 2 (2027): cols Q(17) - AB(28) = 12 months
for c in range(17, 29):
cols.append(c)
# Year 3 (2028): cols AD(30) - AO(41) = 12 months
for c in range(30, 42):
cols.append(c)
# Year 4 (2029): cols AQ(43) - BB(54) = 12 months
for c in range(43, 55):
cols.append(c)
# Year 5 (2030): cols BD(56) - BO(67) = 12 months
for c in range(56, 68):
cols.append(c)
return cols
MONTH_COLS = build_month_columns()
def read_monthly_values(ws, row, data_only=True):
"""Read 60 monthly values from an Excel row."""
values = {}
for m_idx, col in enumerate(MONTH_COLS):
v = ws.cell(row, col).value
if v is not None and v != '' and not isinstance(v, str):
try:
values[f'm{m_idx+1}'] = round(float(v), 2)
except (ValueError, TypeError):
values[f'm{m_idx+1}'] = 0
else:
values[f'm{m_idx+1}'] = 0
return values
def safe_str(v):
if v is None:
return ''
return str(v).strip()
def safe_float(v, default=0):
if v is None:
return default
try:
return float(v)
except (ValueError, TypeError):
return default
def safe_date(v):
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
return None
def import_personalkosten(cur, ws, scenario_id):
"""Import Personalkosten sheet (rows 10-29 = 20 positions)."""
print(" Importing Personalkosten...")
count = 0
for i in range(20):
row = 10 + i
name = safe_str(ws.cell(row, 1).value)
nr = safe_str(ws.cell(row, 2).value)
position = safe_str(ws.cell(row, 3).value)
start = safe_date(ws.cell(row, 4).value)
end = safe_date(ws.cell(row, 5).value)
brutto = safe_float(ws.cell(row, 7).value)
raise_pct = safe_float(ws.cell(row, 8).value, 3.0)
if not name and not nr:
continue
# Read computed monthly totals from the "Personalaufwand" section
# The actual monthly values are in a different row range (rows 32-51 for brutto, 56-75 for sozial)
# But we store the inputs and let the engine compute
values_total = {} # will be computed by engine
cur.execute("""
INSERT INTO fp_personalkosten
(scenario_id, person_name, person_nr, position, start_date, end_date,
brutto_monthly, annual_raise_pct, is_editable,
values_brutto, values_sozial, values_total, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s, %s)
""", (scenario_id, name, nr, position, start, end,
brutto, raise_pct, Json({}), Json({}), Json({}), row, i + 1))
count += 1
print(f" -> {count} Positionen importiert")
def import_betriebliche_aufwendungen(cur, ws, scenario_id):
"""Import Betriebliche Aufwendungen (rows 3-47)."""
print(" Importing Betriebliche Aufwendungen...")
# Define the structure based on Excel analysis
rows_config = [
(3, 'personal', 'Personalkosten', False, True, '=Personalkosten total'),
(4, 'raumkosten', 'Raumkosten', True, False, None),
(5, 'steuern', 'Betriebliche Steuern', False, True, '=SUM(6,7)'),
(6, 'steuern', 'Gewerbesteuer', True, False, None),
(7, 'steuern', 'KFZ-Steuern', True, False, None),
(8, 'versicherungen', 'Versich./Beitraege', False, True, '=SUM(9:18)'),
(9, 'versicherungen', 'IHK', True, False, None),
(10, 'versicherungen', 'Rundfunkbeitrag', True, False, None),
(11, 'versicherungen', 'Berufsgenossenschaft', True, False, None),
(12, 'versicherungen', 'Bundesanzeiger/Transparenzregister', True, False, None),
(13, 'versicherungen', 'D&O-Versicherung', True, False, None),
(14, 'versicherungen', 'E&O-Versicherung', True, False, None),
(15, 'versicherungen', 'Produkthaftpflicht', True, False, None),
(16, 'versicherungen', 'Cyber-Versicherung', True, False, None),
(17, 'versicherungen', 'Rechtsschutzversicherung', True, False, None),
(18, 'versicherungen', 'KFZ-Versicherung', True, False, None),
(19, 'besondere', 'Besondere Kosten', False, True, '=SUM(20:22)'),
(20, 'besondere', 'Schutzrechte/Lizenzkosten', True, False, None),
(21, 'besondere', 'Marketing Videos', True, False, None),
(22, 'besondere', 'Fort-/Weiterbildungskosten', True, False, None),
(23, 'fahrzeug', 'Fahrzeugkosten', True, False, None),
(24, 'marketing', 'Werbe-/Reisekosten', False, True, '=SUM(25:30)'),
(25, 'marketing', 'Reisekosten', True, False, None),
(26, 'marketing', 'Teilnahme an Messen', True, False, None),
(27, 'marketing', 'Allgemeine Marketingkosten', True, False, None),
(28, 'marketing', 'Marketing-Agentur', True, False, None),
(29, 'marketing', 'Editorial Content', True, False, None),
(30, 'marketing', 'Bewirtungskosten', True, False, None),
(31, 'warenabgabe', 'Kosten Warenabgabe', True, False, None),
(32, 'abschreibungen', 'Abschreibungen', False, False, '=Investitionen AfA'),
(33, 'reparatur', 'Reparatur/Instandh.', True, False, None),
(34, 'sonstige', 'Sonstige Kosten', False, True, '=SUM(35:45)'),
(35, 'sonstige', 'Telefon', True, False, None),
(36, 'sonstige', 'Bankgebuehren', True, False, None),
(37, 'sonstige', 'Buchfuehrung', True, False, None),
(38, 'sonstige', 'Jahresabschluss', True, False, None),
(39, 'sonstige', 'Rechts-/Beratungskosten', True, False, None),
(40, 'sonstige', 'Werkzeuge/Kleingeraete', True, False, None),
(41, 'sonstige', 'Serverkosten (Cloud)', True, False, None),
(42, 'sonstige', 'Verbrauchsmaterialien', True, False, None),
(43, 'sonstige', 'Mietkosten Software', True, False, None),
(44, 'sonstige', 'Nebenkosten Geldverkehr', True, False, None),
(46, 'summe', 'Summe sonstige (ohne Pers., Abschr.)', False, True, '=computed'),
(47, 'summe', 'Gesamtkosten (Klasse 6)', False, True, '=computed'),
]
count = 0
for idx, (row, cat, label, editable, is_sum, formula) in enumerate(rows_config):
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_betriebliche_aufwendungen
(scenario_id, category, row_label, row_index, is_editable, is_sum_row,
formula_desc, values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (scenario_id, cat, label, row, editable, is_sum,
formula, Json(values), row, idx + 1))
count += 1
print(f" -> {count} Kostenposten importiert")
def import_investitionen(cur, ws, scenario_id):
"""Import Investitionen (rows 6-42)."""
print(" Importing Investitionen...")
count = 0
for i in range(37):
row = 6 + i
name = safe_str(ws.cell(row, 1).value)
amount = safe_float(ws.cell(row, 2).value)
purchase = safe_date(ws.cell(row, 3).value)
afa_years = safe_float(ws.cell(row, 4).value)
afa_end = safe_date(ws.cell(row, 5).value)
if not name and amount == 0:
continue
# Read monthly investment values (col G onwards in Investitionen sheet)
# This sheet has different column mapping — dates as headers
# For simplicity, store purchase amount and let engine compute AfA
cur.execute("""
INSERT INTO fp_investitionen
(scenario_id, item_name, category, purchase_amount, purchase_date,
afa_years, afa_end_date, is_editable,
values_invest, values_afa, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s)
""", (scenario_id, name, 'ausstattung' if 'Ausstattung' in name else 'gwg',
amount, purchase, int(afa_years) if afa_years else None, afa_end,
Json({}), Json({}), row, count + 1))
count += 1
print(f" -> {count} Investitionsgueter importiert")
def import_sonst_ertraege(cur, ws, scenario_id):
"""Import Sonst. betr. Ertraege (6 categories)."""
print(" Importing Sonst. betr. Ertraege...")
categories = [
(3, 8, 'Interne Kostenstellen'),
(9, 12, 'Entwicklung National'),
(13, 16, 'Entwicklung International'),
(17, 20, 'Beratungsdienstleistung'),
(21, 24, 'Zuwendungen'),
(25, 28, 'TBD'),
]
count = 0
for sum_row, end_row, cat_name in categories:
# Sum row
values = read_monthly_values(ws, sum_row)
cur.execute("""
INSERT INTO fp_sonst_ertraege
(scenario_id, category, row_label, row_index, is_editable, is_sum_row,
values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, false, true, %s, %s, %s)
""", (scenario_id, cat_name, cat_name, sum_row, Json(values), sum_row, count + 1))
count += 1
# Detail rows
for r in range(sum_row + 1, end_row + 1):
values = read_monthly_values(ws, r)
label = safe_str(ws.cell(r, 1).value) or f'Position {r - sum_row}'
cur.execute("""
INSERT INTO fp_sonst_ertraege
(scenario_id, category, row_label, row_index, is_editable, is_sum_row,
values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, true, false, %s, %s, %s)
""", (scenario_id, cat_name, label, r, Json(values), r, count + 1))
count += 1
# Total row (29)
values = read_monthly_values(ws, 29)
cur.execute("""
INSERT INTO fp_sonst_ertraege
(scenario_id, category, row_label, row_index, is_editable, is_sum_row,
values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, false, true, %s, %s, %s)
""", (scenario_id, 'GESAMT', 'GESAMTUMSATZ', 29, Json(values), 29, count + 1))
print(f" -> {count + 1} Ertragsposten importiert")
def import_liquiditaet(cur, ws, scenario_id):
"""Import Liquiditaet (rows 4-27)."""
print(" Importing Liquiditaet...")
rows_config = [
(4, 'Umsatzerloese', 'einzahlung', False, '=Umsatzerloese!GESAMT'),
(5, 'Sonst. betriebl. Ertraege', 'einzahlung', False, '=Sonst.Ertraege!GESAMT'),
(6, 'Anzahlungen', 'einzahlung', True, None),
(7, 'Neuer Eigenkapitalzugang', 'einzahlung', True, None),
(8, 'Erhaltenes Fremdkapital', 'einzahlung', True, None),
(9, 'Summe EINZAHLUNGEN', 'einzahlung', False, '=SUM(4:8)'),
(12, 'Materialaufwand', 'auszahlung', False, '=Materialaufwand!SUMME'),
(13, 'Personalkosten', 'auszahlung', False, '=Personalkosten!Total'),
(14, 'Sonstige Kosten', 'auszahlung', False, '=Betriebliche!Summe_sonstige'),
(15, 'Kreditrueckzahlungen', 'auszahlung', True, None),
(16, 'Umsatzsteuer', 'auszahlung', True, None),
(17, 'Gewerbesteuer', 'auszahlung', True, None),
(18, 'Koerperschaftsteuer', 'auszahlung', True, None),
(19, 'Summe AUSZAHLUNGEN', 'auszahlung', False, '=SUM(12:18)'),
(21, 'UEBERSCHUSS VOR INVESTITIONEN', 'ueberschuss', False, '=Einzahlungen-Auszahlungen'),
(22, 'Investitionen', 'ueberschuss', False, '=Investitionen!Gesamt'),
(23, 'UEBERSCHUSS VOR ENTNAHMEN', 'ueberschuss', False, '=21-22'),
(24, 'Kapitalentnahmen/Ausschuettungen', 'ueberschuss', True, None),
(25, 'UEBERSCHUSS', 'ueberschuss', False, '=23-24'),
(26, 'Kontostand zu Beginn des Monats', 'kontostand', False, '=prev_month_liquiditaet'),
(27, 'LIQUIDITAET', 'kontostand', False, '=26+25'),
]
count = 0
for row, label, row_type, editable, formula in rows_config:
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_liquiditaet
(scenario_id, row_label, row_type, is_editable, formula_desc,
values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (scenario_id, label, row_type, editable, formula,
Json(values), row, count + 1))
count += 1
print(f" -> {count} Liquiditaetszeilen importiert")
def import_kunden(cur, ws, scenario_id):
"""Import Kunden (6 segments, rows 26-167)."""
print(" Importing Kunden...")
# Summary rows (4-23) = aggregation across segments
for i in range(20):
row = 4 + i
label = safe_str(ws.cell(row, 1).value)
if not label:
label = f'Produkt {i + 1}'
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_kunden_summary
(scenario_id, row_label, row_index, values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, i + 1))
# 6 segments, each starts 24 rows apart: 26, 50, 74, 98, 122, 146
segment_starts = [
(26, 'Care (Privat)'),
(50, 'Horse (Haendler)'),
(74, 'Segment 3'),
(98, 'Segment 4'),
(122, 'Segment 5'),
(146, 'Segment 6'),
]
# Read segment names from Excel
for idx, (start_row, default_name) in enumerate(segment_starts):
seg_name = safe_str(ws.cell(start_row, 1).value) or default_name
# Each segment: row+2 = header, row+2..row+21 = module rows
module_start = start_row + 2 # row 28, 52, 76, 100, 124, 148
count = 0
for m in range(20):
row = module_start + m
label = safe_str(ws.cell(row, 1).value)
if not label:
continue
pct = safe_float(ws.cell(row, 2).value)
pct_label = safe_str(ws.cell(row, 2).value)
values = read_monthly_values(ws, row)
# First module per segment (m=0) is the base editable input
is_base = (m == 0)
formula_type = None
if pct_label == 'gerechnet':
formula_type = 'cumulative'
elif pct > 0 and not is_base:
formula_type = 'rounddown_pct'
cur.execute("""
INSERT INTO fp_kunden
(scenario_id, segment_name, segment_index, row_label, row_index,
percentage, formula_type, is_editable, values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (scenario_id, seg_name, idx + 1, label, row,
pct if pct else None, formula_type, is_base,
Json(values), row, count + 1))
count += 1
print(f" -> Kunden importiert (6 Segmente)")
def import_umsatzerloese(cur, ws, scenario_id):
"""Import Umsatzerloese (revenue, quantity, prices)."""
print(" Importing Umsatzerloese...")
count = 0
# Revenue rows (3-23): computed = quantity * price
for i in range(21):
row = 3 + i
label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}'
if not label.strip():
continue
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_umsatzerloese
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'revenue', %s, %s, false, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, count + 1))
count += 1
# Total row (24)
values = read_monthly_values(ws, 24)
cur.execute("""
INSERT INTO fp_umsatzerloese
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'revenue', 'GESAMTUMSATZ', 24, false, %s, 24, %s)
""", (scenario_id, Json(values), count + 1))
count += 1
# Quantity rows (27-46): from Kunden
for i in range(20):
row = 27 + i
label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}'
if not label.strip():
continue
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_umsatzerloese
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'quantity', %s, %s, false, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, count + 1))
count += 1
# Price rows (49-73): editable VK prices
for i in range(25):
row = 49 + i
label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}'
if not label.strip():
continue
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_umsatzerloese
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'price', %s, %s, true, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, count + 1))
count += 1
print(f" -> {count} Umsatzzeilen importiert")
def import_materialaufwand(cur, ws, scenario_id):
"""Import Materialaufwand (simplified: only Mac Mini + Mac Studio)."""
print(" Importing Materialaufwand...")
count = 0
# Cost rows (3-23): computed = quantity * unit_cost
for i in range(21):
row = 3 + i
label = safe_str(ws.cell(row, 1).value)
if not label:
continue
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_materialaufwand
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'cost', %s, %s, false, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, count + 1))
count += 1
# Total (24)
values = read_monthly_values(ws, 24)
cur.execute("""
INSERT INTO fp_materialaufwand
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'cost', 'SUMME', 24, false, %s, 24, %s)
""", (scenario_id, Json(values), count + 1))
count += 1
# Unit cost rows (51-73): editable EK prices
for i in range(23):
row = 51 + i
label = safe_str(ws.cell(row, 1).value)
if not label:
continue
values = read_monthly_values(ws, row)
cur.execute("""
INSERT INTO fp_materialaufwand
(scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order)
VALUES (%s, 'unit_cost', %s, %s, true, %s, %s, %s)
""", (scenario_id, label, row, Json(values), row, count + 1))
count += 1
print(f" -> {count} Materialzeilen importiert (Mac Mini 3.200 / Mac Studio 13.000 EK)")
def import_guv(cur, ws, scenario_id):
"""Import GuV Jahresabschluss (annual summary)."""
print(" Importing GuV Jahresabschluss...")
# Annual columns: B=2026, C=2027, D=2028, E=2029, F=2030
year_cols = {2: 'y2026', 3: 'y2027', 4: 'y2028', 5: 'y2029', 6: 'y2030'}
rows_config = [
(3, 'Umsatzerloese', False, '=Umsatzerloese!Jahressumme'),
(4, 'Bestandsveraenderungen', True, None),
(5, 'Gesamtleistung', False, '=SUM(3:4)'),
(9, 'Sonst. betriebl. Ertraege', False, '=Sonst.Ertraege!Jahressumme'),
(10, 'Summe sonst. Ertraege', False, '=SUM(8:9)'),
(13, 'Materialaufwand Waren', False, '=Materialaufwand!Jahressumme'),
(14, 'Materialaufwand Leistungen', False, '=Materialaufwand!bezogene_Leistungen'),
(15, 'Summe Materialaufwand', False, '=13+14'),
(17, 'Rohergebnis', False, '=5+10-15'),
(20, 'Loehne und Gehaelter', False, '=Personalkosten!Brutto'),
(21, 'Soziale Abgaben', False, '=Personalkosten!Sozial'),
(22, 'Summe Personalaufwand', False, '=20+21'),
(25, 'Abschreibungen', False, '=Investitionen!AfA'),
(27, 'Sonst. betriebl. Aufwendungen', False, '=Betriebliche!Summe_sonstige'),
(29, 'EBIT', False, '=5+10-15-22-25-27'),
(31, 'Zinsertraege', True, None),
(33, 'Zinsaufwendungen', True, None),
(35, 'Steuern gesamt', False, '=36+37'),
(36, 'Koerperschaftssteuer', False, '=computed'),
(37, 'Gewerbesteuer', False, '=computed'),
(39, 'Ergebnis nach Steuern', False, '=29+31-33-35'),
(41, 'Sonstige Steuern', True, None),
(43, 'Jahresueberschuss', False, '=39-41'),
]
count = 0
for row, label, is_edit, formula in rows_config:
values = {}
for col, key in year_cols.items():
v = ws.cell(row, col).value
values[key] = round(float(v), 2) if v and not isinstance(v, str) else 0
cur.execute("""
INSERT INTO fp_guv
(scenario_id, row_label, row_index, is_sum_row, formula_desc,
values, excel_row, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (scenario_id, label, row, formula is not None and not is_edit,
formula, Json(values), row, count + 1))
count += 1
print(f" -> {count} GuV-Zeilen importiert")
def main():
if len(sys.argv) < 2:
print("Usage: python3 import-finanzplan.py <path-to-xlsm>")
sys.exit(1)
xlsx_path = sys.argv[1]
print(f"Opening: {xlsx_path}")
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
print(f"Connecting to: {DB_URL.split('@')[1] if '@' in DB_URL else DB_URL}")
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Schema already applied separately — skip if tables exist
cur.execute("SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'fp_scenarios')")
if not cur.fetchone()[0]:
schema_path = os.path.join(os.path.dirname(__file__), '001_finanzplan_tables.sql')
print(f"Applying schema: {schema_path}")
with open(schema_path) as f:
cur.execute(f.read())
conn.commit()
else:
print("Schema already exists, skipping.")
# Get or create default scenario
cur.execute("SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1")
row = cur.fetchone()
if row:
scenario_id = row[0]
# Clear existing data for re-import
for table in ['fp_kunden', 'fp_kunden_summary', 'fp_umsatzerloese', 'fp_materialaufwand',
'fp_personalkosten', 'fp_betriebliche_aufwendungen', 'fp_investitionen',
'fp_sonst_ertraege', 'fp_liquiditaet', 'fp_guv']:
cur.execute(f"DELETE FROM {table} WHERE scenario_id = %s", (scenario_id,))
else:
cur.execute("INSERT INTO fp_scenarios (name, is_default) VALUES ('Base Case', true) RETURNING id")
scenario_id = cur.fetchone()[0]
print(f"Scenario ID: {scenario_id}")
print(f"\nImporting sheets...")
# Import each sheet
import_kunden(cur, wb['Kunden'], scenario_id)
import_umsatzerloese(cur, wb['Umsatzerlöse'], scenario_id)
import_materialaufwand(cur, wb['Materialaufwand'], scenario_id)
import_personalkosten(cur, wb['Personalkosten'], scenario_id)
import_betriebliche_aufwendungen(cur, wb['Betriebliche Aufwendungen'], scenario_id)
import_investitionen(cur, wb['Investitionen'], scenario_id)
import_sonst_ertraege(cur, wb['Sonst. betr. Erträge'], scenario_id)
import_liquiditaet(cur, wb['Liquidität'], scenario_id)
import_guv(cur, wb['GuV Jahresabschluss'], scenario_id)
conn.commit()
print(f"\nImport abgeschlossen!")
# Summary
for table in ['fp_kunden', 'fp_kunden_summary', 'fp_umsatzerloese', 'fp_materialaufwand',
'fp_personalkosten', 'fp_betriebliche_aufwendungen', 'fp_investitionen',
'fp_sonst_ertraege', 'fp_liquiditaet', 'fp_guv']:
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE scenario_id = %s", (scenario_id,))
count = cur.fetchone()[0]
print(f" {table}: {count} Zeilen")
cur.close()
conn.close()
if __name__ == '__main__':
main()