#!/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())