Files
breakpilot-core/pitch-deck/scripts/add-inflation-formulas.py
T
Benjamin Admin 01e2e0fc4b feat(pitch-deck): Finanzplan-Tooling + formel-getriebene Versionen Base/Bull/Bear
8 neue Skripte erweitern die Excel-Finanzpläne deutlich:
- add-kunden-formulas: Neukunden-Lookup + kumulativer Churn (SUMPRODUCT-basiert)
- add-price-formulas: jährliche Preiserhöhung Jan via Treiber
- add-inflation-formulas: Inflation auf Betriebskosten + Büromiete-Logik
- add-tantieme-and-explanations: Gründer-Tantieme 2028-2030 + Erläuterungen
  in Cohort-Analyse + Sensitivity-Sheets
- apply-bueromiete: 1000€/Monat ab Sep 2026 mit Inflation
- apply-number-formatting: Euro / Count / Percent per Label-Klassifikation
- cleanup-finanzplan-labels: 'kategorie — '-Präfix entfernt
- copy-extra-sheets: Charts/Cohort/Sensitivity/Hiring-Plan von Series-A
  auf 400k Base/Bull/Bear übertragen (inkl. 12 Chart-Objekten)

Neue Excel-Dateien (für L-Bank Wandeldarlehen 400k Pitch):
- Finanzplan-Wandeldarlehen-400k.xlsx (Base)
- Finanzplan-Wandeldarlehen-400k-Bull.xlsx
- Finanzplan-Wandeldarlehen-400k-Bear.xlsx
- Finanzplan-Series-A-Ambitioniert.xlsx (Series-A Variante)

Inhaltliche Anpassungen (400k Base/Bull/Bear):
- Channel-Provision Bechtle/Cancom → Channel-Partner Provision, Format Euro
- GuV: 'Steuerbares Einkommen' → 'Zu versteuerndes Einkommen (nach Verlustvortrag)',
  Formel um Zinserträge/-aufwand erweitert
- IT-Recht/Datenschutzjurist auf 100% (6666 € statt 3333 €)
- Series-A-Investor in WD-Sheet auf 0 € (nicht eingeplant in 400k Variante)
- Mitarbeiter +1 Monat verschoben (außer Gründer = Okt 2026)
- 3 Enterprise-Neukunden zusätzlich (Apr 2027, Jun 2027, Okt 2029)
- Marketing-Agentur Cut ~33% pro Szenario (Base 4%, Bull 5%, Bear 2%)

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

