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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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())
|
||||
@@ -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