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,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