/** * 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 { 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 & { 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 { 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 const out = new Array(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 // row_label_with_segment -> excel row umsatzByLabel: Map // section+row_label -> excel row materialByLabel: Map personalInputRow: Map // person id -> excel row (input block) personalBruttoRow: Map // person id -> excel row (Brutto monthly) personalSozialRow: Map personalTotalRow: Map personalSummary: { brutto: number; sozial: number; total: number; headcount: number; founderHc: number } investInputRow: Map investInvestRow: Map investAfaRow: Map investTotals: { invest: number; afa: number } betriebByLabel: Map sonstByIdx: Map sonstSumGesamt: number liquidByLabel: Map guvByLabel: Map } 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() 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() 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() // 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 = { '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 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) })