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
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>
1255 lines
55 KiB
TypeScript
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)
|
|
})
|