diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx index 068ffd9..4ccb38e 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx index ae1b335..90e5095 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx index 9accdae..b145b2a 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx b/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx new file mode 100644 index 0000000..ba35741 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx index 8663500..0360c13 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx new file mode 100644 index 0000000..3d97e54 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx new file mode 100644 index 0000000..d1d4e6c Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx new file mode 100644 index 0000000..a0c8dad Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx index fbfa5e2..d67caf1 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx index 1c58837..20d70ea 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx differ diff --git a/pitch-deck/scripts/add-inflation-formulas.py b/pitch-deck/scripts/add-inflation-formulas.py new file mode 100644 index 0000000..1d3a0c1 --- /dev/null +++ b/pitch-deck/scripts/add-inflation-formulas.py @@ -0,0 +1,330 @@ +#!/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()) diff --git a/pitch-deck/scripts/add-kunden-formulas.py b/pitch-deck/scripts/add-kunden-formulas.py new file mode 100644 index 0000000..ae724a3 --- /dev/null +++ b/pitch-deck/scripts/add-kunden-formulas.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Replace hard-coded values in 'Kunden' sheet with proper formulas: + +- Neukunden = driver lookup (per year × segment) from Treiber sheet +- Churn = ROUND(previous-month Bestandskunden × Churn-Rate, 0) +- Bestandskunden = previous + new - churn (cumulative) + +Default driver values are derived from each file's existing monthly Neukunden +data so each scenario keeps its growth profile. Churn rates default to +industry-typical B2B SaaS values (Starter 1%, Pro 0.5%, Enterprise 0.3%/Monat). + +Usage: + python3 pitch-deck/scripts/add-kunden-formulas.py --dry-run + python3 pitch-deck/scripts/add-kunden-formulas.py + python3 pitch-deck/scripts/add-kunden-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx +""" + +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" + +# Default monthly churn rates per segment per year. Reflects business reality: +# - Starter: high early churn (testing, startups failing) decreasing as product matures +# - Professional: moderate, gradually decreasing +# - Enterprise: very low — they integrate the product, don't switch +# Annual equivalents: +# Starter 64% → 17%, Professional 26% → 9%, Enterprise 6% → 1% +CHURN_DEFAULTS: dict[str, dict[int, float]] = { + "starter": {2026: 0.08, 2027: 0.05, 2028: 0.03, 2029: 0.02, 2030: 0.015}, + "professional": {2026: 0.025, 2027: 0.02, 2028: 0.015, 2029: 0.01, 2030: 0.008}, + "enterprise": {2026: 0.005, 2027: 0.003, 2028: 0.002, 2029: 0.001, 2030: 0.001}, +} + +# Segment definitions: (segment_name, neukunden_row, churn_row, bestand_row, suffix) +SEGMENTS = [ + ("Starter (<10 MA)", 4, 5, 6, "starter"), + ("Professional (10-250 MA)", 7, 8, 9, "professional"), + ("Enterprise (250+ MA)", 10, 11, 12, "enterprise"), +] + + +def year_columns(ws_kunden) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for c in range(2, ws_kunden.max_column + 1): + v = ws_kunden.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 monthly_avg_per_year(ws_kunden, row: int, year_cols: dict[int, list[int]]) -> dict[int, int]: + """Compute rounded monthly average from existing per-month values. + + Uses half-up rounding (0.5 → 1) instead of Python's banker's rounding (0.5 → 0) + to preserve segments that grew at exactly half-a-customer-per-month. + """ + res: dict[int, int] = {} + for yr, cols in year_cols.items(): + total = 0.0 + for c in cols: + v = ws_kunden.cell(row=row, column=c).value + if v in (None, ""): + continue + try: + total += float(v) + except (TypeError, ValueError): + pass + avg = total / len(cols) if cols else 0 + res[yr] = max(0, int(avg + 0.5)) + return res + + +_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"} + + +def _clear_old_kundenakquise_block(ws_treiber) -> None: + """If we ran a previous version, drop any rows after the original 30 to start fresh.""" + if ws_treiber.max_row > 30: + for r in range(31, ws_treiber.max_row + 1): + ws_treiber.cell(row=r, column=1).value = None + ws_treiber.cell(row=r, column=2).value = None + + +def write_treiber_drivers(ws_treiber, years: list[int], defaults_by_segment: dict[str, dict[int, int]]) -> dict: + """Rewrite the Kundenakquise + Churn driver block. Returns row indices for cross-references. + + Layout (after original row 30): + 31 (blank) + 32 Header "Kundenakquise" + 33..47 Neukunden/Monat per (segment, year) + 48 (blank) + 49 Header "Churn (monatliche Rate, pro Jahr)" + 50..64 Churn-Rate/Monat per (segment, year) + """ + _clear_old_kundenakquise_block(ws_treiber) + + r = 32 + ws_treiber.cell(row=r, column=1).value = "Kundenakquise" + r += 1 + new_customer_refs: dict[str, dict[int, int]] = {} + for suffix in ("starter", "professional", "enterprise"): + new_customer_refs[suffix] = {} + for yr in years: + ws_treiber.cell(row=r, column=1).value = f"Neukunden/Monat {_SEG_LABEL[suffix]} {yr}" + ws_treiber.cell(row=r, column=2).value = defaults_by_segment[suffix][yr] + new_customer_refs[suffix][yr] = r + r += 1 + r += 1 # blank row 48 + ws_treiber.cell(row=r, column=1).value = "Churn (monatliche Rate, pro Jahr)" + r += 1 + churn_refs: dict[str, dict[int, int]] = {} + for suffix in ("starter", "professional", "enterprise"): + churn_refs[suffix] = {} + for yr in years: + ws_treiber.cell(row=r, column=1).value = f"Churn-Rate/Monat {_SEG_LABEL[suffix]} {yr}" + ws_treiber.cell(row=r, column=2).value = CHURN_DEFAULTS[suffix][yr] + churn_refs[suffix][yr] = r + r += 1 + return {"new_customer_rows": new_customer_refs, "churn_rows": churn_refs} + + +# Helper rows in Kunden sheet (added BELOW the totals to avoid breaking external refs). +# Each helper row holds the year-varying monthly churn rate per column for one segment. +HELPER_ROWS = { + "starter": 18, + "professional": 19, + "enterprise": 20, +} + + +def write_kunden_formulas(ws_kunden, years: list[int], refs: dict) -> dict: + min_year = min(years) + new_refs = refs["new_customer_rows"] + churn_refs = refs["churn_rows"] + stats = {"neu": 0, "churn": 0, "bestand": 0, "helper": 0} + + # 1. Write per-segment helper rows (18-20) with year-lookup rates per column. + ws_kunden.cell(row=17, column=1).value = None # separator + for suffix, helper_row in HELPER_ROWS.items(): + first = min(churn_refs[suffix].values()) + last = max(churn_refs[suffix].values()) + ws_kunden.cell(row=helper_row, column=1).value = ( + f"Monatl. Churn-Rate {_SEG_LABEL[suffix]} (Helper)" + ) + for c in range(2, ws_kunden.max_column + 1): + col_letter = get_column_letter(c) + ws_kunden.cell(row=helper_row, column=c).value = ( + f"=INDEX(Treiber!$B${first}:$B${last},{col_letter}$1-{min_year - 1})" + ) + stats["helper"] += 1 + + # 2. Write per-segment Neukunden/Churn/Bestandskunden formulas. + for seg_label, neu_row, churn_row, bestand_row, suffix in SEGMENTS: + ws_kunden.cell(row=neu_row, column=1).value = f"Neukunden {seg_label}" + ws_kunden.cell(row=churn_row, column=1).value = f"Churn {seg_label}" + ws_kunden.cell(row=bestand_row, column=1).value = f"Bestandskunden {seg_label}" + + seg_neu_rows = new_refs[suffix] + neu_first = min(seg_neu_rows.values()) + neu_last = max(seg_neu_rows.values()) + helper_row = HELPER_ROWS[suffix] + + for c in range(2, ws_kunden.max_column + 1): + col_letter = get_column_letter(c) + prev_col = get_column_letter(c - 1) if c > 2 else None + + # --- Neukunden: year-lookup driver --- + ws_kunden.cell(row=neu_row, column=c).value = ( + f"=INDEX(Treiber!$B${neu_first}:$B${neu_last},{col_letter}$1-{min_year - 1})" + ) + stats["neu"] += 1 + + # --- Churn: cumulative-rounding to make small-base churn visible --- + # Approach: expected_cum_churn(t) = SUMPRODUCT(Bestand[B..t-1], Rate[C..t]) + # Each month's churn = ROUND(expected_cum) - already_booked_churn. + # This guarantees integer monthly values that aggregate to ROUND(expected_total). + if c == 2: + # Aug 2026: no prior month, no churn. + ws_kunden.cell(row=churn_row, column=c).value = 0 + elif c == 3: + # Sep 2026: one prior bestand cell (B), no churn-booked yet. + ws_kunden.cell(row=churn_row, column=c).value = ( + f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:B{bestand_row}," + f"$C{helper_row}:C{helper_row}),0))" + ) + else: + ws_kunden.cell(row=churn_row, column=c).value = ( + f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:{prev_col}{bestand_row}," + f"$C{helper_row}:{col_letter}{helper_row}),0)" + f"-SUM($C{churn_row}:{prev_col}{churn_row}))" + ) + stats["churn"] += 1 + + # --- Bestandskunden: cumulative balance --- + if c == 2: + ws_kunden.cell(row=bestand_row, column=c).value = ( + f"={col_letter}{neu_row}-{col_letter}{churn_row}" + ) + else: + ws_kunden.cell(row=bestand_row, column=c).value = ( + f"={prev_col}{bestand_row}+{col_letter}{neu_row}-{col_letter}{churn_row}" + ) + stats["bestand"] += 1 + + return stats + + +def _was_already_processed(ws_treiber) -> bool: + """Detect if a previous run already wrote the Kundenakquise driver block.""" + return ws_treiber.cell(row=32, column=1).value == "Kundenakquise" + + +def _read_existing_neukunden_drivers(ws_treiber, years: list[int]) -> dict: + """Read driver values previously written to Treiber rows 33..47. + + Re-running on a processed file would otherwise compute defaults from + formula cells (which openpyxl returns as strings) and reset everything to 0. + """ + defaults: dict[str, dict[int, int]] = {} + r = 33 + for suffix in ("starter", "professional", "enterprise"): + defaults[suffix] = {} + for yr in years: + v = ws_treiber.cell(row=r, column=2).value + try: + defaults[suffix][yr] = int(round(float(v))) if v is not None else 0 + except (TypeError, ValueError): + defaults[suffix][yr] = 0 + r += 1 + return defaults + + +def process_file(path: Path, dry_run: bool) -> dict | None: + wb = load_workbook(path) + if "Kunden" not in wb.sheetnames or "Treiber" not in wb.sheetnames: + return None # caller will report skip + + ws_k = wb["Kunden"] + ws_t = wb["Treiber"] + + yc = year_columns(ws_k) + years = sorted(yc.keys()) + + if _was_already_processed(ws_t): + # Preserve user-edited driver values from a previous run + defaults = _read_existing_neukunden_drivers(ws_t, years) + source = "treiber (preserved)" + else: + # First time: compute defaults from existing Kunden values + defaults = { + "starter": monthly_avg_per_year(ws_k, 4, yc), + "professional": monthly_avg_per_year(ws_k, 7, yc), + "enterprise": monthly_avg_per_year(ws_k, 10, yc), + } + source = "kunden data" + + refs = write_treiber_drivers(ws_t, years, defaults) + stats = write_kunden_formulas(ws_k, years, refs) + + if not dry_run: + wb.save(path) + + return {"years": years, "defaults": defaults, "stats": stats, "refs": refs, "defaults_source": source} + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-kunden-formulas-{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: + # Peek first to decide whether to backup + wb_peek = load_workbook(path, read_only=True) + if "Treiber" not in wb_peek.sheetnames or "Kunden" not in wb_peek.sheetnames: + print(f"\n ⨯ skip {path.name}: no Treiber sheet") + 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: + print(f" ⨯ skip {path.name}: structural mismatch") + continue + print(f"\n === {path.name} ===") + print(f" Years: {info['years']}") + print(f" Defaults source: {info['defaults_source']}") + print(" Neukunden/Monat:") + for seg in ("starter", "professional", "enterprise"): + print(f" {seg:13s}: {info['defaults'][seg]}") + print(f" Cells written: {info['stats']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/add-price-formulas.py b/pitch-deck/scripts/add-price-formulas.py new file mode 100644 index 0000000..fc63e8e --- /dev/null +++ b/pitch-deck/scripts/add-price-formulas.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Add price-escalation formulas to Finanzplan Excel files. + +Replaces hard-coded Preis/Monat values in 'Umsatzerlöse' rows 4, 7, 10 with: +- Starting price (Aug 2026) read from a Treiber driver +- Annual price increase applied in January of each subsequent year +- Increase percentage configurable per year via Treiber driver + +Treiber layout (appended after existing rows): + blank + 'Preise' + 'Startpreis/Monat Starter' → 300 + 'Startpreis/Monat Professional' → 2083 + 'Startpreis/Monat Enterprise' → 4167 + blank + 'Preiserhöhung pro Jahr (%)' + 'Preiserhöhung 2027' → 0.03 + 'Preiserhöhung 2028' → 0.03 + 'Preiserhöhung 2029' → 0.03 + 'Preiserhöhung 2030' → 0.03 + +Usage: + python3 pitch-deck/scripts/add-price-formulas.py --dry-run + python3 pitch-deck/scripts/add-price-formulas.py + python3 pitch-deck/scripts/add-price-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx +""" + +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" + +# Segment → row in Umsatzerlöse where Preis/Monat lives +PRICE_ROWS = { + "starter": 4, + "professional": 7, + "enterprise": 10, +} + +# Default starting prices (Aug 2026) — used only if existing value isn't a number +DEFAULT_START_PRICES = { + "starter": 300, + "professional": 2083, + "enterprise": 4167, +} + +DEFAULT_PRICE_INCREASE = 0.03 # 3% per year by default + +_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"} + + +def year_columns(ws_kunden) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for c in range(2, ws_kunden.max_column + 1): + v = ws_kunden.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 existing_start_prices(ws_umsatz) -> dict[str, float]: + """Read current Aug 2026 (column B) prices to use as Startpreis defaults.""" + res: dict[str, float] = {} + for suffix, row in PRICE_ROWS.items(): + v = ws_umsatz.cell(row=row, column=2).value + try: + res[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix]) + except (TypeError, ValueError): + # Already a formula? Use default. + res[suffix] = float(DEFAULT_START_PRICES[suffix]) + return res + + +def already_processed(ws_treiber) -> tuple[bool, int]: + """Return (already_processed, start_row). start_row is where to place the Preise block.""" + # Scan for an existing 'Preise' header + for r in range(1, ws_treiber.max_row + 1): + if ws_treiber.cell(row=r, column=1).value == "Preise": + return True, r + # First time: place after max_row with a blank separator + return False, ws_treiber.max_row + 2 + + +def read_existing_price_drivers(ws_treiber, header_row: int, years_after_start: list[int]) -> tuple[dict, dict, dict]: + """Read existing price drivers we wrote previously. Returns (start_prices, increases, refs).""" + start_prices: dict[str, float] = {} + increases: dict[int, float] = {} + refs: dict[str, int | dict[int, int]] = {} + + r = header_row + 1 + start_refs: dict[str, int] = {} + for suffix in ("starter", "professional", "enterprise"): + v = ws_treiber.cell(row=r, column=2).value + try: + start_prices[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix]) + except (TypeError, ValueError): + start_prices[suffix] = float(DEFAULT_START_PRICES[suffix]) + start_refs[suffix] = r + r += 1 + refs["start_refs"] = start_refs + + r += 1 # blank + r += 1 # 'Preiserhöhung' header + increase_refs: dict[int, int] = {} + for yr in years_after_start: + v = ws_treiber.cell(row=r, column=2).value + try: + increases[yr] = float(v) if v is not None else DEFAULT_PRICE_INCREASE + except (TypeError, ValueError): + increases[yr] = DEFAULT_PRICE_INCREASE + increase_refs[yr] = r + r += 1 + refs["increase_refs"] = increase_refs + + return start_prices, increases, refs + + +def write_treiber_price_drivers(ws_treiber, start_row: int, years_after_start: list[int], + start_prices: dict[str, float], increases: dict[int, float]) -> dict: + r = start_row + ws_treiber.cell(row=r, column=1).value = "Preise" + r += 1 + start_refs: dict[str, int] = {} + for suffix in ("starter", "professional", "enterprise"): + ws_treiber.cell(row=r, column=1).value = f"Startpreis/Monat {_SEG_LABEL[suffix]}" + ws_treiber.cell(row=r, column=2).value = start_prices[suffix] + start_refs[suffix] = r + r += 1 + r += 1 # blank + ws_treiber.cell(row=r, column=1).value = "Preiserhöhung pro Jahr (%)" + r += 1 + increase_refs: dict[int, int] = {} + for yr in years_after_start: + ws_treiber.cell(row=r, column=1).value = f"Preiserhöhung {yr}" + ws_treiber.cell(row=r, column=2).value = increases.get(yr, DEFAULT_PRICE_INCREASE) + increase_refs[yr] = r + r += 1 + return {"start_refs": start_refs, "increase_refs": increase_refs} + + +def write_umsatz_price_formulas(ws_umsatz, refs: dict, base_year: int) -> int: + """Write price formulas to Umsatzerlöse rows 4/7/10 across all month columns. + + Formula structure: + col B (Aug 2026, first month): =Treiber!$B$startpreis + col C+ same year: =prev_col + col where month=1 and year>base_year: + =prev_col*(1+INDEX(Treiber!$B$inc_first:$B$inc_last,year-base_year)) + """ + start_refs = refs["start_refs"] + increase_refs = refs["increase_refs"] + inc_first = min(increase_refs.values()) + inc_last = max(increase_refs.values()) + + cells_written = 0 + for suffix, row in PRICE_ROWS.items(): + # Clean label + ws_umsatz.cell(row=row, column=1).value = f"Preis/Monat ({_SEG_LABEL[suffix]})" + for c in range(2, ws_umsatz.max_column + 1): + col_letter = get_column_letter(c) + if c == 2: + # Aug 2026 starting price + ws_umsatz.cell(row=row, column=c).value = ( + f"=Treiber!$B${start_refs[suffix]}" + ) + else: + prev_col = get_column_letter(c - 1) + ws_umsatz.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}," + f"{col_letter}$1-{base_year}))," + f"{prev_col}{row})" + ) + cells_written += 1 + return cells_written + + +def process_file(path: Path, dry_run: bool) -> dict | None: + wb = load_workbook(path) + if "Umsatzerlöse" not in wb.sheetnames or "Treiber" not in wb.sheetnames or "Kunden" not in wb.sheetnames: + return None + + ws_u = wb["Umsatzerlöse"] + ws_t = wb["Treiber"] + ws_k = wb["Kunden"] + + yc = year_columns(ws_k) + years = sorted(yc.keys()) + base_year = min(years) + years_after_start = [y for y in years if y > base_year] + + processed, start_row = already_processed(ws_t) + if processed: + # Preserve existing driver values + start_prices, increases, _ = read_existing_price_drivers(ws_t, start_row, years_after_start) + source = "treiber (preserved)" + else: + # First time: read current Aug 2026 prices, default increases + start_prices = existing_start_prices(ws_u) + increases = {y: DEFAULT_PRICE_INCREASE for y in years_after_start} + source = "umsatz column B" + + refs = write_treiber_price_drivers(ws_t, start_row, years_after_start, start_prices, increases) + cells = write_umsatz_price_formulas(ws_u, refs, base_year) + + if not dry_run: + wb.save(path) + + return { + "years": years, + "start_prices": start_prices, + "increases": increases, + "cells": cells, + "source": source, + "refs": refs, + } + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-price-formulas-{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 not all(s in wb_peek.sheetnames for s in ("Umsatzerlöse", "Treiber", "Kunden")): + print(f"\n ⨯ skip {path.name}: missing required 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" Source: {info['source']}") + print(f" Startpreise (Aug {min(info['years'])}):") + for seg, val in info["start_prices"].items(): + print(f" {seg:13s}: {val:>8.2f} €/Monat") + print(" Preiserhöhung pro Jahr (Jan jeweils):") + for yr, inc in info["increases"].items(): + print(f" {yr}: {inc*100:.1f}%") + print(f" Cells written: {info['cells']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/add-tantieme-and-explanations.py b/pitch-deck/scripts/add-tantieme-and-explanations.py new file mode 100644 index 0000000..a5dacbb --- /dev/null +++ b/pitch-deck/scripts/add-tantieme-and-explanations.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Add Tantieme driver + founder bonus logic + explanation text to Sensitivity/Cohort. + +Changes per file (Wandeldarlehen-400k Base/Bull/Bear + Series-A): + 1. Treiber: append 'Tantieme Gründer' block (2028, 2029, 2030, default 0%). + 2. Personalkosten: modify rows 27 (Benjamin) and 28 (Sharang) for columns from + Jan 2028 onwards — wrap base brutto in (1 + tantieme_for_year). Cols B-R + (Aug 2026 to Dec 2027) stay untouched. + 3. Cohort-Analyse: add 'Erläuterung' block at the bottom explaining what the + sheet shows and how to read it. + 4. Sensitivity: add 'Erläuterung' block explaining baseline column + reading. + +Usage: + python3 pitch-deck/scripts/add-tantieme-and-explanations.py --dry-run + python3 pitch-deck/scripts/add-tantieme-and-explanations.py +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.styles import Alignment, Font + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +TARGETS = [ + "Finanzplan-Series-A-Ambitioniert.xlsx", + "Finanzplan-Wandeldarlehen-400k.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bull.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bear.xlsx", +] + +# Founders detected by column-A label ('Benjamin', 'Sharang') — actual row varies +# by file (400k: rows 27/28, Series-A: rows 39/40). +FOUNDER_NAME_HINTS = ("Benjamin", "Sharang") +# Year when Tantieme starts (Jan) +TANTIEME_START_YEAR = 2028 +TANTIEME_END_YEAR = 2030 +TANTIEME_DEFAULT = 0.0 # 0% by default; user sets 0.20 to 1.00 + + +COHORT_EXPLANATION = [ + "Erläuterung", + "", + "Was zeigt diese Tabelle? Jede Zeile entspricht einer Akquise-Kohorte (= alle", + "Kunden, die in diesem Monat neu gewonnen wurden). Die Spalten M0, M1, M2 …", + "zeigen, wie viele dieser Kunden nach 0, 1, 2 … Monaten noch aktiv sind.", + "Die Werte sinken nach Akquise durch Churn (B5 = Retention pro Monat).", + "", + "Warum nützlich? Die Cohort-Analyse macht sichtbar, wie schnell Kunden", + "abwandern und wie lang die durchschnittliche Customer Lifetime ist. Daraus", + "ergibt sich der LTV: Lifetime (Monate) × ARPU × Bruttomarge.", + "", + "Beispiel: Bei monatlicher Churn-Rate 1,5% verbleiben nach 12 Monaten ~83%,", + "nach 24 Monaten ~70%. Lifetime ≈ 1 / 0,015 = 67 Monate (~5,6 Jahre).", + "Das Beobachtungsfenster ist auf 24 Monate begrenzt (B6 änderbar).", +] + + +SENSITIVITY_EXPLANATION = [ + "Erläuterung", + "", + "Was ist Spalte E? Der 'Baseline'-Wert für EBIT 2030 ($B$5 = GuV!F16). In", + "jeder Zeile gleich, weil keine Variable verändert wird = Erwartung wenn", + "alle Annahmen wie geplant. Wenn EBIT 2030 nicht sinnvoll erscheint, hängt", + "das von Umsatz-, Personal- und Aufwand-Annahmen ab — siehe Treiber-Sheet.", + "", + "Was zeigen die Spalten -30% bis +30%? Pro Zeile (= eine Variable) wird", + "diese eine Annahme um den genannten Prozentsatz variiert. Andere Annahmen", + "bleiben Baseline. Ergebnis: EBIT 2030 unter dieser Variation.", + "", + "Beispiel: Zeile 'Monatliche Churn-Rate', Spalte '+30%'. Bedeutung: Wenn die", + "Churn-Rate 30% schlechter ist als geplant, wie hoch wäre der EBIT? Die", + "Differenz zur Baseline (E) = Sensitivität dieses Faktors.", + "", + "Wie interpretieren? Die Variable mit der größten Spannweite zwischen -30%", + "und +30% ist am riskantesten (Tornado-Logik). 2D-Heatmap (Row 26+) zeigt", + "kombinierte Effekte von Churn × ARPU auf LTV/CAC — gesund: > 3.", + "", + "Limitierung: One-at-a-Time ignoriert Wechselwirkungen (z.B. ändert sich", + "Preis-Erhöhung → auch Churn). Für Investoren ist OAT trotzdem üblich.", +] + + +def add_tantieme_to_treiber(ws_t) -> dict[int, int]: + """Append Tantieme driver block after row 81. Returns {year: row}.""" + start = ws_t.max_row + 2 # blank then header + r = start + ws_t.cell(row=r, column=1).value = "Tantieme Gründer (% Brutto, ab Jan 2028)" + r += 1 + refs: dict[int, int] = {} + for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1): + ws_t.cell(row=r, column=1).value = f"Tantieme Gründer {yr}" + ws_t.cell(row=r, column=2).value = TANTIEME_DEFAULT + ws_t.cell(row=r, column=2).number_format = "0%" + refs[yr] = r + r += 1 + return refs + + +def find_jan_2028_col(ws_pk) -> int: + for c in range(2, ws_pk.max_column + 1): + y = ws_pk.cell(row=1, column=c).value + m = ws_pk.cell(row=2, column=c).value + if y == 2028 and m == 1: + return c + raise RuntimeError("Could not locate Jan 2028 column in Personalkosten") + + +# Match the base brutto subexpression we want to wrap: $D$X*(1+$E$X/100)^($1-$G$X) +BASE_BRUTTO_RE = re.compile( + r"(\$D\$(\d+)\*\(1\+\$E\$\2/100\)\^\(([A-Z]+)\$1-\$G\$\2\))" +) + + +def wrap_with_tantieme(formula: str, tantieme_first: int, tantieme_last: int) -> str | None: + """Wrap the base-brutto subexpression with (1 + tantieme_for_year). Returns + the new formula, or None if no match or already wrapped (idempotent). + """ + # Idempotency: skip if a Tantieme multiplier with these rows already exists + if f"INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}" in formula: + return None + m = BASE_BRUTTO_RE.search(formula) + if not m: + return None + base_expr = m.group(1) + col_letter = m.group(3) + tantieme_factor = ( + f"(1+INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}," + f"{col_letter}$1-{TANTIEME_START_YEAR - 1}))" + ) + new_expr = f"{base_expr}*{tantieme_factor}" + return formula.replace(base_expr, new_expr) + + +def find_founder_brutto_rows(ws_pk) -> list[int]: + rows: list[int] = [] + for r in range(1, ws_pk.max_row + 1): + a = ws_pk.cell(row=r, column=1).value + if not isinstance(a, str): + continue + if "— Brutto" in a and any(hint in a for hint in FOUNDER_NAME_HINTS): + rows.append(r) + return rows + + +def apply_tantieme_to_personalkosten(ws_pk, refs: dict[int, int]) -> int: + jan_2028 = find_jan_2028_col(ws_pk) + tantieme_first = min(refs.values()) + tantieme_last = max(refs.values()) + rows = find_founder_brutto_rows(ws_pk) + n = 0 + for row in rows: + for c in range(jan_2028, ws_pk.max_column + 1): + cell = ws_pk.cell(row=row, column=c) + if not isinstance(cell.value, str) or not cell.value.startswith("="): + continue + new_formula = wrap_with_tantieme(cell.value, tantieme_first, tantieme_last) + if new_formula is not None: + cell.value = new_formula + n += 1 + return n + + +def add_explanation_block(ws, lines: list[str], start_row: int) -> int: + """Write lines starting at start_row, col A. Returns last row written.""" + bold = Font(bold=True, size=12) + wrap = Alignment(wrap_text=True, vertical="top") + r = start_row + for line in lines: + cell = ws.cell(row=r, column=1, value=line) + if line == "Erläuterung": + cell.font = bold + else: + cell.alignment = wrap + r += 1 + return r - 1 + + +def process_file(path: Path, dry_run: bool) -> dict: + wb = load_workbook(path) + stats: dict = {} + + # 1 — Tantieme driver in Treiber + if "Treiber" not in wb.sheetnames or "Personalkosten" not in wb.sheetnames: + return {"error": "missing required sheets"} + # Detect existing Tantieme block to avoid duplicating + ws_t = wb["Treiber"] + existing_row = None + for r in range(1, ws_t.max_row + 1): + v = ws_t.cell(row=r, column=1).value + if isinstance(v, str) and v.startswith("Tantieme Gründer (%"): + existing_row = r + break + if existing_row: + # Already present: read existing references, don't re-append + refs = {} + r = existing_row + 1 + for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1): + refs[yr] = r + r += 1 + stats["tantieme_block"] = "preserved" + else: + refs = add_tantieme_to_treiber(ws_t) + stats["tantieme_block"] = f"added rows {min(refs.values())}-{max(refs.values())}" + + # 2 — Apply Tantieme to founder Brutto rows + ws_pk = wb["Personalkosten"] + n_formulas = apply_tantieme_to_personalkosten(ws_pk, refs) + stats["formulas_wrapped"] = n_formulas + + # 3 — Cohort-Analyse explanation + if "Cohort-Analyse" in wb.sheetnames: + ws_co = wb["Cohort-Analyse"] + # Append at row 49 (after content at row 47). Idempotent: don't re-add. + existing = any( + ws_co.cell(row=r, column=1).value == "Erläuterung" + for r in range(48, min(ws_co.max_row + 1, 70)) + ) + if not existing: + last = add_explanation_block(ws_co, COHORT_EXPLANATION, start_row=49) + stats["cohort_explanation"] = f"rows 49-{last}" + else: + stats["cohort_explanation"] = "already present" + + # 4 — Sensitivity explanation + if "Sensitivity" in wb.sheetnames: + ws_se = wb["Sensitivity"] + existing = any( + ws_se.cell(row=r, column=1).value == "Erläuterung" + for r in range(38, min(ws_se.max_row + 1, 70)) + ) + if not existing: + last = add_explanation_block(ws_se, SENSITIVITY_EXPLANATION, start_row=39) + stats["sensitivity_explanation"] = f"rows 39-{last}" + else: + stats["sensitivity_explanation"] = "already present" + + if not dry_run: + wb.save(path) + return stats + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-tantieme-{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("--no-backup", action="store_true") + args = ap.parse_args() + + for name in TARGETS: + path = EXPORTS / name + if not path.exists(): + print(f" ⨯ skip {name}: not found") + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + stats = process_file(path, dry_run=args.dry_run) + print(f"\n === {name} ===") + for k, v in stats.items(): + print(f" {k}: {v}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/apply-bueromiete.py b/pitch-deck/scripts/apply-bueromiete.py new file mode 100644 index 0000000..54c2b1a --- /dev/null +++ b/pitch-deck/scripts/apply-bueromiete.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Apply 'Büromiete ab Sep 2027 → 1000€/Monat' to Finanzplan-Wandeldarlehen-400k +Base/Bull/Bear scenarios. + +Updates row 27 (raumkosten) in 'Betriebliche Aufwendungen' sheet of each file. +Existing label is renamed to 'Büromiete (ab Sep 2027)'. + +Usage: + python3 pitch-deck/scripts/apply-bueromiete.py + python3 pitch-deck/scripts/apply-bueromiete.py --dry-run +""" + +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" + +TARGETS = [ + "Finanzplan-Wandeldarlehen-400k.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bull.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bear.xlsx", +] + +RENT_ROW = 27 +RENT_AMOUNT = 1000 +START_YEAR = 2027 +START_MONTH = 9 +NEW_LABEL = "Büromiete (ab Sep 2027)" + + +def find_start_col(ws) -> int: + for c in range(2, ws.max_column + 1): + if ws.cell(row=1, column=c).value == START_YEAR and ws.cell(row=2, column=c).value == START_MONTH: + return c + raise RuntimeError(f"Could not find {START_MONTH}/{START_YEAR} in header rows") + + +def process_file(path: Path, dry_run: bool) -> tuple[int, int, int]: + wb = load_workbook(path) + if "Betriebliche Aufwendungen" not in wb.sheetnames: + raise RuntimeError(f"{path.name}: missing 'Betriebliche Aufwendungen' sheet") + ws = wb["Betriebliche Aufwendungen"] + + current_label = ws.cell(row=RENT_ROW, column=1).value + if current_label is None or "raumkosten" not in str(current_label).lower() and "raum" not in str(current_label).lower() and "miete" not in str(current_label).lower() and "büro" not in str(current_label).lower(): + raise RuntimeError(f"{path.name}: row {RENT_ROW} label is {current_label!r}, expected raumkosten/raum/miete/büro") + + ws.cell(row=RENT_ROW, column=1).value = NEW_LABEL + + start_col = find_start_col(ws) + end_col = ws.max_column + for c in range(start_col, end_col + 1): + ws.cell(row=RENT_ROW, column=c).value = RENT_AMOUNT + + if not dry_run: + wb.save(path) + + return start_col, end_col, end_col - start_col + 1 + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-bueromiete-{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("--no-backup", action="store_true") + args = ap.parse_args() + + for name in TARGETS: + path = EXPORTS / name + if not path.exists(): + print(f" ⚠ skip (not found): {name}", file=sys.stderr) + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + start_col, end_col, n = process_file(path, dry_run=args.dry_run) + print( + f" {name}: row {RENT_ROW} → '{NEW_LABEL}', " + f"cols {get_column_letter(start_col)}..{get_column_letter(end_col)} = {RENT_AMOUNT}€ ({n} months)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/apply-number-formatting.py b/pitch-deck/scripts/apply-number-formatting.py new file mode 100644 index 0000000..61f86cb --- /dev/null +++ b/pitch-deck/scripts/apply-number-formatting.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Apply Euro / Count / Percent number formatting to Finanzplan Excel files. + +Per-sheet defaults + per-row classification based on column-A labels: + +- Sheets with mostly EUR values: Dashboard, Umsatzerlöse, Personalkosten, + Investitionen, Materialaufwand, Betriebliche Aufwendungen, Liquidität, GuV. +- Kunden sheet: counts by default, with helper rows (rates) as percent. +- Treiber sheet: row-by-row classification by label (no sheet default). +- Skip: Formelübersicht (docs). + +Label patterns drive the classification: + - 'EUR/', 'EUR ', 'Startpreis', 'Preis/Monat' → euro + - 'rate', 'satz', 'quote', 'inflation', 'erhöhung', 'provision', '% vom', + 'anteil', 'förderquote' → percent + - 'headcount', 'anzahl', 'mitarbeiter je', 'neukunden/monat', 'neukunden ', + 'bestandskunden', 'churn ' → count + - 'faktor' → skip (it's a multiplier, leave default) + +Inputs sections (Personalkosten rows 5-24, Investitionen 5-29) are skipped +because they contain mixed text/dates/numbers per row that would mis-format +under a single classification. + +Usage: + python3 pitch-deck/scripts/apply-number-formatting.py --dry-run + python3 pitch-deck/scripts/apply-number-formatting.py +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +EURO_FORMAT = '#,##0 "€";-#,##0 "€"' +COUNT_FORMAT = '#,##0' +PERCENT_FORMAT = '0.0%' + +SHEET_CONFIG: dict[str, dict] = { + "Dashboard": {"default": "euro", "start_row": 4}, + "Umsatzerlöse": {"default": "euro", "start_row": 4}, + "Personalkosten": {"default": "euro", "start_row": 27}, + "Investitionen": {"default": "euro", "start_row": 31}, + "Materialaufwand": {"default": "euro", "start_row": 4}, + "Betriebliche Aufwendungen":{"default": "euro", "start_row": 4}, + "Liquidität": {"default": "euro", "start_row": 4}, + "GuV": {"default": "euro", "start_row": 2}, + "Kunden": {"default": "count", "start_row": 4}, + "Treiber": {"default": None, "start_row": 1}, +} + +SKIP_SHEETS = {"Formelübersicht"} + +FORMAT_MAP = {"euro": EURO_FORMAT, "count": COUNT_FORMAT, "percent": PERCENT_FORMAT} + + +def classify_kunden_row(label: str | None) -> str: + """Kunden sheet is always counts/rates regardless of stray 'EUR' substrings.""" + if not label: + return "skip" + s = str(label).lower() + if "rate" in s or "helper" in s: + return "percent" + return "count" + + +def classify_label(label: str | None, sheet_default: str | None) -> str: + if not label: + return "skip" + s = str(label).lower() + + # 1. Explicit Euro markers + if any(k in s for k in ("eur/", " eur ", "eur ", " eur", "startpreis", "preis/monat", "preis (")): + return "euro" + + # 2. Multipliers — skip (preserve existing format) + if "faktor" in s: + return "skip" + + # 3. Percent patterns. Use precise tokens to avoid substring traps: + # 'satz' alone matches 'Umsatz' — use '-satz' / 'steuersatz' instead. + # 'inflation' as substring matches 'Büromiete (+Inflation)' annotation — + # require the label to START with 'inflation' (covers 'Inflation 2027' driver rows). + if s.startswith("inflation"): + return "percent" + # Note: 'provision' alone is too broad — it matches the BA channel-partner + # provision row whose value is in EUR. Use 'anteil' (matches Treiber's + # 'Channel-Provision (Anteil vom Umsatz)') instead. + if any(k in s for k in ("-rate", "rate ", "rate(", "-satz", "steuersatz", + "quote", "erhöhung", + "% vom", "anteil")): + return "percent" + + # 4. Count patterns + if any(k in s for k in ("headcount", "anzahl", "mitarbeiter je", + "/monat starter", "/monat professional", "/monat enterprise")): + return "count" + + # 5. Kunden-sheet customer-tracking rows + if s.startswith(("neukunden", "churn ", "bestandskunden")): + return "count" + + if sheet_default: + return sheet_default + return "skip" + + +def cell_is_numeric_or_formula(value) -> bool: + if value is None: + return True + if isinstance(value, (int, float)): + return True + if isinstance(value, str): + return value.startswith("=") + return False + + +def format_sheet(ws, sheet_name: str) -> dict[str, int]: + config = SHEET_CONFIG.get(sheet_name) + if config is None: + return {"euro": 0, "count": 0, "percent": 0, "skipped_rows": 0} + start_row = config["start_row"] + sheet_default = config["default"] + stats = {"euro": 0, "count": 0, "percent": 0, "skipped_rows": 0} + + for r in range(start_row, ws.max_row + 1): + label = ws.cell(row=r, column=1).value + if sheet_name == "Kunden": + kind = classify_kunden_row(label) + else: + kind = classify_label(label, sheet_default) + if kind == "skip": + stats["skipped_rows"] += 1 + continue + fmt = FORMAT_MAP[kind] + + # Treiber: value lives in col B only (apply to row's col B) + if sheet_name == "Treiber": + cell = ws.cell(row=r, column=2) + if cell_is_numeric_or_formula(cell.value) and cell.value is not None: + cell.number_format = fmt + stats[kind] += 1 + continue + + # Other sheets: apply across all value columns + for c in range(2, ws.max_column + 1): + cell = ws.cell(row=r, column=c) + if not cell_is_numeric_or_formula(cell.value): + continue + cell.number_format = fmt + stats[kind] += 1 + + return stats + + +def process_file(path: Path, dry_run: bool) -> dict: + wb = load_workbook(path) + sheet_stats: dict[str, dict] = {} + for sheet_name in wb.sheetnames: + if sheet_name in SKIP_SHEETS: + continue + if sheet_name not in SHEET_CONFIG: + continue + sheet_stats[sheet_name] = format_sheet(wb[sheet_name], sheet_name) + + if not dry_run: + wb.save(path) + return sheet_stats + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-formatting-{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: + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + stats = process_file(path, dry_run=args.dry_run) + print(f"\n === {path.name} ===") + for sheet, s in stats.items(): + total = s["euro"] + s["count"] + s["percent"] + if total > 0 or s["skipped_rows"] > 0: + parts = [] + if s["euro"]: + parts.append(f"€:{s['euro']}") + if s["count"]: + parts.append(f"#:{s['count']}") + if s["percent"]: + parts.append(f"%:{s['percent']}") + if s["skipped_rows"]: + parts.append(f"skip:{s['skipped_rows']}") + print(f" {sheet}: {' '.join(parts)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/cleanup-finanzplan-labels.py b/pitch-deck/scripts/cleanup-finanzplan-labels.py new file mode 100644 index 0000000..98be49b --- /dev/null +++ b/pitch-deck/scripts/cleanup-finanzplan-labels.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Clean up Finanzplan Excel files: + +Strip 'category — ' prefix from column-A labels in 'Betriebliche Aufwendungen' +and 'Liquidität' sheets. + +Runs against ALL Finanzplan-*.xlsx files in pitch-deck/exports/ (except BACKUPs). +Creates a single timestamped backup per file before modification. + +Usage: + python3 pitch-deck/scripts/cleanup-finanzplan-labels.py + python3 pitch-deck/scripts/cleanup-finanzplan-labels.py --dry-run + python3 pitch-deck/scripts/cleanup-finanzplan-labels.py --only Finanzplan-Wandeldarlehen-400k.xlsx +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + + +def strip_prefix(value): + """Drop a lowercase 'kategorie — ' prefix from a label.""" + if not value: + return value + s = str(value) + if " — " not in s: + return s + prefix, rest = s.split(" — ", 1) + # Only strip if prefix looks like a single-word lowercase category tag + if prefix.islower() and prefix.replace("/", "").replace("-", "").isalpha(): + return rest + return s + + +def clean_labels(ws, start_row: int = 4) -> int: + n = 0 + for r in range(start_row, ws.max_row + 1): + before = ws.cell(row=r, column=1).value + after = strip_prefix(before) + if after != before: + ws.cell(row=r, column=1).value = after + n += 1 + return n + + +def process_file(path: Path, dry_run: bool = False) -> dict: + wb = load_workbook(path) + stats = {"liq_labels": 0, "ba_labels": 0} + + if "Liquidität" in wb.sheetnames: + stats["liq_labels"] = clean_labels(wb["Liquidität"], start_row=4) + + if "Betriebliche Aufwendungen" in wb.sheetnames: + stats["ba_labels"] = clean_labels(wb["Betriebliche Aufwendungen"], start_row=4) + + if not dry_run: + wb.save(path) + + return stats + + +def backup(path: Path, tag: str) -> Path: + """Make a single timestamped backup. Returns the backup path.""" + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-{tag}-{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", help="Show what would change, do not write") + ap.add_argument("--only", help="Process only a specific filename") + ap.add_argument("--no-backup", action="store_true", help="Skip backup (default: backup once)") + 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] + if not files: + print("No files matched", file=sys.stderr) + return 1 + + print(f"{'DRY-RUN — ' if args.dry_run else ''}{len(files)} file(s) to process") + for path in files: + if not args.dry_run and not args.no_backup: + bk = backup(path, "pre-label-cleanup") + print(f" ✓ backup: {bk.name}") + stats = process_file(path, dry_run=args.dry_run) + print( + f" {path.name}: " + f"Liq labels={stats['liq_labels']} BA labels={stats['ba_labels']}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/copy-extra-sheets.py b/pitch-deck/scripts/copy-extra-sheets.py new file mode 100644 index 0000000..fe14e24 --- /dev/null +++ b/pitch-deck/scripts/copy-extra-sheets.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Copy 'extra' sheets from the Series-A Ambitioniert reference workbook to the +400k Wandeldarlehen Base/Bull/Bear workbooks. + +Source: Finanzplan-Series-A-Ambitioniert.xlsx +Sheets copied: + - Charts (153 rows + 12 chart objects) + - Unit Economics (Investor-KPIs incl. LTV/CAC, NRR) + - Wandeldarlehen (Tranchen-Konditionen) + - Cohort-Analyse (Retention pro Akquise-Monat) + - Sensitivity (Sensitivity-Analyse) + - Hiring-Plan (Quartal-Übersicht) + +Also renames 'Net Dollar Retention' → 'Net Revenue Retention' and 'NDR' → 'NRR' +across all target files (and the source), since we calculate in Euro. + +Cell values, number formats, basic styles, merged cells, column widths, row +heights, and freeze panes are copied. Charts are deep-copied — they may fail +silently if the openpyxl chart structure isn't fully serializable; a warning +is printed in that case. + +Usage: + python3 pitch-deck/scripts/copy-extra-sheets.py --dry-run + python3 pitch-deck/scripts/copy-extra-sheets.py +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from copy import copy as shallow_copy +from copy import deepcopy +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +SOURCE_FILE = "Finanzplan-Series-A-Ambitioniert.xlsx" + +TARGETS = [ + "Finanzplan-Wandeldarlehen-400k.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bull.xlsx", + "Finanzplan-Wandeldarlehen-400k-Bear.xlsx", +] + +SHEETS_TO_COPY = [ + "Charts", + "Unit Economics", + "Wandeldarlehen", + "Cohort-Analyse", + "Sensitivity", + "Hiring-Plan", +] + +# Currency-neutral renames (Euro project, not USD) +RENAMES = { + "Net Dollar Retention": "Net Revenue Retention", + "NDR Annahme": "NRR Annahme", + "• NDR steigt": "• NRR steigt", + "NDR": "NRR", # last; safety renames any remaining bare 'NDR' +} + + +def copy_cell_style(src_cell, dst_cell) -> None: + if src_cell.has_style: + dst_cell.font = shallow_copy(src_cell.font) + dst_cell.fill = shallow_copy(src_cell.fill) + dst_cell.border = shallow_copy(src_cell.border) + dst_cell.alignment = shallow_copy(src_cell.alignment) + dst_cell.number_format = src_cell.number_format + dst_cell.protection = shallow_copy(src_cell.protection) + + +def copy_sheet_content(src_ws, dst_ws) -> None: + """Copy cells, dimensions, merged ranges, freeze panes, and charts.""" + for row in src_ws.iter_rows(): + for src_cell in row: + dst_cell = dst_ws.cell(row=src_cell.row, column=src_cell.column, value=src_cell.value) + copy_cell_style(src_cell, dst_cell) + + # Column widths + hidden state + for col_key, dim in src_ws.column_dimensions.items(): + d = dst_ws.column_dimensions[col_key] + if dim.width: + d.width = dim.width + d.hidden = dim.hidden + + # Row heights + hidden state + for row_key, dim in src_ws.row_dimensions.items(): + d = dst_ws.row_dimensions[row_key] + if dim.height: + d.height = dim.height + d.hidden = dim.hidden + + # Merged cells + for merged_range in list(src_ws.merged_cells.ranges): + dst_ws.merge_cells(str(merged_range)) + + # Freeze panes + if src_ws.freeze_panes: + dst_ws.freeze_panes = src_ws.freeze_panes + + # Charts (best-effort deepcopy) + charts_ok = 0 + charts_fail = 0 + for chart in src_ws._charts: + try: + new_chart = deepcopy(chart) + dst_ws.add_chart(new_chart) + charts_ok += 1 + except Exception as e: # noqa: BLE001 + charts_fail += 1 + print(f" ⚠ chart copy failed: {type(e).__name__}: {e}", file=sys.stderr) + if charts_ok or charts_fail: + print(f" charts: {charts_ok} ok, {charts_fail} failed") + + +def insert_sheet_before_formeluebersicht(wb, sheet_name: str) -> None: + """If 'Formelübersicht' is in the workbook, move new sheet just before it.""" + if "Formelübersicht" not in wb.sheetnames: + return + new_idx = wb.sheetnames.index(sheet_name) + fmt_idx = wb.sheetnames.index("Formelübersicht") + if new_idx > fmt_idx: + # move new sheet to just before Formelübersicht + offset = (fmt_idx) - new_idx + wb.move_sheet(sheet_name, offset=offset) + + +def rename_in_workbook(wb) -> int: + """Apply NDR → NRR renames across all string cells. Returns count of changes.""" + n = 0 + for ws in wb.worksheets: + for row in ws.iter_rows(): + for cell in row: + if not isinstance(cell.value, str) or not cell.value: + continue + new_value = cell.value + for old, new in RENAMES.items(): + if old in new_value: + new_value = new_value.replace(old, new) + if new_value != cell.value: + cell.value = new_value + n += 1 + return n + + +def process_target(target_path: Path, src_wb, dry_run: bool) -> dict: + wb = load_workbook(target_path) + copied_sheets = [] + for sheet_name in SHEETS_TO_COPY: + if sheet_name not in src_wb.sheetnames: + print(f" skip {sheet_name}: not in source") + continue + if sheet_name in wb.sheetnames: + del wb[sheet_name] + new_ws = wb.create_sheet(title=sheet_name) + print(f" copying {sheet_name} ...") + copy_sheet_content(src_wb[sheet_name], new_ws) + insert_sheet_before_formeluebersicht(wb, sheet_name) + copied_sheets.append(sheet_name) + + renames = rename_in_workbook(wb) + + if not dry_run: + wb.save(target_path) + + return {"copied": copied_sheets, "renames": renames} + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-extra-sheets-{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("--no-backup", action="store_true") + ap.add_argument("--skip-rename", action="store_true", + help="Don't apply NDR → NRR renames") + args = ap.parse_args() + + if args.skip_rename: + RENAMES.clear() + + source_path = EXPORTS / SOURCE_FILE + if not source_path.exists(): + print(f"ERROR: source not found: {source_path}", file=sys.stderr) + return 2 + src_wb = load_workbook(source_path) + + # Also rename in source + if not args.dry_run: + bk_src = backup(source_path) + print(f" ✓ source backup: {bk_src.name}") + n_src_renames = rename_in_workbook(src_wb) + if not args.dry_run: + src_wb.save(source_path) + print(f" source ({SOURCE_FILE}): {n_src_renames} cell rename(s)") + # Re-open source as the now-renamed version so target copies pick up new labels + src_wb = load_workbook(source_path) + + for name in TARGETS: + path = EXPORTS / name + if not path.exists(): + print(f"\n ⨯ skip {name}: not found") + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f"\n ✓ backup: {bk.name}") + print(f" === {name} ===") + info = process_target(path, src_wb, dry_run=args.dry_run) + print(f" copied: {info['copied']}") + print(f" cell renames: {info['renames']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())