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:
100
pitch-deck/app/api/finanzplan/[sheetName]/route.ts
Normal file
100
pitch-deck/app/api/finanzplan/[sheetName]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
38
pitch-deck/app/api/finanzplan/compute/route.ts
Normal file
38
pitch-deck/app/api/finanzplan/compute/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
32
pitch-deck/app/api/finanzplan/route.ts
Normal file
32
pitch-deck/app/api/finanzplan/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
],
|
||||
|
||||
251
pitch-deck/components/slides/FinanzplanSlide.tsx
Normal file
251
pitch-deck/components/slides/FinanzplanSlide.tsx
Normal 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 ? '2026–2030 · Monatliche Granularitaet · Editierbar' : '2026–2030 · 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
412
pitch-deck/lib/finanzplan/engine.ts
Normal file
412
pitch-deck/lib/finanzplan/engine.ts
Normal 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
|
||||
210
pitch-deck/lib/finanzplan/types.ts
Normal file
210
pitch-deck/lib/finanzplan/types.ts
Normal 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 },
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
pitch-deck/next-env.d.ts
vendored
Normal 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
2692
pitch-deck/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
246
pitch-deck/scripts/001_finanzplan_tables.sql
Normal file
246
pitch-deck/scripts/001_finanzplan_tables.sql
Normal 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);
|
||||
583
pitch-deck/scripts/import-finanzplan.py
Normal file
583
pitch-deck/scripts/import-finanzplan.py
Normal 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()
|
||||
Reference in New Issue
Block a user