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>
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user