Files
breakpilot-core/pitch-deck/scripts/export-finanzplan-excel.ts
T
Sharang Parnerkar 77993d0ea0
Build pitch-deck / build-push-deploy (push) Failing after 24s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 5m28s
CI / test-python-voice (push) Successful in 4m0s
CI / test-bqas (push) Successful in 32s
feat(pitch-deck): Finanzplan-Export nach Excel mit Live-Formeln und Charts
Generiert pro Szenario (Wandeldarlehen 200k/Bear/Bull, 1 Mio Base/Bear/Bull)
ein .xlsx mit 10 Tabs (Dashboard, Kunden, Umsatzerlöse, Personalkosten,
Investitionen, Materialaufwand, Betriebliche Aufwendungen, Liquidität, GuV,
Formelübersicht). Editierbare Eingaben bleiben rohe Werte; abgeleitete Zellen
werden zu echten Excel-Formeln über Tabs hinweg, sodass das Bearbeiten von
Inputs Personal/Opex/Liquidität/GuV neu berechnet.

Dashboard-Tab fasst Jahres-KPIs zusammen und enthält fünf Charts
(Umsatz/Material/Personal/EBIT YoY, Jahresüberschuss YoY, Liquidität,
Headcount, Personalkosten monatlich).

Run: PG_CONN=... pitch-deck/scripts/export-finanzplan.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:08:27 +02:00

1255 lines
55 KiB
TypeScript

