Files
breakpilot-core/pitch-deck/scripts/add-kunden-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

324 lines
13 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
"""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())