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,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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user