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:
Benjamin Admin
2026-05-20 16:23:12 +02:00
parent 7d721a6787
commit 01e2e0fc4b
18 changed files with 1857 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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())
+323
View File
@@ -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())
+275
View File
@@ -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())
@@ -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)^(<col>$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())
+99
View File
@@ -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())
@@ -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())
@@ -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())
+225
View File
@@ -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())