01e2e0fc4b
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>
276 lines
9.9 KiB
Python
276 lines
9.9 KiB
Python
#!/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())
|