/**
* Export Finanzplan to Excel with back-formulas.
*
* Generates one .xlsx per scenario. Editable inputs become raw values;
* computed cells become live Excel formulas that reference the inputs
* across sheets, so editing an input recalculates downstream values.
*
* Run: npx tsx scripts/export-finanzplan-excel.ts
*/
import { Pool } from 'pg'
import ExcelJS from 'exceljs'
import * as path from 'path'
const CONN = process.env.PG_CONN
if (!CONN) {
console.error('PG_CONN environment variable is required (e.g. postgresql://user:pass@host:port/breakpilot_db)')
process.exit(1)
}
const SCENARIOS: { id: string; slug: string }[] = [
{ id: 'c0000000-0000-0000-0000-000000000200', slug: 'Wandeldarlehen-200k' },
{ id: 'd0000000-0000-0000-0000-000000000201', slug: 'Wandeldarlehen-Bear' },
{ id: 'd0000000-0000-0000-0000-000000000202', slug: 'Wandeldarlehen-Bull' },
{ id: 'd0000000-0000-0000-0000-000000000300', slug: '1Mio-Euro-Base' },
{ id: 'd0000000-0000-0000-0000-000000000301', slug: '1Mio-Euro-Bear' },
{ id: 'd0000000-0000-0000-0000-000000000302', slug: '1Mio-Euro-Bull' },
]
const MONTHS = 60
const START_YEAR = 2026
const FOUNDING_M = 8 // Aug 2026
const FIRST_M = FOUNDING_M // drop Jan..Jul 2026 from the workbook
const VISIBLE_MONTHS = MONTHS - FIRST_M + 1 // 53
// Number format that displays zero as blank
const NUMFMT = '#,##0;-#,##0;""'
// Sheet name registry — full German names with umlauts.
const SHEET = {
Dashboard: 'Dashboard',
Kunden: 'Kunden',
Umsatz: 'Umsatzerlöse',
Personal: 'Personalkosten',
Invest: 'Investitionen',
Material: 'Materialaufwand',
Betrieb: 'Betriebliche Aufwendungen',
Liquid: 'Liquidität',
GuV: 'GuV',
Formulas: 'Formelübersicht',
} as const
// In Excel formulas, sheet names with spaces, periods or umlauts must be wrapped in single quotes.
function S(name: string): string {
return /[\s.\-äöüÄÖÜß]/.test(name) ? `'${name.replace(/'/g, "''")}'` : name
}
// --- column helpers ---
function col(n: number): string {
// 1 -> A, 26 -> Z, 27 -> AA
let s = ''
while (n > 0) {
const r = (n - 1) % 26
s = String.fromCharCode(65 + r) + s
n = Math.floor((n - 1) / 26)
}
return s
}
// monthIdx FIRST_M..MONTHS -> Excel column letter (B..BC). A is label column.
const monthCol = (m: number) => col(m - FIRST_M + 2)
// Iterate visible months (FIRST_M..MONTHS)
function* visibleMonths(): Generator<number> {
for (let m = FIRST_M; m <= MONTHS; m++) yield m
}
function monthToDate(m: number): Date {
const y = START_YEAR + Math.floor((m - 1) / 12)
const mo = ((m - 1) % 12) + 1
return new Date(Date.UTC(y, mo - 1, 1))
}
function dateToMonth(d: Date): number {
return (d.getUTCFullYear() - START_YEAR) * 12 + (d.getUTCMonth() + 1)
}
// --- types ---
type Row = Record<string, unknown> & { id: number; sort_order: number }
interface ScenarioData {
scenario: Row
kunden: Row[]
umsatz: Row[]
material: Row[]
personal: Row[]
betrieb: Row[]
invest: Row[]
sonst: Row[]
liquid: Row[]
guv: Row[]
}
async function loadScenario(pool: Pool, id: string): Promise<ScenarioData> {
const [scen, kunden, umsatz, material, personal, betrieb, invest, sonst, liquid, guv] = await Promise.all([
pool.query('SELECT * FROM fp_scenarios WHERE id=$1', [id]),
pool.query('SELECT * FROM fp_kunden WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_umsatzerloese WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_materialaufwand WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_personalkosten WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id=$1 ORDER BY sort_order, id', [id]),
pool.query('SELECT * FROM fp_investitionen WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_sonst_ertraege WHERE scenario_id=$1 ORDER BY sort_order', [id]),
pool.query('SELECT * FROM fp_liquiditaet WHERE scenario_id=$1 ORDER BY sort_order, id', [id]),
pool.query('SELECT * FROM fp_guv WHERE scenario_id=$1 ORDER BY sort_order, id', [id]),
])
return {
scenario: scen.rows[0],
kunden: kunden.rows,
umsatz: umsatz.rows,
material: material.rows,
personal: personal.rows,
betrieb: betrieb.rows,
invest: invest.rows,
sonst: sonst.rows,
liquid: liquid.rows,
guv: guv.rows,
}
}
// Returns m1..m60 array of values from a JSONB values field
function vals(row: any, field = 'values'): number[] {
const v = (row[field] || {}) as Record<string, number>
const out = new Array<number>(MONTHS)
for (let m = 1; m <= MONTHS; m++) out[m - 1] = Number(v[`m${m}`] || 0)
return out
}
// Write the three header rows (Year / Month-num / Month-name) on a worksheet starting col B.
// We avoid storing Date objects (timezone hazards) and use numeric Year+Month for formulas.
function writeMonthHeader(ws: ExcelJS.Worksheet): void {
ws.getCell('A1').value = 'Year'
ws.getCell('A2').value = 'Month'
ws.getCell('A3').value = 'Label'
for (const m of visibleMonths()) {
const c = monthCol(m)
const d = monthToDate(m)
ws.getCell(`${c}1`).value = d.getUTCFullYear()
ws.getCell(`${c}2`).value = d.getUTCMonth() + 1
ws.getCell(`${c}3`).value =
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()] +
' ' + d.getUTCFullYear()
}
ws.getRow(1).font = { bold: true }
ws.getRow(2).font = { bold: true }
ws.getRow(3).font = { bold: true }
ws.getColumn(1).width = 38
for (let i = 0; i < VISIBLE_MONTHS; i++) ws.getColumn(i + 2).width = 10
// Freeze panes: keep label column + headers visible
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 3 }]
}
// Apply numFmt to all month columns on a row (the data row)
function fmtMonthRow(ws: ExcelJS.Worksheet, r: number, fmt = NUMFMT): void {
for (const m of visibleMonths()) ws.getCell(`${monthCol(m)}${r}`).numFmt = fmt
}
// Apply NUMFMT to all numeric data cells on a worksheet (skipping header rows 1-3)
function applyNumFmtToSheet(ws: ExcelJS.Worksheet, headerRows = 3): void {
ws.eachRow({ includeEmpty: false }, (row, rNum) => {
if (rNum <= headerRows) return
row.eachCell({ includeEmpty: false }, (cell, cNum) => {
if (cNum < 2) return
const v = cell.value
if (typeof v === 'number' || (typeof v === 'object' && v !== null && 'formula' in (v as any))) {
cell.numFmt = NUMFMT
}
})
})
}
// Parse a YYYY-MM-DD string (or Date) into { year, month, day } with no timezone shift.
// node-postgres parses date columns as JS Dates constructed in local time, so use local accessors.
function parseYMD(d: unknown): { y: number; m: number; day: number } | null {
if (!d) return null
if (d instanceof Date) {
return { y: d.getFullYear(), m: d.getMonth() + 1, day: d.getDate() }
}
const s = String(d)
const match = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!match) return null
return { y: Number(match[1]), m: Number(match[2]), day: Number(match[3]) }
}
// Write the 5-year header for GuV sheet
function writeYearHeader(ws: ExcelJS.Worksheet): void {
ws.getCell('A1').value = 'Position'
for (let y = 0; y < 5; y++) {
const c = col(y + 2)
ws.getCell(`${c}1`).value = `${START_YEAR + y}`
}
ws.getRow(1).font = { bold: true }
ws.getColumn(1).width = 38
for (let y = 0; y < 5; y++) ws.getColumn(y + 2).width = 14
}
// =====================================================================
// SHEETS
// =====================================================================
interface SheetRefs {
kunden: Map<string, number> // row_label_with_segment -> excel row
umsatzByLabel: Map<string, number> // section+row_label -> excel row
materialByLabel: Map<string, number>
personalInputRow: Map<number, number> // person id -> excel row (input block)
personalBruttoRow: Map<number, number> // person id -> excel row (Brutto monthly)
personalSozialRow: Map<number, number>
personalTotalRow: Map<number, number>
personalSummary: { brutto: number; sozial: number; total: number; headcount: number; founderHc: number }
investInputRow: Map<number, number>
investInvestRow: Map<number, number>
investAfaRow: Map<number, number>
investTotals: { invest: number; afa: number }
betriebByLabel: Map<string, number>
sonstByIdx: Map<number, number>
sonstSumGesamt: number
liquidByLabel: Map<string, number>
guvByLabel: Map<string, number>
}
function buildKunden(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Kunden)
writeMonthHeader(ws)
// Group kunden by segment_name and row_index. "Gesamt" rows compute formulas; others raw.
const segRows = data.kunden.filter(r => (r as any).segment_name !== 'Gesamt')
const gesamtRows = data.kunden.filter(r => (r as any).segment_name === 'Gesamt')
let r = 4
for (const row of segRows) {
const label = `${(row as any).segment_name}${(row as any).row_label}`
ws.getCell(`A${r}`).value = label
refs.kunden.set((row as any).row_label, r)
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) {
ws.getCell(`${monthCol(m)}${r}`).value = v[m - 1]
}
r++
}
r += 1
for (const row of gesamtRows) {
const label = (row as any).row_label as string
ws.getCell(`A${r}`).value = label
ws.getCell(`A${r}`).font = { bold: true }
// Match: "Neukunden gesamt" sums all "Neukunden (...)" rows
const baseLabel = label.replace(' gesamt', '')
const sumRows: number[] = []
for (const seg of segRows) {
const sl = (seg as any).row_label as string
if (sl.startsWith(baseLabel + ' ')) {
const er = refs.kunden.get(sl)
if (er) sumRows.push(er)
}
}
refs.kunden.set(label, r)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const parts = sumRows.map(er => `${c}${er}`).join('+')
ws.getCell(`${c}${r}`).value = { formula: parts || '0' }
}
r++
}
}
function buildUmsatz(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Umsatz)
writeMonthHeader(ws)
// Determine row positions by sort_order
let r = 4
const rowsByLabel = new Map<string, number>()
for (const row of data.umsatz) {
const label = (row as any).row_label as string
rowsByLabel.set(label, r)
refs.umsatzByLabel.set(label, r)
ws.getCell(`A${r}`).value = label
if ((row as any).section === 'revenue' && !(row as any).is_editable) ws.getCell(`A${r}`).font = { bold: true }
r++
}
// tier extraction
const tierOf = (l: string) => {
const m = l.match(/\(([^)]+)\)/)
return m ? m[1] : l
}
for (const row of data.umsatz) {
const label = (row as any).row_label as string
const section = (row as any).section as string
const editable = (row as any).is_editable as boolean
const rr = rowsByLabel.get(label)!
if (section === 'price' || (section === 'quantity' /* not editable but no source we can recompute */) || (section === 'revenue' && editable)) {
// raw values
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
} else if (section === 'revenue' && !editable && label !== 'GESAMTUMSATZ') {
// Umsatz (X) = price_row * quantity_row for same tier
const tier = tierOf(label)
const priceRow = data.umsatz.find(x => (x as any).section === 'price' && tierOf((x as any).row_label) === tier)
const qtyRow = data.umsatz.find(x => (x as any).section === 'quantity' && tierOf((x as any).row_label) === tier)
const prR = priceRow ? rowsByLabel.get((priceRow as any).row_label) : undefined
const qR = qtyRow ? rowsByLabel.get((qtyRow as any).row_label) : undefined
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (prR && qR) ws.getCell(`${c}${rr}`).value = { formula: `${c}${prR}*${c}${qR}` }
else ws.getCell(`${c}${rr}`).value = vals(row)[m - 1]
}
} else if (label === 'GESAMTUMSATZ') {
// SUM all revenue rows except itself
const revRows = data.umsatz
.filter(x => (x as any).section === 'revenue' && (x as any).row_label !== 'GESAMTUMSATZ')
.map(x => rowsByLabel.get((x as any).row_label)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: revRows.map(er => `${c}${er}`).join('+') }
}
}
}
}
function buildMaterial(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Material)
writeMonthHeader(ws)
const costRows = data.material.filter(r => (r as any).section === 'cost')
let r = 4
const labelToRow = new Map<string, number>()
for (const row of costRows) {
const label = (row as any).row_label as string
labelToRow.set(label, r)
refs.materialByLabel.set(label, r)
ws.getCell(`A${r}`).value = label
if (label === 'SUMME') ws.getCell(`A${r}`).font = { bold: true }
r++
}
const sumRow = labelToRow.get('SUMME')
for (const row of costRows) {
const label = (row as any).row_label as string
const rr = labelToRow.get(label)!
if (label === 'SUMME') {
// SUM all other cost rows
const ids = [...labelToRow.entries()].filter(([k]) => k !== 'SUMME').map(([, er]) => er)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: ids.map(er => `${c}${er}`).join('+') }
}
} else if (label.includes('Cloud-Hosting')) {
// = MAX(0, ${S(SHEET.Kunden)}!Bestandskunden_gesamt[m] - 10) * 100 + 1500, only from m=FOUNDING_M onwards
const bestRow = refs.kunden.get('Bestandskunden gesamt')
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (m < FOUNDING_M || !bestRow) {
ws.getCell(`${c}${rr}`).value = 0
} else {
ws.getCell(`${c}${rr}`).value = { formula: `MAX(0,${S(SHEET.Kunden)}!${c}${bestRow}-10)*100+1500` }
}
}
} else {
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
}
}
}
function buildPersonal(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Personal)
writeMonthHeader(ws)
// Input block at top (cols A..H)
// A: Person Nr, B: Name, C: Position, D: Brutto, E: Raise%, F: Sozial%, G: Start, H: End
// But col A is used for the month-header row labels too. So we use a different layout:
// Row 5: Input headers
// Row 6..6+N-1: input rows (A: Nr, B: Name, ..., G: Start, H: End)
// Then leave a gap and write Brutto/Sozial/Total monthly blocks where col A=label and col B..BI=m1..m60.
ws.getCell('A5').value = 'Inputs'
ws.getCell('A5').font = { bold: true }
// Inputs: A=Nr, B=Name, C=Position, D=Brutto, E=Raise%, F=Sozial%, G=StartYear, H=StartMonth, I=EndYear, J=EndMonth
const inHdr = ['Nr', 'Name', 'Position', 'Brutto/Monat', 'Raise %/Yr', 'AG-Sozial %', 'Start-Jahr', 'Start-Monat', 'End-Jahr', 'End-Monat']
inHdr.forEach((h, i) => {
ws.getCell(`${col(i + 1)}6`).value = h
ws.getCell(`${col(i + 1)}6`).font = { bold: true }
})
const personStart = 7
data.personal.forEach((row, i) => {
const rr = personStart + i
refs.personalInputRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = (row as any).person_nr || ''
ws.getCell(`B${rr}`).value = (row as any).person_name
ws.getCell(`C${rr}`).value = (row as any).position || ''
ws.getCell(`D${rr}`).value = Number((row as any).brutto_monthly || 0)
ws.getCell(`E${rr}`).value = Number((row as any).annual_raise_pct || 0)
ws.getCell(`F${rr}`).value = Number((row as any).ag_sozial_pct || 0)
const sd = parseYMD((row as any).start_date)
const ed = parseYMD((row as any).end_date)
ws.getCell(`G${rr}`).value = sd ? sd.y : ''
ws.getCell(`H${rr}`).value = sd ? sd.m : ''
ws.getCell(`I${rr}`).value = ed ? ed.y : ''
ws.getCell(`J${rr}`).value = ed ? ed.m : ''
})
// Block: Brutto monthly per person
const bruttoStart = personStart + data.personal.length + 2
ws.getCell(`A${bruttoStart - 1}`).value = 'Brutto monatlich'
ws.getCell(`A${bruttoStart - 1}`).font = { bold: true }
data.personal.forEach((row, i) => {
const rr = bruttoStart + i
const inR = refs.personalInputRow.get((row as any).id)!
refs.personalBruttoRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Brutto`
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
// Compare monthIndex = year*12 + month. Active if startKey<=cur and (endKey is blank or cur<=endKey).
// Brutto = ROUND(brutto * (1+raise/100)^(curYear - startYear), 0)
const cur = `${c}$1*12+${c}$2`
const startKey = `$G$${inR}*12+$H$${inR}`
const endKey = `$I$${inR}*12+$J$${inR}`
const active = `AND(${cur}>=${startKey},OR($I$${inR}="",${cur}<=${endKey}))`
const expo = `${c}$1-$G$${inR}`
const f = `IF(${active},ROUND($D$${inR}*(1+$E$${inR}/100)^(${expo}),0),0)`
ws.getCell(`${c}${rr}`).value = { formula: f }
}
})
// Sum Brutto
const sumBruttoRow = bruttoStart + data.personal.length + 1
ws.getCell(`A${sumBruttoRow}`).value = 'TOTAL Brutto'
ws.getCell(`A${sumBruttoRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const refs1 = data.personal.map(p => `${c}${refs.personalBruttoRow.get((p as any).id)!}`).join('+')
ws.getCell(`${c}${sumBruttoRow}`).value = { formula: refs1 || '0' }
}
refs.personalSummary.brutto = sumBruttoRow
// Block: Sozial monthly
const sozialStart = sumBruttoRow + 2
ws.getCell(`A${sozialStart - 1}`).value = 'AG-Sozialversicherung monatlich'
ws.getCell(`A${sozialStart - 1}`).font = { bold: true }
data.personal.forEach((row, i) => {
const rr = sozialStart + i
const inR = refs.personalInputRow.get((row as any).id)!
const bR = refs.personalBruttoRow.get((row as any).id)!
refs.personalSozialRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Sozial`
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${c}${bR}*$F$${inR}/100,0)` }
}
})
const sumSozialRow = sozialStart + data.personal.length + 1
ws.getCell(`A${sumSozialRow}`).value = 'TOTAL Sozial'
ws.getCell(`A${sumSozialRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const refs1 = data.personal.map(p => `${c}${refs.personalSozialRow.get((p as any).id)!}`).join('+')
ws.getCell(`${c}${sumSozialRow}`).value = { formula: refs1 || '0' }
}
refs.personalSummary.sozial = sumSozialRow
// Block: Total = Brutto + Sozial
const totalStart = sumSozialRow + 2
ws.getCell(`A${totalStart - 1}`).value = 'Total pro Person monatlich'
ws.getCell(`A${totalStart - 1}`).font = { bold: true }
data.personal.forEach((row, i) => {
const rr = totalStart + i
const bR = refs.personalBruttoRow.get((row as any).id)!
const sR = refs.personalSozialRow.get((row as any).id)!
refs.personalTotalRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Total`
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: `${c}${bR}+${c}${sR}` }
}
})
const sumTotalRow = totalStart + data.personal.length + 1
ws.getCell(`A${sumTotalRow}`).value = 'TOTAL Personalkosten'
ws.getCell(`A${sumTotalRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const refs1 = data.personal.map(p => `${c}${refs.personalTotalRow.get((p as any).id)!}`).join('+')
ws.getCell(`${c}${sumTotalRow}`).value = { formula: refs1 || '0' }
}
refs.personalSummary.total = sumTotalRow
// Headcount (= count of persons with brutto>0 in that month)
const hcRow = sumTotalRow + 2
ws.getCell(`A${hcRow}`).value = 'Headcount'
ws.getCell(`A${hcRow}`).font = { bold: true }
const firstBr = refs.personalBruttoRow.get((data.personal[0] as any).id)!
const lastBr = refs.personalBruttoRow.get((data.personal[data.personal.length - 1] as any).id)!
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${hcRow}`).value = { formula: `COUNTIF(${c}${firstBr}:${c}${lastBr},">0")` }
}
refs.personalSummary.headcount = hcRow
// Headcount minus founders (2). Used by some opex formulas.
const hcMinusRow = hcRow + 1
ws.getCell(`A${hcMinusRow}`).value = 'Headcount (ohne 2 Gruender)'
ws.getCell(`A${hcMinusRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${hcMinusRow}`).value = { formula: `MAX(0,${c}${hcRow}-2)` }
}
refs.personalSummary.founderHc = hcMinusRow
}
function buildInvest(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Invest)
writeMonthHeader(ws)
// Input block: A=Position, B=Kategorie, C=Betrag, D=Anschaffungs-Jahr, E=Anschaffungs-Monat, F=AfA Jahre
ws.getCell('A5').value = 'Inputs'
ws.getCell('A5').font = { bold: true }
const inHdr = ['Position', 'Kategorie', 'Betrag', 'Anschaff-Jahr', 'Anschaff-Monat', 'AfA Jahre']
inHdr.forEach((h, i) => {
ws.getCell(`${col(i + 1)}6`).value = h
ws.getCell(`${col(i + 1)}6`).font = { bold: true }
})
const itemStart = 7
data.invest.forEach((row, i) => {
const rr = itemStart + i
refs.investInputRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = (row as any).item_name
ws.getCell(`B${rr}`).value = (row as any).category || ''
ws.getCell(`C${rr}`).value = Number((row as any).purchase_amount || 0)
const pd = parseYMD((row as any).purchase_date)
ws.getCell(`D${rr}`).value = pd ? pd.y : ''
ws.getCell(`E${rr}`).value = pd ? pd.m : ''
ws.getCell(`F${rr}`).value = (row as any).afa_years ?? ''
})
// Block: Investitionsausgaben per item
const invStart = itemStart + data.invest.length + 2
ws.getCell(`A${invStart - 1}`).value = 'Investitionsausgaben'
ws.getCell(`A${invStart - 1}`).font = { bold: true }
data.invest.forEach((row, i) => {
const rr = invStart + i
const inR = refs.investInputRow.get((row as any).id)!
refs.investInvestRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = `${(row as any).item_name} — Ausgabe`
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
// Outlay only in purchase month: when cur_year==purchase_year AND cur_month==purchase_month
const f = `IF(AND(${c}$1=$D$${inR},${c}$2=$E$${inR}),$C$${inR},0)`
ws.getCell(`${c}${rr}`).value = { formula: f }
}
})
const sumInvestRow = invStart + data.invest.length + 1
ws.getCell(`A${sumInvestRow}`).value = 'TOTAL Investitionsausgaben'
ws.getCell(`A${sumInvestRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const parts = data.invest.map(it => `${c}${refs.investInvestRow.get((it as any).id)!}`).join('+')
ws.getCell(`${c}${sumInvestRow}`).value = { formula: parts || '0' }
}
refs.investTotals.invest = sumInvestRow
// Block: AfA per item
const afaStart = sumInvestRow + 2
ws.getCell(`A${afaStart - 1}`).value = 'Abschreibungen (AfA)'
ws.getCell(`A${afaStart - 1}`).font = { bold: true }
data.invest.forEach((row, i) => {
const rr = afaStart + i
const inR = refs.investInputRow.get((row as any).id)!
refs.investAfaRow.set((row as any).id, rr)
ws.getCell(`A${rr}`).value = `${(row as any).item_name} — AfA`
const hasYears = (row as any).afa_years != null && Number((row as any).afa_years) > 0
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
// Inputs: D=purchaseYear, E=purchaseMonth, F=afa_years
// monthIndex = year*12 + month
const cur = `${c}$1*12+${c}$2`
const startKey = `$D$${inR}*12+$E$${inR}`
// end_exclusive = startKey + F*12
const endKey = `$D$${inR}*12+$E$${inR}+$F$${inR}*12`
let f: string
if (hasYears) {
f = `IF(AND(${cur}>=${startKey},${cur}<${endKey}),ROUND($C$${inR}/($F$${inR}*12),0),0)`
} else {
// GWG: full in purchase month
f = `IF(AND(${c}$1=$D$${inR},${c}$2=$E$${inR}),$C$${inR},0)`
}
ws.getCell(`${c}${rr}`).value = { formula: f }
}
})
const sumAfaRow = afaStart + data.invest.length + 1
ws.getCell(`A${sumAfaRow}`).value = 'TOTAL AfA'
ws.getCell(`A${sumAfaRow}`).font = { bold: true }
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
const parts = data.invest.map(it => `${c}${refs.investAfaRow.get((it as any).id)!}`).join('+')
ws.getCell(`${c}${sumAfaRow}`).value = { formula: parts || '0' }
}
refs.investTotals.afa = sumAfaRow
}
function buildSonst(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet('SonstErtraege')
writeMonthHeader(ws)
// Group by category. is_sum_row=true means subtotal across that category's detail rows.
let r = 4
const rowsByLabel = new Map<string, number>()
// Pre-pass: assign rows
for (const row of data.sonst) {
rowsByLabel.set(`${(row as any).category}|${(row as any).row_label}|${(row as any).row_index}`, r)
refs.sonstByIdx.set((row as any).row_index, r)
ws.getCell(`A${r}`).value = `${(row as any).category}${(row as any).row_label}`
if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true }
r++
}
for (const row of data.sonst) {
const key = `${(row as any).category}|${(row as any).row_label}|${(row as any).row_index}`
const rr = rowsByLabel.get(key)!
if ((row as any).row_label === 'GESAMTUMSATZ') {
// Sum of all category-sum rows
const sumIds = data.sonst
.filter(x => (x as any).is_sum_row && (x as any).row_label !== 'GESAMTUMSATZ')
.map(x => rowsByLabel.get(`${(x as any).category}|${(x as any).row_label}|${(x as any).row_index}`)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: sumIds.length ? sumIds.map(er => `${c}${er}`).join('+') : '0' }
}
refs.sonstSumGesamt = rr
} else if ((row as any).is_sum_row) {
// Sum of detail rows in same category
const detailIds = data.sonst
.filter(x => (x as any).category === (row as any).category && !(x as any).is_sum_row)
.map(x => rowsByLabel.get(`${(x as any).category}|${(x as any).row_label}|${(x as any).row_index}`)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' }
}
} else {
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
}
}
}
function buildBetrieb(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Betrieb)
writeMonthHeader(ws)
// Excel column for the date row (row 3): column letter c
// Engine-defined formula rows (label -> per-cell formula factory)
// Per-unit formulas (apply only m >= FOUNDING_M, else 0)
const perUnitMap: Record<string, { perUnit: number; source: 'hc' | 'hcMinusFounders' | 'bestand' }> = {
'Fort-/Weiterbildungskosten (F)': { perUnit: 300, source: 'hcMinusFounders' },
'Reisekosten (F)': { perUnit: 75, source: 'hc' },
'Bewirtungskosten (F)': { perUnit: 50, source: 'bestand' },
'Internet/Mobilfunk (F)': { perUnit: 50, source: 'hc' },
}
let r = 4
for (const row of data.betrieb) {
refs.betriebByLabel.set((row as any).row_label as string, r)
ws.getCell(`A${r}`).value = `${(row as any).category}${(row as any).row_label}`
if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true }
r++
}
// First pass: simple rows
for (const row of data.betrieb) {
const label = (row as any).row_label as string
const rr = refs.betriebByLabel.get(label)!
const isSum = (row as any).is_sum_row as boolean
const category = (row as any).category as string
if (label === 'Personalkosten' && category === 'personal') {
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}` }
}
continue
}
if (label === 'Abschreibungen' && category === 'abschreibungen') {
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: `${S(SHEET.Invest)}!${c}${refs.investTotals.afa}` }
}
continue
}
if (perUnitMap[label]) {
const { perUnit, source } = perUnitMap[label]
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue }
let src = ''
if (source === 'hc') src = `${S(SHEET.Personal)}!${c}${refs.personalSummary.headcount}`
else if (source === 'hcMinusFounders') src = `${S(SHEET.Personal)}!${c}${refs.personalSummary.founderHc}`
else src = `${S(SHEET.Kunden)}!${c}${refs.kunden.get('Bestandskunden gesamt')!}`
ws.getCell(`${c}${rr}`).value = { formula: `${src}*${perUnit}` }
}
continue
}
if (label.includes('Berufsgenossenschaft')) {
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue }
ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${S(SHEET.Personal)}!${c}${refs.personalSummary.brutto}*0.005,0)` }
}
continue
}
if (label.includes('Allgemeine Marketingkosten')) {
const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')!
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue }
const rate = m <= 36 ? 0.08 : 0.10
ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${S(SHEET.Umsatz)}!${c}${umsRow}*${rate},0)` }
}
continue
}
if (isSum) continue // handled below
// Default: raw values
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
}
// Second pass: category sums + sum sonstige + gesamtkosten + gewerbesteuer
// Determine for each row: which non-tax-opex rows belong to "rest of opex" for Gewerbesteuer
const nonTaxOpexRows = data.betrieb.filter(r => {
const cat = (r as any).category as string
if (cat === 'steuern' || cat === 'personal' || cat === 'abschreibungen') return false
if ((r as any).is_sum_row) return false
const lbl = (r as any).row_label as string
if (lbl.includes('Summe') || lbl.includes('SUMME')) return false
return true
})
for (const row of data.betrieb) {
const label = (row as any).row_label as string
const rr = refs.betriebByLabel.get(label)!
const isSum = (row as any).is_sum_row as boolean
const category = (row as any).category as string
if (label.includes('Gewerbesteuer')) {
const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')!
const matSum = refs.materialByLabel.get('SUMME')!
const persTotal = refs.personalSummary.total
const afaTotal = refs.investTotals.afa
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue }
const opexParts = nonTaxOpexRows
.map(or => `${c}${refs.betriebByLabel.get((or as any).row_label as string)!}`)
.join('+')
const profit = `${S(SHEET.Umsatz)}!${c}${umsRow}-${S(SHEET.Material)}!${c}${matSum}-${S(SHEET.Personal)}!${c}${persTotal}-${S(SHEET.Invest)}!${c}${afaTotal}-(${opexParts})`
ws.getCell(`${c}${rr}`).value = { formula: `IF((${profit})>0,ROUND((${profit})*0.1225,0),0)` }
}
continue
}
if (!isSum) continue
if (label.includes('Summe sonstige')) {
// Sum: all betrieb rows except personal/abschreibungen, sum rows, gesamtkosten
const detailIds = data.betrieb.filter(x => {
const xl = (x as any).row_label as string
if (xl === 'Personalkosten' || xl === 'Abschreibungen') return false
if (xl.includes('Summe sonstige') || xl.includes('Gesamtkosten') || xl.includes('SUMME Betriebliche')) return false
if ((x as any).is_sum_row) return false
if ((x as any).category === 'personal' || (x as any).category === 'abschreibungen') return false
return true
}).map(x => refs.betriebByLabel.get((x as any).row_label as string)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' }
}
continue
}
if (label.includes('Gesamtkosten') || label.includes('SUMME Betriebliche')) {
const persR = refs.betriebByLabel.get('Personalkosten')!
const abrR = refs.betriebByLabel.get('Abschreibungen')!
const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1]
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: `${c}${persR}+${c}${abrR}+${c}${sonstSum}` }
}
continue
}
// Category sum (steuern, versicherungen, besondere, marketing, sonstige, fahrzeug)
const detailIds = data.betrieb.filter(x => (x as any).category === category && !(x as any).is_sum_row)
.map(x => refs.betriebByLabel.get((x as any).row_label as string)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' }
}
}
}
function buildLiquid(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.Liquid)
writeMonthHeader(ws)
let r = 4
for (const row of data.liquid) {
refs.liquidByLabel.set((row as any).row_label as string, r)
ws.getCell(`A${r}`).value = `${(row as any).row_type}${(row as any).row_label}`
if (!(row as any).is_editable && (row as any).row_type !== 'einzahlung') ws.getCell(`A${r}`).font = { bold: true }
r++
}
// Pass 1: linked rows + editable raw rows
for (const row of data.liquid) {
const label = (row as any).row_label as string
const rType = (row as any).row_type as string
const editable = (row as any).is_editable as boolean
const rr = refs.liquidByLabel.get(label)!
const setFormulaAll = (fn: (c: string) => string) => {
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
ws.getCell(`${c}${rr}`).value = { formula: fn(c) }
}
}
if (label === 'Umsatzerlöse') {
const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')!
setFormulaAll(c => `${S(SHEET.Umsatz)}!${c}${umsRow}`)
continue
}
if (label === 'Sonst. betriebl. Erträge') {
// SonstErtraege sheet was dropped (empty in DB). Write 0 inline.
for (const m of visibleMonths()) ws.getCell(`${monthCol(m)}${rr}`).value = 0
continue
}
if (label === 'Materialaufwand') {
const matR = refs.materialByLabel.get('SUMME')!
setFormulaAll(c => `${S(SHEET.Material)}!${c}${matR}`)
continue
}
if (label === 'Personalkosten') {
setFormulaAll(c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}`)
continue
}
if (label === 'Sonstige Kosten') {
const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1]
if (sonstSum) {
setFormulaAll(c => `${S(SHEET.Betrieb)}!${c}${sonstSum}`)
continue
}
}
if (label === 'Investitionen' && rType === 'ueberschuss') {
setFormulaAll(c => `${S(SHEET.Invest)}!${c}${refs.investTotals.invest}`)
continue
}
// Editable rows: write raw values (stored in DB)
if (editable) {
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
continue
}
// For Gewerbesteuer / Körperschaftsteuer in Liquiditaet — they reference GuV (1/12 of annual)
// Write raw values for now (they have circular dependency we don't want in Excel)
const v = vals(row)
for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1]
}
// Pass 2: aggregation rows (Summe ERTRÄGE, AUSZAHLUNGEN, UEBERSCHUSS chain, Kontostand, LIQUIDITAT)
const sumEin = refs.liquidByLabel.get('Summe ERTRÄGE') || refs.liquidByLabel.get('Summe EINZAHLUNGEN')
const sumAus = refs.liquidByLabel.get('Summe AUSZAHLUNGEN')
const uvi = refs.liquidByLabel.get('ÜBERSCHUSS VOR INVESTITIONEN') || refs.liquidByLabel.get('UEBERSCHUSS VOR INVESTITIONEN')
const uve = refs.liquidByLabel.get('ÜBERSCHUSS VOR ENTNAHMEN') || refs.liquidByLabel.get('UEBERSCHUSS VOR ENTNAHMEN')
const ueb = refs.liquidByLabel.get('ÜBERSCHUSS') || refs.liquidByLabel.get('UEBERSCHUSS')
const liqInvest = refs.liquidByLabel.get('Investitionen')
const entnahmen = refs.liquidByLabel.get('Kapitalentnahmen/Ausschüttungen') || refs.liquidByLabel.get('Kapitalentnahmen/Ausschuettungen')
// Find kontostand (no LIQUIDIT in label) and liquiditaet (with LIQUIDIT)
const kontostandRow = data.liquid.find(r => (r as any).row_type === 'kontostand' && !((r as any).row_label as string).includes('LIQUIDIT'))
const liqRow = data.liquid.find(r => (r as any).row_type === 'kontostand' && ((r as any).row_label as string).includes('LIQUIDIT'))
const konto = kontostandRow ? refs.liquidByLabel.get((kontostandRow as any).row_label) : undefined
const liquidit = liqRow ? refs.liquidByLabel.get((liqRow as any).row_label) : undefined
const einzIds = data.liquid.filter(x => (x as any).row_type === 'einzahlung' && (x as any).row_label !== 'Summe ERTRÄGE' && (x as any).row_label !== 'Summe EINZAHLUNGEN')
.map(x => refs.liquidByLabel.get((x as any).row_label as string)!)
const auszIds = data.liquid.filter(x => (x as any).row_type === 'auszahlung' && (x as any).row_label !== 'Summe AUSZAHLUNGEN')
.map(x => refs.liquidByLabel.get((x as any).row_label as string)!)
for (let m = FIRST_M; m <= MONTHS; m++) {
const c = monthCol(m)
if (sumEin) ws.getCell(`${c}${sumEin}`).value = { formula: einzIds.map(er => `${c}${er}`).join('+') || '0' }
if (sumAus) ws.getCell(`${c}${sumAus}`).value = { formula: auszIds.map(er => `${c}${er}`).join('+') || '0' }
if (uvi && sumEin && sumAus) ws.getCell(`${c}${uvi}`).value = { formula: `${c}${sumEin}-${c}${sumAus}` }
if (uve && uvi && liqInvest) ws.getCell(`${c}${uve}`).value = { formula: `${c}${uvi}-${c}${liqInvest}` }
if (ueb && uve && entnahmen) ws.getCell(`${c}${ueb}`).value = { formula: `${c}${uve}-${c}${entnahmen}` }
else if (ueb && uve) ws.getCell(`${c}${ueb}`).value = { formula: `${c}${uve}` }
if (konto && liquidit && ueb) {
if (m === FIRST_M) ws.getCell(`${c}${konto}`).value = 0
else {
const prev = monthCol(m - 1)
ws.getCell(`${c}${konto}`).value = { formula: `${prev}${liquidit}` }
}
ws.getCell(`${c}${liquidit}`).value = { formula: `${c}${konto}+${c}${ueb}` }
}
}
}
function buildGuV(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void {
const ws = wb.addWorksheet(SHEET.GuV)
writeYearHeader(ws)
let r = 2
for (const row of data.guv) {
refs.guvByLabel.set((row as any).row_label as string, r)
ws.getCell(`A${r}`).value = (row as any).row_label
if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true }
r++
}
// For each year, the columns B..F (idx 2..6) correspond to 2026..2030 (months m=1..12, 13..24, ..., 49..60).
const yearCol = (y: number) => col(y - START_YEAR + 2)
const monthRangeFor = (y: number, sheet: string, row: number) => {
const startM = Math.max(FIRST_M, (y - START_YEAR) * 12 + 1)
const endM = startM > (y - START_YEAR) * 12 + 12 ? startM : (y - START_YEAR) * 12 + 12
return `SUM(${S(sheet)}!${monthCol(startM)}${row}:${monthCol(endM)}${row})`
}
// For each GuV row, build formula by year
for (const row of data.guv) {
const label = (row as any).row_label as string
const rr = refs.guvByLabel.get(label)!
const setEachYear = (fn: (y: number) => string | { formula: string } | number) => {
for (let y = START_YEAR; y <= START_YEAR + 4; y++) {
const c = yearCol(y)
const v = fn(y)
if (typeof v === 'string') ws.getCell(`${c}${rr}`).value = { formula: v }
else if (typeof v === 'number') ws.getCell(`${c}${rr}`).value = v
else ws.getCell(`${c}${rr}`).value = v
}
}
if (label === 'Umsatzerlöse' || label === 'Gesamtleistung') {
setEachYear(y => monthRangeFor(y, SHEET.Umsatz, refs.umsatzByLabel.get('GESAMTUMSATZ')!))
continue
}
if (label === 'Summe Materialaufwand') {
setEachYear(y => monthRangeFor(y, SHEET.Material, refs.materialByLabel.get('SUMME')!))
continue
}
if (label === 'Rohergebnis') {
const umsR = refs.guvByLabel.get('Umsatzerlöse')!
const matR = refs.guvByLabel.get('Summe Materialaufwand')!
setEachYear(y => `${yearCol(y)}${umsR}-${yearCol(y)}${matR}`)
continue
}
if (label === 'Löhne und Gehälter') {
setEachYear(y => monthRangeFor(y, SHEET.Personal, refs.personalSummary.brutto))
continue
}
if (label === 'Soziale Abgaben') {
setEachYear(y => monthRangeFor(y, SHEET.Personal, refs.personalSummary.sozial))
continue
}
if (label === 'Summe Personalaufwand') {
const lR = refs.guvByLabel.get('Löhne und Gehälter')!
const sR = refs.guvByLabel.get('Soziale Abgaben')!
setEachYear(y => `${yearCol(y)}${lR}+${yearCol(y)}${sR}`)
continue
}
if (label === 'Abschreibungen') {
setEachYear(y => monthRangeFor(y, SHEET.Invest, refs.investTotals.afa))
continue
}
if (label === 'Sonst. betriebl. Aufwendungen') {
const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1]
if (sonstSum) setEachYear(y => monthRangeFor(y, SHEET.Betrieb, sonstSum))
continue
}
if (label === 'Sonst. betriebl. Erträge') {
// SonstErtraege sheet is dropped (empty in DB) — leave as 0
continue
}
if (label === 'Summe sonst. Erträge') {
const sR = refs.guvByLabel.get('Sonst. betriebl. Erträge')!
setEachYear(y => `${yearCol(y)}${sR}`)
continue
}
if (label === 'EBIT') {
const um = refs.guvByLabel.get('Umsatzerlöse')!
const mat = refs.guvByLabel.get('Summe Materialaufwand')!
const per = refs.guvByLabel.get('Summe Personalaufwand')!
const abr = refs.guvByLabel.get('Abschreibungen')!
const sonst = refs.guvByLabel.get('Sonst. betriebl. Aufwendungen')!
setEachYear(y => {
const c = yearCol(y)
return `${c}${um}-${c}${mat}-${c}${per}-${c}${abr}-${c}${sonst}`
})
continue
}
if (label === 'Ergebnis nach Steuern' || label === 'Jahresüberschuss') {
const ebit = refs.guvByLabel.get('EBIT')!
const steu = refs.guvByLabel.get('Steuern gesamt')!
const zinsE = refs.guvByLabel.get('Zinserträge')!
const zinsA = refs.guvByLabel.get('Zinsaufwendungen')!
setEachYear(y => `${yearCol(y)}${ebit}+${yearCol(y)}${zinsE}-${yearCol(y)}${zinsA}-${yearCol(y)}${steu}`)
continue
}
if (label === 'Steuern gesamt') {
const gst = refs.guvByLabel.get('Gewerbesteuer')!
const kst = refs.guvByLabel.get('Körperschaftssteuer')!
setEachYear(y => `${yearCol(y)}${gst}+${yearCol(y)}${kst}`)
continue
}
// Körperschaftssteuer / Gewerbesteuer: keep stored values (tax with Verlustvortrag is too complex to inline)
// Other rows (Materialaufwand Waren/Leistungen, Bestandsveränderungen, Zinserträge, Zinsaufwendungen, Sonstige Steuern): raw values from DB
const v = (row as any).values as Record<string, number>
for (let y = START_YEAR; y <= START_YEAR + 4; y++) {
ws.getCell(`${yearCol(y)}${rr}`).value = Number(v?.[`y${y}`] || 0)
}
}
}
/**
* Dashboard sheet with KPI tables that chart drivers will reference.
* Layout:
* Annual KPIs (B2..F11): Year | Umsatz | Material | Personnel | AfA | Sonst Opex | EBIT | Steuern | Jahresueberschuss
* Monthly Liquidity (B14:?): Month label header + value row
* Monthly Headcount (B17): label header + value row
* Monthly Personalkosten (B20): label header + value row
*
* Charts are added by the openpyxl post-processor referencing these ranges.
*/
function buildDashboard(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs, scenarioName: string): void {
const ws = wb.addWorksheet(SHEET.Dashboard)
ws.getCell('A1').value = scenarioName
ws.getCell('A1').font = { bold: true, size: 16 }
ws.getColumn(1).width = 28
// Annual KPI table (rows 3..11 to leave room): columns A=Label, B..F=2026..2030
const yearCol = (y: number) => col(y - START_YEAR + 2)
ws.getCell('A3').value = 'Jahres-KPI'
ws.getCell('A3').font = { bold: true }
for (let y = START_YEAR; y < START_YEAR + 5; y++) {
const c = yearCol(y)
ws.getCell(`${c}3`).value = y
ws.getCell(`${c}3`).font = { bold: true }
ws.getColumn(c.charCodeAt(0) - 64).width = 14
}
// Metrics map to GuV rows (formula-based pull). Some need rebuilt formulas.
const metrics: { label: string; build: (c: string) => string }[] = [
{ label: 'Umsatzerlöse', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Umsatzerlöse')!}` },
{ label: 'Materialaufwand', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Summe Materialaufwand')!}` },
{ label: 'Personalkosten', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Summe Personalaufwand')!}` },
{ label: 'Abschreibungen', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Abschreibungen')!}` },
{ label: 'Sonst. betr. Aufwand', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Sonst. betriebl. Aufwendungen')!}` },
{ label: 'EBIT', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('EBIT')!}` },
{ label: 'Steuern', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Steuern gesamt')!}` },
{ label: 'Jahresüberschuss', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Jahresüberschuss')!}` },
]
metrics.forEach((m, i) => {
const rr = 4 + i
ws.getCell(`A${rr}`).value = m.label
if (m.label === 'EBIT' || m.label === 'Jahresüberschuss') ws.getCell(`A${rr}`).font = { bold: true }
for (let y = START_YEAR; y < START_YEAR + 5; y++) {
const c = yearCol(y)
ws.getCell(`${c}${rr}`).value = { formula: m.build(c) }
ws.getCell(`${c}${rr}`).numFmt = NUMFMT
}
})
// Monthly drivers — each driver gets a 2-row block: header (month label) + values
const blockBase = 4 + metrics.length + 2 // ~14
const writeMonthlyBlock = (label: string, baseRow: number, formulaBuilder: (c: string) => string): void => {
ws.getCell(`A${baseRow}`).value = label
ws.getCell(`A${baseRow}`).font = { bold: true }
ws.getCell(`A${baseRow + 1}`).value = 'Monat'
ws.getCell(`A${baseRow + 2}`).value = label
for (const m of visibleMonths()) {
const c = monthCol(m)
const d = monthToDate(m)
const monthName = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getUTCMonth()]
ws.getCell(`${c}${baseRow + 1}`).value = `${monthName} ${d.getUTCFullYear()}`
ws.getCell(`${c}${baseRow + 2}`).value = { formula: formulaBuilder(c) }
ws.getCell(`${c}${baseRow + 2}`).numFmt = NUMFMT
}
}
// Liquidity
const liqLabel = data.liquid.find(r => (r as any).row_type === 'kontostand' && ((r as any).row_label as string).includes('LIQUIDIT'))
const liqRow = liqLabel ? refs.liquidByLabel.get((liqLabel as any).row_label as string)! : 0
writeMonthlyBlock('Liquidität (monatlich)', blockBase, c => `${S(SHEET.Liquid)}!${c}${liqRow}`)
// Headcount
writeMonthlyBlock('Headcount (monatlich)', blockBase + 4, c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.headcount}`)
// Personalkosten total
writeMonthlyBlock('Personalkosten total (monatlich)', blockBase + 8, c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}`)
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 3 }]
}
function buildFormulas(wb: ExcelJS.Workbook, data: ScenarioData): void {
const ws = wb.addWorksheet(SHEET.Formulas)
ws.getCell('A1').value = 'Sheet'
ws.getCell('B1').value = 'Row'
ws.getCell('C1').value = 'Formel-Beschreibung'
ws.getRow(1).font = { bold: true }
ws.getColumn(1).width = 18
ws.getColumn(2).width = 50
ws.getColumn(3).width = 80
let r = 2
const push = (sheet: string, label: string, desc: string) => {
ws.getCell(`A${r}`).value = sheet
ws.getCell(`B${r}`).value = label
ws.getCell(`C${r}`).value = desc
r++
}
push('Kunden', '... gesamt', 'SUM ueber 3 Segmente')
push('Umsatzerloese', 'Umsatz (X)', 'Preis * Anzahl Kunden im Tier X')
push('Umsatzerloese', 'GESAMTUMSATZ', 'SUM aller Umsatz-Zeilen + Beratung & Service')
push('Materialaufwand', 'Cloud-Hosting', 'MAX(0, Bestandskunden_gesamt - 10) * 100 + 1500 (ab Aug 2026)')
push('Materialaufwand', 'SUMME', 'SUM aller Cost-Zeilen')
push('Personalkosten', 'Brutto je Person', 'IF(Monat in [start..end], Brutto * (1+raise)^Jahre_seit_start, 0)')
push('Personalkosten', 'Sozial je Person', 'Brutto * AG-Sozial% / 100')
push('Personalkosten', 'Total je Person', 'Brutto + Sozial')
push('Personalkosten', 'TOTAL Personalkosten', 'SUM ueber alle Positionen')
push('Personalkosten', 'Headcount', 'COUNTIF Brutto>0 in der Spalte')
push('Investitionen', 'Ausgabe je Position', 'IF(Monat == Anschaffungsmonat, Betrag, 0)')
push('Investitionen', 'AfA je Position', 'IF AfA-Jahre vorhanden: linear ueber afa_jahre*12 Monate, sonst voll im Anschaffungsmonat (GWG)')
push('Betriebliche', 'Personalkosten (Zeile)', '=Personalkosten!TOTAL Personalkosten')
push('Betriebliche', 'Abschreibungen (Zeile)', '=Investitionen!TOTAL AfA')
push('Betriebliche', 'Fort-/Weiterbildungskosten', 'Headcount(ohne Gruender) * 300, ab Aug 2026')
push('Betriebliche', 'Reisekosten', 'Headcount * 75, ab Aug 2026')
push('Betriebliche', 'Bewirtungskosten', 'Bestandskunden_gesamt * 50, ab Aug 2026')
push('Betriebliche', 'Internet/Mobilfunk', 'Headcount * 50, ab Aug 2026')
push('Betriebliche', 'Berufsgenossenschaft', '0,5% von Brutto-Personalkosten')
push('Betriebliche', 'Allgemeine Marketingkosten', '8% von Gesamtumsatz bis Dez 2028, 10% ab Jan 2029')
push('Betriebliche', 'Gewerbesteuer (F)', '12,25% vom monatlichen Profit (falls positiv); Profit = Umsatz - Material - Personal - AfA - Rest-Opex')
push('Betriebliche', 'Kategorie-Summen', 'SUM aller Detailzeilen der Kategorie')
push('Betriebliche', 'Summe sonstige', 'SUM aller Betrieb-Zeilen ohne Personal/Abschr./Sum-Zeilen')
push('Betriebliche', 'Gesamtkosten', 'Personalkosten + Abschreibungen + Summe sonstige')
push('Liquiditaet', 'Umsatzerloese', '=Umsatzerlöse!GESAMTUMSATZ')
push('Liquiditaet', 'Materialaufwand', '=Materialaufwand!SUMME')
push('Liquiditaet', 'Personalkosten', '=Personalkosten!TOTAL')
push('Liquiditaet', 'Sonstige Kosten', '=Betriebliche Aufwendungen!Summe sonstige')
push('Liquiditaet', 'Investitionen', '=Investitionen!TOTAL Investitionsausgaben')
push('Liquiditaet', 'Summe ERTRAEGE', 'SUM aller Einzahlungen')
push('Liquiditaet', 'Summe AUSZAHLUNGEN', 'SUM aller Auszahlungen')
push('Liquiditaet', 'UEBERSCHUSS VOR INVEST.', 'Summe ERTRAEGE - Summe AUSZAHLUNGEN')
push('Liquiditaet', 'UEBERSCHUSS VOR ENTN.', 'UEBERSCHUSS VOR INVEST - Investitionen')
push('Liquiditaet', 'UEBERSCHUSS', 'UEBERSCHUSS VOR ENTN - Kapitalentnahmen')
push('Liquiditaet', 'Kontostand (Monatsbeginn)', '0 in m1, sonst LIQUIDITAET des Vormonats')
push('Liquiditaet', 'LIQUIDITAET', 'Kontostand + UEBERSCHUSS')
push('Liquiditaet', 'Gewerbe-/Koerperschaftsteuer', 'Aus DB uebernommen (Verlustvortrag-Logik nicht in Excel inline)')
push('GuV', 'Umsatzerloese / Gesamtleistung', 'SUM Umsatzerloese!GESAMTUMSATZ ueber das Jahr')
push('GuV', 'Summe Materialaufwand', 'SUM Materialaufwand!SUMME ueber das Jahr')
push('GuV', 'Rohergebnis', 'Umsatzerloese - Summe Materialaufwand')
push('GuV', 'Loehne und Gehaelter', 'SUM Personalkosten!TOTAL Brutto')
push('GuV', 'Soziale Abgaben', 'SUM Personalkosten!TOTAL Sozial')
push('GuV', 'Summe Personalaufwand', 'Loehne + Soziale Abgaben')
push('GuV', 'Abschreibungen', 'SUM Investitionen!TOTAL AfA')
push('GuV', 'Sonst. betr. Aufwendungen', 'SUM Betriebliche Aufwendungen!Summe sonstige')
push('GuV', 'EBIT', 'Umsatz - Material - Personal - AfA - Sonst. betr. Aufwand')
push('GuV', 'Koerperschaft-/Gewerbesteuer', 'Aus DB uebernommen (Verlustvortrag-Logik)')
push('GuV', 'Ergebnis nach Steuern / Jahresueberschuss', 'EBIT + Zinsertraege - Zinsaufwendungen - Steuern gesamt')
}
// =====================================================================
// MAIN
// =====================================================================
async function main() {
const pool = new Pool({ connectionString: CONN, ssl: false })
const outDir = path.join(__dirname, '..', 'exports')
require('fs').mkdirSync(outDir, { recursive: true })
for (const sc of SCENARIOS) {
console.log(`\n=== Exporting ${sc.slug} (${sc.id}) ===`)
const data = await loadScenario(pool, sc.id)
if (!data.scenario) {
console.log(' Scenario not found, skip')
continue
}
const refs: SheetRefs = {
kunden: new Map(),
umsatzByLabel: new Map(),
materialByLabel: new Map(),
personalInputRow: new Map(),
personalBruttoRow: new Map(),
personalSozialRow: new Map(),
personalTotalRow: new Map(),
personalSummary: { brutto: 0, sozial: 0, total: 0, headcount: 0, founderHc: 0 },
investInputRow: new Map(),
investInvestRow: new Map(),
investAfaRow: new Map(),
investTotals: { invest: 0, afa: 0 },
betriebByLabel: new Map(),
sonstByIdx: new Map(),
sonstSumGesamt: 0,
liquidByLabel: new Map(),
guvByLabel: new Map(),
}
const wb = new ExcelJS.Workbook()
wb.creator = 'BreakPilot Finanzplan Export'
wb.created = new Date()
// Order matters: dependent sheets reference earlier ones.
// SonstErtraege is skipped — DB has 0 values for all rows across all scenarios.
buildKunden(wb, data, refs)
buildUmsatz(wb, data, refs)
buildPersonal(wb, data, refs)
buildInvest(wb, data, refs)
buildMaterial(wb, data, refs)
buildBetrieb(wb, data, refs)
buildLiquid(wb, data, refs)
buildGuV(wb, data, refs)
// Dashboard must be built after GuV (uses guvByLabel) and Liquid/Personal/Material refs.
buildDashboard(wb, data, refs, `${(data.scenario as any).name} — Finanzplan`)
buildFormulas(wb, data)
// Sheet order is handled by the Python post-processor.
// Apply zero-suppressing number format to every data sheet (skip the docs tab, which is text)
wb.eachSheet(s => {
if (s.name === SHEET.Formulas) return
applyNumFmtToSheet(s)
})
const file = path.join(outDir, `Finanzplan-${sc.slug}.xlsx`)
await wb.xlsx.writeFile(file)
console.log(` Wrote ${file}`)
}
await pool.end()
}
main().catch(e => {
console.error(e)
process.exit(1)
})