331 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Add inflation formulas to 'Betriebliche Aufwendungen' rows + update Büromiete.
What this does:
1. Update row 27 (Büromiete): 0 in Aug 2026, then 1000 €/Monat from Sep 2026 to
Dec 2026 (hardcoded), then inflation-adjusted formulas from Jan 2027 onwards.
2. Add Treiber driver block 'Inflation Betriebskosten' (one rate per year).
3. For each eligible row, keep 2026 values hardcoded (the 'Startwert') and
replace cells from Jan 2027 onwards with a formula that:
- carries the previous month's value forward in non-January months
- multiplies by (1 + inflation_for_this_year) every January
Eligibility (which rows get inflation formulas):
- Row contains numeric values (not formulas) — formula rows like KFZ that
scale via Personalkosten are left alone.
- Row has at least 2 non-zero months in 2026 (constant or 'later activation'
patterns: D&O insurance, Recruiting, Buchführung, etc.).
- Yearly-fee rows (exactly 1 non-zero month in 2026 — e.g., IHK in Sep)
get a year-over-year carry: same month next year = last year × (1+inflation),
other months stay 0.
- Rows where all 2026 values are 0 are skipped.
- Subtotal/summe rows are formulas already → automatically skipped.
Usage:
python3 pitch-deck/scripts/add-inflation-formulas.py --dry-run
python3 pitch-deck/scripts/add-inflation-formulas.py
"""
from __future__ import annotations
import argparse
import shutil
import sys
from datetime import datetime
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
BUEROMIETE_ROW = 27
BUEROMIETE_START_MONTH = 9 # September
BUEROMIETE_VALUE = 1000
DEFAULT_INFLATION = 0.03
_BA_SHEET = "Betriebliche Aufwendungen"
def year_columns(ws) -> dict[int, list[int]]:
out: dict[int, list[int]] = {}
for c in range(2, ws.max_column + 1):
v = ws.cell(row=1, column=c).value
if v is None:
continue
try:
yr = int(v)
except (TypeError, ValueError):
continue
out.setdefault(yr, []).append(c)
return out
def cols_by_year_month(ws) -> dict[tuple[int, int], int]:
"""Return {(year, month): column_index} based on rows 1 and 2."""
out: dict[tuple[int, int], int] = {}
for c in range(2, ws.max_column + 1):
y = ws.cell(row=1, column=c).value
m = ws.cell(row=2, column=c).value
try:
out[(int(y), int(m))] = c
except (TypeError, ValueError):
continue
return out
# ---------------------------------------------------------------------------
# Treiber: add or read inflation driver block
# ---------------------------------------------------------------------------
INFLATION_HEADER = "Inflation Betriebskosten"
def find_or_create_inflation_block(ws_t, years_after_start: list[int]) -> dict[int, int]:
"""Find existing inflation block by header, or append a new one. Return {year: row}."""
# Look for existing header
header_row = None
for r in range(1, ws_t.max_row + 1):
if ws_t.cell(row=r, column=1).value == INFLATION_HEADER:
header_row = r
break
if header_row is None:
# Append new block
header_row = ws_t.max_row + 2 # blank then header
ws_t.cell(row=header_row, column=1).value = INFLATION_HEADER
refs: dict[int, int] = {}
r = header_row + 1
for yr in years_after_start:
label_cell = ws_t.cell(row=r, column=1)
if label_cell.value is None or not str(label_cell.value).startswith("Inflation"):
# Fresh entry
ws_t.cell(row=r, column=1).value = f"Inflation {yr}"
ws_t.cell(row=r, column=2).value = DEFAULT_INFLATION
# If already there, preserve existing value
refs[yr] = r
r += 1
return refs
# ---------------------------------------------------------------------------
# Büromiete special handling (row 27)
# ---------------------------------------------------------------------------
def update_bueromiete(ws_ba, col_index: dict, base_year: int, inflation_refs: dict[int, int]) -> int:
"""Set row 27: 0 in Aug 2026, 1000 from Sep 2026 to Dec 2026 (hardcoded),
then inflation formula from Jan 2027 onwards.
"""
inc_first = min(inflation_refs.values())
inc_last = max(inflation_refs.values())
# Keep label free of the word 'Inflation' — number-formatting heuristics
# treat that as a percent indicator and would mis-format Euro cells.
ws_ba.cell(row=BUEROMIETE_ROW, column=1).value = "Büromiete (ab Sep 2026)"
written = 0
for c in range(2, ws_ba.max_column + 1):
y = ws_ba.cell(row=1, column=c).value
m = ws_ba.cell(row=2, column=c).value
if y == base_year:
# Hardcoded 2026 values
val = BUEROMIETE_VALUE if m >= BUEROMIETE_START_MONTH else 0
ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = val
written += 1
else:
# Inflation formula from Jan 2027 onwards
col_letter = get_column_letter(c)
prev_col = get_column_letter(c - 1)
ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = (
f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year}),"
f"{prev_col}{BUEROMIETE_ROW}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year})),"
f"{prev_col}{BUEROMIETE_ROW})"
)
written += 1
return written
# ---------------------------------------------------------------------------
# General inflation application to qualifying rows
# ---------------------------------------------------------------------------
def is_formula(value) -> bool:
return isinstance(value, str) and value.startswith("=")
def collect_eligible_rows(ws_ba, base_year_cols: list[int], skip_rows: set[int]) -> dict[int, str]:
"""For each row, decide if it's eligible and classify it.
Returns {row: kind} where kind is one of:
'monthly' - ≥2 non-zero months in 2026, apply Jan-inflation carry-forward
'yearly' - exactly 1 non-zero month in 2026, apply same-month-next-year
(rows not eligible are absent from the dict)
"""
out: dict[int, str] = {}
for r in range(4, ws_ba.max_row + 1):
if r in skip_rows:
continue
label = ws_ba.cell(row=r, column=1).value
if not label:
continue
if str(label).startswith(("summe —", "Summe ")) or "SUMME" in str(label):
continue
values_2026 = [ws_ba.cell(row=r, column=c).value for c in base_year_cols]
if any(is_formula(v) for v in values_2026):
continue
nonzero_count = sum(1 for v in values_2026 if v not in (None, 0, ""))
if nonzero_count == 0:
continue
if nonzero_count == 1:
out[r] = "yearly"
else:
out[r] = "monthly"
return out
def write_monthly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int,
inflation_refs: dict[int, int]) -> int:
"""From first_formula_col onwards, carry previous + Jan inflation."""
inc_first = min(inflation_refs.values())
inc_last = max(inflation_refs.values())
written = 0
for c in range(first_formula_col, ws_ba.max_column + 1):
col_letter = get_column_letter(c)
prev_col = get_column_letter(c - 1)
ws_ba.cell(row=row, column=c).value = (
f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year}),"
f"{prev_col}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year})),"
f"{prev_col}{row})"
)
written += 1
return written
def write_yearly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int,
inflation_refs: dict[int, int], col_index: dict) -> int:
"""For yearly-fee rows: same month next year × (1 + inflation), else 0."""
inc_first = min(inflation_refs.values())
inc_last = max(inflation_refs.values())
written = 0
for c in range(first_formula_col, ws_ba.max_column + 1):
y = ws_ba.cell(row=1, column=c).value
m = ws_ba.cell(row=2, column=c).value
if y is None or m is None:
continue
same_month_prev_year = col_index.get((int(y) - 1, int(m)))
if same_month_prev_year is None:
continue
prev_letter = get_column_letter(same_month_prev_year)
col_letter = get_column_letter(c)
ws_ba.cell(row=row, column=c).value = (
f"={prev_letter}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year}))"
)
written += 1
return written
# ---------------------------------------------------------------------------
# Main per-file processing
# ---------------------------------------------------------------------------
def process_file(path: Path, dry_run: bool) -> dict | None:
wb = load_workbook(path)
if _BA_SHEET not in wb.sheetnames or "Treiber" not in wb.sheetnames:
return None
ws_ba = wb[_BA_SHEET]
ws_t = wb["Treiber"]
yc = year_columns(ws_ba)
years = sorted(yc.keys())
base_year = min(years)
years_after_start = [y for y in years if y > base_year]
inflation_refs = find_or_create_inflation_block(ws_t, years_after_start)
col_index = cols_by_year_month(ws_ba)
base_year_cols = yc[base_year]
first_year_after = min(years_after_start)
first_formula_col = col_index.get((first_year_after, 1))
if first_formula_col is None:
return None
# Büromiete (always)
bueromiete_cells = update_bueromiete(ws_ba, col_index, base_year, inflation_refs)
# Determine eligible rows (skip Büromiete since we just handled it)
skip = {BUEROMIETE_ROW}
eligible = collect_eligible_rows(ws_ba, base_year_cols, skip)
cells_monthly = 0
cells_yearly = 0
n_monthly = n_yearly = 0
for row, kind in eligible.items():
if kind == "monthly":
cells_monthly += write_monthly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs)
n_monthly += 1
else:
cells_yearly += write_yearly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs, col_index)
n_yearly += 1
if not dry_run:
wb.save(path)
return {
"years": years,
"monthly_rows": n_monthly,
"yearly_rows": n_yearly,
"cells_bueromiete": bueromiete_cells,
"cells_monthly": cells_monthly,
"cells_yearly": cells_yearly,
"eligible_rows": {kind: [r for r, k in eligible.items() if k == kind] for kind in ("monthly", "yearly")},
}
def backup(path: Path) -> Path:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
bk = path.with_name(f"{path.stem}.BACKUP-pre-inflation-{ts}{path.suffix}")
shutil.copy2(path, bk)
return bk
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--only", help="Process only this filename")
ap.add_argument("--no-backup", action="store_true")
args = ap.parse_args()
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
files = [f for f in files if "BACKUP" not in f.name]
if args.only:
files = [f for f in files if f.name == args.only]
for path in files:
wb_peek = load_workbook(path, read_only=True)
if _BA_SHEET not in wb_peek.sheetnames or "Treiber" not in wb_peek.sheetnames:
print(f"\n skip {path.name}: missing sheets")
continue
if not args.dry_run and not args.no_backup:
bk = backup(path)
print(f" ✓ backup: {bk.name}")
info = process_file(path, dry_run=args.dry_run)
if info is None:
continue
print(f"\n === {path.name} ===")
print(f" Büromiete (row {BUEROMIETE_ROW}): {info['cells_bueromiete']} cells (Sep 2026 start)")
print(f" Monthly-inflation rows: {info['monthly_rows']} ({info['cells_monthly']} cells)")
print(f" Yearly-fee rows: {info['yearly_rows']} ({info['cells_yearly']} cells)")
print(f" Eligible monthly rows: {info['eligible_rows']['monthly']}")
print(f" Eligible yearly rows: {info['eligible_rows']['yearly']}")
return 0
if __name__ == "__main__":
sys.exit(main())