Compare commits
9 Commits
b4043b20b2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3b0e26fd | |||
| 19d1a56df4 | |||
| 3934bdf814 | |||
| dbd44ecc20 | |||
| 93687a32fe | |||
| 2d9fec3a6d | |||
| a6f4ca88a4 | |||
| 297eff949e | |||
| 01e2e0fc4b |
@@ -0,0 +1,83 @@
|
||||
# Lizenzregeln der Control-Pipeline
|
||||
|
||||
> **Stand:** 2026-05-21 — Mapping festgezurrt nach DB-Inspektion und IACE-Audit.
|
||||
>
|
||||
> Die Pipeline klassifiziert jede Regulation (und damit jedes daraus extrahierte
|
||||
> Chunk und jeden atomic_control) in eine von **drei Lizenzregeln**. Die Regel
|
||||
> entscheidet, ob der Volltext aufbewahrt werden darf und welche Attribution im
|
||||
> Ausgabe-Renderer Pflicht ist.
|
||||
|
||||
## Die drei Regeln
|
||||
|
||||
| Regel | Bedeutung | Volltext speichern? | Attribution Pflicht? | Beispiele |
|
||||
|-------|-----------|---------------------|----------------------|-----------|
|
||||
| **1** | Wörtlich — Hoheitsrecht / Public Domain | ✓ | nein (empfohlen für Audit) | EU-Recht (EUR-Lex), Bundesrecht, Satzungsrecht (DGUV UVV), TRBS, TRGS, ASR, US Federal Code (OSHA), NIST SP, EU-Leitfäden |
|
||||
| **2** | Wörtlich mit Attribution — freie Lizenzen | ✓ | **ja** | OWASP (CC-BY-SA-4.0), OECD AI Principles (OECD_PUBLIC), ENISA-Dokumente (CC-BY-4.0), Apache-2.0 Werke |
|
||||
| **3** | Nur zitieren — proprietäre Standards | ✗ | nicht anwendbar (kein Volltext) | DIN, EN, ISO, ANSI, UL, IEC, IEEE, DGUV Regeln/Informationen/Grundsätze, Bitkom-Leitfäden, BSI-Bausteine (urheberrechtlich) |
|
||||
|
||||
**Wichtige Klarstellung:** Regel 3 = "nur Identifier/Abschnitt zitieren", **nicht** "umformulieren". Die ursprüngliche Bezeichnung "neu formulieren" war irreführend. Korrekt: Bei Regel-3-Quellen darf die Pipeline den Volltext nicht speichern; sie bewahrt nur die Quellenreferenz (regulation_id + article/paragraph), und der Output-Renderer zeigt diese Referenz im Frontend/PDF.
|
||||
|
||||
## Mapping `license_type` → `license_rule`
|
||||
|
||||
| license_type | license_rule | Erklärung |
|
||||
|---|---|---|
|
||||
| `EU_LAW`, `EU_PUBLIC` | 1 | EU-Verordnungen, Richtlinien, OJ-Veröffentlichungen, EU-Leitfäden |
|
||||
| `DE_LAW`, `DE_PUBLIC` | 1 | Bundesgesetze, TRBS, TRGS, ASR, DGUV-UVV (Satzungsrecht) |
|
||||
| `AT_LAW`, `CH_LAW`, `FR_LAW`, `IT_LAW`, `ES_LAW`, `NL_LAW`, `HU_LAW` | 1 | Andere EU-Mitgliedsstaaten-Recht |
|
||||
| `US_GOV_PUBLIC`, `NIST_PUBLIC_DOMAIN`, `OSHA_PUBLIC` | 1 | US Federal Code (17 U.S.C. §105 Public Domain) |
|
||||
| `CC-BY-4.0`, `CC-BY-SA-4.0`, `CC-BY-3.0`, `CC-BY-SA-3.0` | 2 | Creative-Commons mit Attribution-Pflicht |
|
||||
| `Apache-2.0`, `MIT` | 2 | Permissive OSS-Lizenzen, NOTICE-Pflicht |
|
||||
| `OECD_PUBLIC`, `ENISA_CC_BY_4.0` | 2 | Behörden-Publikationen mit Attribution-Auflage |
|
||||
| `DIN_COPYRIGHT`, `ISO_COPYRIGHT`, `ANSI_COPYRIGHT`, `UL_COPYRIGHT`, `IEC_COPYRIGHT` | 3 | Normungsorganisationen — nur Identifier-Zitat |
|
||||
| `DGUV_COPYRIGHT` | 3 | DGUV Regeln/Informationen/Grundsätze (nicht UVV) |
|
||||
| `BITKOM_COPYRIGHT`, `BSI_COPYRIGHT`, `VDMA_COPYRIGHT` | 3 | Verbands-/Behörden-Publikationen mit eigenständigem Urheberrecht |
|
||||
| `OWN_WORK` | 3 | BreakPilot-Eigentexte (Templates, eigene Patterns) — kein externes Lizenzrisiko, aber auch kein Public-Domain-Status |
|
||||
|
||||
**Sonderfall DGUV:** Die Klasse trennt sich nach Publikationstyp:
|
||||
- DGUV **Vorschriften / UVV** → `DE_LAW` → Regel 1
|
||||
- DGUV **Regeln, Informationen, Grundsätze** → `DGUV_COPYRIGHT` → Regel 3
|
||||
|
||||
## Auswirkung pro Pipeline-Stage
|
||||
|
||||
| Stage | Verhalten bei Regel 1 | Regel 2 | Regel 3 |
|
||||
|---|---|---|---|
|
||||
| Stage 6 ControlCompose (`pipeline_adapter.py:147`) | speichert `chunk_text` | speichert `chunk_text` | speichert `chunk_text = None` |
|
||||
| Atomic-Control-Bildung | Volltext als Quelle | Volltext + Attribution-Vermerk | nur regulation_id + article |
|
||||
| Output-Renderer (Frontend/PDF) | optionaler Quellen-Hinweis | **Pflicht-Attribution in Footer + Inline** | nur Identifier rendern |
|
||||
| Tech-File-Anhang | Quelle nennen | Quelle + Lizenz-URL | Identifier-Liste |
|
||||
|
||||
## Quellen ohne Klassifikation
|
||||
|
||||
Aktuell sind in `regulation_registry` **232 Regulationen** klassifiziert (Stand 2026-05-21). Die folgenden müssen noch ergänzt werden (Task #20 deckt den DGUV-Ingest):
|
||||
|
||||
| Quelle | Regel | Begründung |
|
||||
|---|---|---|
|
||||
| TRBS-Familie (24 PDFs im RAG) | 1 | Technische Regeln Betriebssicherheit — BAuA Bundesarbeitsblatt |
|
||||
| TRGS-Familie (alle Volltext-Chunks) | 1 | Technische Regeln Gefahrstoffe — BAuA |
|
||||
| ASR-Familie (17 PDFs) | 1 | Arbeitsstättenregeln — BAuA |
|
||||
| OSHA 29 CFR 1910 Subpart O + Technical Manual | 1 | US Federal Public Domain (17 U.S.C. §105) |
|
||||
| DGUV Vorschrift 1 + UVV-Familie (sobald ingest) | 1 | Satzungsrecht der BG |
|
||||
| DGUV Regel 100-500 + Information 209-072/074/073 | 3 | DGUV-Copyright, nur Identifier |
|
||||
| DIN-Identifier-Tabelle (ohne Volltext) | 3 | DIN-Beuth-Copyright |
|
||||
| ANSI B11.0 + RIA R15.06 + UL 508A Identifier | 3 | ANSI/UL-Copyright |
|
||||
| ISO 12100/13849/13857 Identifier | 3 | ISO-Copyright |
|
||||
|
||||
## Audit-Pflicht
|
||||
|
||||
Vor jedem Ingest neuer Quellen:
|
||||
1. Lizenz prüfen (publikationen.dguv.de, EUR-Lex, etc.)
|
||||
2. license_type aus obiger Tabelle wählen — wenn nicht vorhanden, hier ergänzen
|
||||
3. license_rule wird daraus deterministisch abgeleitet
|
||||
4. Attribution-Text bei Regel 2 ist Pflichtfeld
|
||||
|
||||
Vor jedem Output:
|
||||
- Wenn ein atomic_control aus einer Regel-3-Quelle stammt: prüfen dass NUR Identifier gezeigt wird, niemals Volltext
|
||||
- Wenn aus Regel-2-Quelle: Attribution muss im PDF-Footer und im Frontend-Tooltip vorhanden sein
|
||||
- Wenn aus Regel-1-Quelle: empfohlen Quelle nennen für Auditierbarkeit
|
||||
|
||||
## Verweise
|
||||
|
||||
- Schema: `migrations/002_regulation_registry.sql`
|
||||
- Code: `services/regulation_registry.py`, `services/pipeline_adapter.py`
|
||||
- Seed-Script: `scripts/f1_migrate_regulation_registry.py`
|
||||
- Tests: `tests/test_regulation_registry.py` (assert: rule IN (1,2,3))
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit script for license classification gaps in the control pipeline.
|
||||
|
||||
Reports:
|
||||
|
||||
1. **regulation_registry coverage** — how many regulations are classified, by
|
||||
rule and license_type.
|
||||
2. **atomic_controls without license_rule** — how many controls reference a
|
||||
regulation_id that has no entry (or no license_rule) in the registry.
|
||||
3. **Qdrant payload consistency** — for each indexed collection, how many
|
||||
chunks carry both ``license`` and ``license_rule`` payload fields.
|
||||
|
||||
The goal is to surface every record where the engine could in principle
|
||||
extract or emit content but the license rule is unknown — those records are
|
||||
the highest-risk material in a license audit.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/audit_license_classification.py --db-host 100.80.114.48
|
||||
|
||||
Add ``--check-qdrant`` to also probe ``http://<host>:6333`` collections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib import request as urllib_request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
DEFAULT_HOST = "100.80.114.48"
|
||||
DEFAULT_PORT = 5432
|
||||
DEFAULT_USER = "breakpilot"
|
||||
DEFAULT_DB = "breakpilot_db"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default=DEFAULT_HOST)
|
||||
p.add_argument("--db-port", type=int, default=DEFAULT_PORT)
|
||||
p.add_argument("--db-user", default=DEFAULT_USER)
|
||||
p.add_argument("--db-name", default=DEFAULT_DB)
|
||||
p.add_argument("--db-password", default="")
|
||||
p.add_argument("--check-qdrant", action="store_true")
|
||||
p.add_argument("--qdrant-host", default="100.80.114.48")
|
||||
p.add_argument("--qdrant-port", type=int, default=6333)
|
||||
p.add_argument("--json", action="store_true", help="Emit JSON result on stdout")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def audit_registry(conn) -> dict:
|
||||
"""Coverage of regulation_registry."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT license_rule, license_type, COUNT(*) "
|
||||
"FROM regulation_registry GROUP BY license_rule, license_type "
|
||||
"ORDER BY license_rule, license_type;"
|
||||
)
|
||||
by_rule_and_type: list[tuple] = []
|
||||
by_rule: Counter = Counter()
|
||||
for rule, ltype, count in cur.fetchall():
|
||||
by_rule_and_type.append((rule, ltype or "(empty)", count))
|
||||
by_rule[rule] += count
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM regulation_registry "
|
||||
"WHERE license_type IS NULL OR license_type = '';"
|
||||
)
|
||||
missing_type = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM regulation_registry;")
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_rule": dict(by_rule),
|
||||
"by_rule_and_type": by_rule_and_type,
|
||||
"missing_license_type": missing_type,
|
||||
}
|
||||
|
||||
|
||||
def audit_atomic_controls(conn) -> dict:
|
||||
"""Controls whose source regulation has no license rule.
|
||||
|
||||
Important: the schema differs between core (bp-core) and customer
|
||||
deployments. We probe a handful of likely column names and skip if
|
||||
none are found.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
# Detect controls table
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema='compliance' AND table_name IN "
|
||||
"('atomic_controls','atomic_controls_dedup','canonical_controls');"
|
||||
)
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
if not tables:
|
||||
return {"skipped": True, "reason": "no controls table found"}
|
||||
|
||||
result: dict = {"tables": {}}
|
||||
for tbl in tables:
|
||||
cur.execute(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_schema='compliance' AND table_name='{tbl}';"
|
||||
)
|
||||
cols = {r[0] for r in cur.fetchall()}
|
||||
if "license_rule" not in cols:
|
||||
result["tables"][tbl] = {"skipped": True, "reason": "no license_rule column"}
|
||||
continue
|
||||
cur.execute(f"SELECT COUNT(*) FROM compliance.{tbl};")
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
f"SELECT license_rule, COUNT(*) FROM compliance.{tbl} "
|
||||
f"GROUP BY license_rule ORDER BY license_rule;"
|
||||
)
|
||||
by_rule = {str(r[0]): r[1] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM compliance.{tbl} WHERE license_rule IS NULL;"
|
||||
)
|
||||
missing = cur.fetchone()[0]
|
||||
result["tables"][tbl] = {
|
||||
"total": total,
|
||||
"by_rule": by_rule,
|
||||
"missing_license_rule": missing,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def audit_qdrant(host: str, port: int) -> dict:
|
||||
"""Probe Qdrant collections for license + license_rule payload coverage.
|
||||
|
||||
Samples 500 points per collection and reports how many have neither
|
||||
field populated.
|
||||
"""
|
||||
out: dict = {"collections": {}}
|
||||
base = f"http://{host}:{port}"
|
||||
try:
|
||||
with urllib_request.urlopen(f"{base}/collections", timeout=10) as r:
|
||||
colls = json.loads(r.read()).get("result", {}).get("collections", [])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
for c in colls:
|
||||
name = c["name"]
|
||||
if "compliance" not in name and "atomic_controls" not in name:
|
||||
continue
|
||||
payload = {"limit": 500, "with_payload": True, "with_vector": False}
|
||||
req = urllib_request.Request(
|
||||
f"{base}/collections/{name}/points/scroll",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=15) as r:
|
||||
points = json.loads(r.read()).get("result", {}).get("points", [])
|
||||
except Exception as e:
|
||||
out["collections"][name] = {"error": str(e)}
|
||||
continue
|
||||
sampled = len(points)
|
||||
both_set = 0
|
||||
only_license = 0
|
||||
only_rule = 0
|
||||
neither = 0
|
||||
for p in points:
|
||||
pl = p.get("payload", {}) or {}
|
||||
has_lic = bool(pl.get("license"))
|
||||
has_rule = pl.get("license_rule") is not None
|
||||
if has_lic and has_rule:
|
||||
both_set += 1
|
||||
elif has_lic:
|
||||
only_license += 1
|
||||
elif has_rule:
|
||||
only_rule += 1
|
||||
else:
|
||||
neither += 1
|
||||
out["collections"][name] = {
|
||||
"sampled": sampled,
|
||||
"both_set": both_set,
|
||||
"only_license_field": only_license,
|
||||
"only_license_rule_field": only_rule,
|
||||
"neither_set": neither,
|
||||
"neither_pct": round(neither / sampled * 100, 1) if sampled else 0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed (pip install psycopg2-binary)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
user=args.db_user,
|
||||
dbname=args.db_name,
|
||||
password=args.db_password or None,
|
||||
)
|
||||
try:
|
||||
registry = audit_registry(conn)
|
||||
controls = audit_atomic_controls(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
qdrant: Optional[dict] = None
|
||||
if args.check_qdrant:
|
||||
qdrant = audit_qdrant(args.qdrant_host, args.qdrant_port)
|
||||
|
||||
result = {"registry": registry, "atomic_controls": controls, "qdrant": qdrant}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
print("=" * 60)
|
||||
print(" Audit — License Classification")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"## regulation_registry ({registry['total']} rows)")
|
||||
print(f" By rule: {registry['by_rule']}")
|
||||
print(f" Missing license_type: {registry['missing_license_type']}")
|
||||
print()
|
||||
print("## atomic_controls")
|
||||
for tbl, info in controls.get("tables", {}).items():
|
||||
if info.get("skipped"):
|
||||
print(f" {tbl}: SKIPPED ({info['reason']})")
|
||||
continue
|
||||
print(f" {tbl}: {info['total']} rows")
|
||||
print(f" by_rule={info['by_rule']}")
|
||||
print(f" missing_license_rule={info['missing_license_rule']}")
|
||||
print()
|
||||
if qdrant:
|
||||
print("## qdrant")
|
||||
for name, info in qdrant.get("collections", {}).items():
|
||||
if "error" in info:
|
||||
print(f" {name}: ERROR {info['error']}")
|
||||
continue
|
||||
print(
|
||||
f" {name:30} sampled={info['sampled']:4} "
|
||||
f"both={info['both_set']:4} "
|
||||
f"neither={info['neither_set']:4} ({info['neither_pct']}%)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill license_rule on canonical_controls by inheriting from parent.
|
||||
|
||||
Background
|
||||
==========
|
||||
|
||||
Audit (audit_license_classification.py) showed that 279,384 of 314,811 rows
|
||||
in compliance.canonical_controls have NULL license_rule. Drilling in:
|
||||
|
||||
- 261,980 of those (94%) have a parent_control_uuid whose parent already
|
||||
carries a non-NULL license_rule. The pass0b decomposition pipeline did
|
||||
not propagate the rule to its child controls — this is a clear inheritance
|
||||
bug, fixable without any classification decisions.
|
||||
- 16,617 have a parent that itself has no license_rule (transitive case).
|
||||
Inheriting iteratively converges to either rule-set or root-orphan.
|
||||
- 787 have no parent at all (decomposition roots). These need cluster-based
|
||||
manual classification (see Strategy Notes at the bottom of this file).
|
||||
|
||||
This script runs the inheritance fix in three idempotent stages and
|
||||
prints per-stage counts before any write happens.
|
||||
|
||||
Usage::
|
||||
|
||||
# Always dry-run first:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --dry-run
|
||||
|
||||
# If counts look right:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --apply
|
||||
|
||||
The script is safe to rerun — it only touches rows where license_rule
|
||||
IS NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default="100.80.114.48")
|
||||
p.add_argument("--db-port", type=int, default=5432)
|
||||
p.add_argument("--db-user", default="breakpilot")
|
||||
p.add_argument("--db-name", default="breakpilot_db")
|
||||
p.add_argument("--db-password", required=True)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--max-iterations", type=int, default=5,
|
||||
help="Cap on inheritance iterations to avoid loops")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
# Stage 1: direct parent has license_rule — single UPDATE.
|
||||
# Stage 2: iterative — parent did not have it, but a grandparent does.
|
||||
# We loop until no more rows can be filled or max-iterations.
|
||||
# Stage 3: residual rows with no resolvable parent. Report them clustered
|
||||
# by category/pattern_id so the user can classify by family.
|
||||
|
||||
SQL_REPORT_NULLS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
CASE WHEN cc.parent_control_uuid IS NULL THEN 'no_parent'
|
||||
WHEN p.license_rule IS NULL THEN 'parent_null'
|
||||
ELSE 'parent_set' END AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls cc
|
||||
LEFT JOIN canonical_controls p ON cc.parent_control_uuid = p.id
|
||||
WHERE cc.license_rule IS NULL
|
||||
GROUP BY 1 ORDER BY 2 DESC;
|
||||
"""
|
||||
|
||||
SQL_INHERIT_FROM_PARENT = """
|
||||
SET search_path TO compliance, public;
|
||||
UPDATE canonical_controls cc
|
||||
SET license_rule = p.license_rule, updated_at = NOW()
|
||||
FROM canonical_controls p
|
||||
WHERE cc.parent_control_uuid = p.id
|
||||
AND cc.license_rule IS NULL
|
||||
AND p.license_rule IS NOT NULL;
|
||||
"""
|
||||
|
||||
SQL_REPORT_ORPHAN_CLUSTERS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
COALESCE(category, '(null)') AS category,
|
||||
COALESCE(pattern_id, '(null)') AS pattern_id,
|
||||
COALESCE(generation_strategy, '(null)') AS gen,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls
|
||||
WHERE license_rule IS NULL AND parent_control_uuid IS NULL
|
||||
GROUP BY 1, 2, 3 ORDER BY n DESC LIMIT 25;
|
||||
"""
|
||||
|
||||
|
||||
def print_bucket(rows, label: str) -> None:
|
||||
print(f"\n## {label}")
|
||||
total = 0
|
||||
for bucket, n in rows:
|
||||
print(f" {bucket:12} {n:>8}")
|
||||
total += n
|
||||
print(f" {'TOTAL':12} {total:>8}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host, port=args.db_port, user=args.db_user,
|
||||
dbname=args.db_name, password=args.db_password,
|
||||
)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
|
||||
print("=" * 60)
|
||||
print(" Backfill — license_rule via parent inheritance")
|
||||
print(f" Mode: {'DRY-RUN' if args.dry_run else 'APPLY'}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initial bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
rows = cur.fetchall()
|
||||
print_bucket(rows, "Initial NULL distribution")
|
||||
|
||||
if args.dry_run:
|
||||
# Print what the FIRST inherit pass would resolve (without writing)
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT p.license_rule, COUNT(*) "
|
||||
"FROM canonical_controls cc "
|
||||
"JOIN canonical_controls p ON cc.parent_control_uuid = p.id "
|
||||
"WHERE cc.license_rule IS NULL AND p.license_rule IS NOT NULL "
|
||||
"GROUP BY 1 ORDER BY 1;"
|
||||
)
|
||||
print("\n## First inherit-pass would fill:")
|
||||
for rule, n in cur.fetchall():
|
||||
print(f" rule={rule} {n:>8} rows")
|
||||
|
||||
# Show orphan clusters that would remain
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
print("\n## Orphan clusters (no parent + no rule, top 25):")
|
||||
for cat, pid, gen, n in cur.fetchall():
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
print("\nNo writes performed. Use --apply to execute.")
|
||||
conn.rollback()
|
||||
return 0
|
||||
|
||||
# Apply mode — iterative inheritance
|
||||
total_updated = 0
|
||||
for i in range(1, args.max_iterations + 1):
|
||||
cur.execute(SQL_INHERIT_FROM_PARENT)
|
||||
updated = cur.rowcount
|
||||
total_updated += updated
|
||||
print(f"\n iteration {i}: {updated} rows updated")
|
||||
if updated == 0:
|
||||
break
|
||||
|
||||
conn.commit()
|
||||
print(f"\n✓ Total rows backfilled: {total_updated}")
|
||||
|
||||
# Final bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
print_bucket(cur.fetchall(), "Remaining NULL distribution")
|
||||
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
print("\n## Orphan clusters still need classification:")
|
||||
for cat, pid, gen, n in rows:
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill ``license_rule`` payload field into Qdrant atomic_controls_dedup
|
||||
and related compliance collections, sourced from canonical_controls in Postgres.
|
||||
|
||||
The audit (audit_license_classification.py) surfaced that Qdrant collections
|
||||
holding canonical-control vectors (notably ``atomic_controls_dedup``) carry no
|
||||
license_rule payload at all, even though the underlying Postgres table is now
|
||||
fully classified. This script joins the two via ``control_uuid`` and patches the
|
||||
Qdrant payload in batches.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/backfill_qdrant_license_payload.py \\
|
||||
--pg-host 100.80.114.48 --pg-password breakpilot123 \\
|
||||
--qdrant http://100.80.114.48:6333 \\
|
||||
--collection atomic_controls_dedup \\
|
||||
--dry-run
|
||||
|
||||
# apply
|
||||
python3 scripts/backfill_qdrant_license_payload.py ... --apply
|
||||
|
||||
Notes
|
||||
-----
|
||||
- ``control_uuid`` lives in the payload of atomic_controls_dedup. For other
|
||||
collections that key the canonical control by a different field, override with
|
||||
``--uuid-field``.
|
||||
- Qdrant ``set_payload`` is keyed by point id, not payload field. We resolve
|
||||
UUID → point id by a paginated scroll-and-filter pass, then issue grouped
|
||||
set_payload requests per license_rule (3 batches per collection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Iterator
|
||||
from urllib import request as urllib_request
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--pg-host", default="100.80.114.48")
|
||||
p.add_argument("--pg-port", type=int, default=5432)
|
||||
p.add_argument("--pg-user", default="breakpilot")
|
||||
p.add_argument("--pg-name", default="breakpilot_db")
|
||||
p.add_argument("--pg-password", required=True)
|
||||
p.add_argument("--qdrant", default="http://100.80.114.48:6333")
|
||||
p.add_argument("--qdrant-api-key", default="",
|
||||
help="API key for managed Qdrant (Production)")
|
||||
p.add_argument("--collection", default="atomic_controls_dedup")
|
||||
p.add_argument("--uuid-field", default="control_uuid",
|
||||
help="Payload field used for lookup (control_uuid or regulation_id)")
|
||||
p.add_argument("--lookup", choices=["canonical_controls", "regulation_registry"],
|
||||
default="canonical_controls",
|
||||
help="Postgres table to resolve the lookup against")
|
||||
p.add_argument("--batch-size", type=int, default=500)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def fetch_rule_by_uuid(args) -> dict[str, int]:
|
||||
"""Pull lookup-key → license_rule mapping from Postgres.
|
||||
|
||||
Source table is chosen by ``--lookup``:
|
||||
- canonical_controls: id (UUID) → license_rule, for atomic_controls_dedup
|
||||
- regulation_registry: regulation_id → license_rule, for document chunks
|
||||
"""
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=args.pg_host, port=args.pg_port, user=args.pg_user,
|
||||
dbname=args.pg_name, password=args.pg_password,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SET search_path TO compliance, public;")
|
||||
if args.lookup == "regulation_registry":
|
||||
cur.execute(
|
||||
"SELECT regulation_id, license_rule FROM regulation_registry "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id::text, license_rule FROM canonical_controls "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
mapping = {row[0]: int(row[1]) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
return mapping
|
||||
|
||||
|
||||
def _headers(api_key: str = "") -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
h["api-key"] = api_key
|
||||
return h
|
||||
|
||||
|
||||
def scroll_collection(qdrant: str, collection: str, uuid_field: str, api_key: str = "") -> Iterator[dict]:
|
||||
"""Yield (point_id, uuid_value, has_rule_already) tuples."""
|
||||
next_offset = None
|
||||
while True:
|
||||
body = {"limit": 1000, "with_payload": True, "with_vector": False}
|
||||
if next_offset is not None:
|
||||
body["offset"] = next_offset
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/scroll",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=60) as r:
|
||||
payload = json.loads(r.read())
|
||||
result = payload.get("result", {})
|
||||
for pt in result.get("points", []):
|
||||
pl = pt.get("payload", {}) or {}
|
||||
yield {
|
||||
"id": pt["id"],
|
||||
"uuid": pl.get(uuid_field),
|
||||
"has_rule": "license_rule" in pl,
|
||||
}
|
||||
next_offset = result.get("next_page_offset")
|
||||
if next_offset is None:
|
||||
break
|
||||
|
||||
|
||||
def set_payload_batch(qdrant: str, collection: str, point_ids: list, rule: int, api_key: str = "") -> int:
|
||||
"""POST set_payload for a batch of point IDs with a single license_rule."""
|
||||
body = {
|
||||
"payload": {"license_rule": rule},
|
||||
"points": point_ids,
|
||||
}
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/payload?wait=true",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=120) as r:
|
||||
resp = json.loads(r.read())
|
||||
if resp.get("status") != "ok":
|
||||
raise RuntimeError(f"set_payload failed: {resp}")
|
||||
return len(point_ids)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
print("Loading canonical_controls → license_rule mapping…")
|
||||
rule_by_uuid = fetch_rule_by_uuid(args)
|
||||
print(f" Postgres returned {len(rule_by_uuid)} classified controls")
|
||||
|
||||
print(f"Scrolling Qdrant collection {args.collection!r}…")
|
||||
by_rule: dict[int, list] = {1: [], 2: [], 3: []}
|
||||
points_total = 0
|
||||
points_with_uuid = 0
|
||||
points_already_set = 0
|
||||
points_no_match = 0
|
||||
|
||||
for pt in scroll_collection(args.qdrant, args.collection, args.uuid_field, args.qdrant_api_key):
|
||||
points_total += 1
|
||||
uuid = pt["uuid"]
|
||||
if not uuid:
|
||||
continue
|
||||
points_with_uuid += 1
|
||||
if pt["has_rule"]:
|
||||
points_already_set += 1
|
||||
continue
|
||||
rule = rule_by_uuid.get(uuid)
|
||||
if rule is None:
|
||||
points_no_match += 1
|
||||
continue
|
||||
if rule not in by_rule:
|
||||
continue
|
||||
by_rule[rule].append(pt["id"])
|
||||
|
||||
print(f" total points scanned: {points_total}")
|
||||
print(f" with {args.uuid_field}: {points_with_uuid}")
|
||||
print(f" already had license_rule: {points_already_set}")
|
||||
print(f" uuid not found in Postgres: {points_no_match}")
|
||||
print(f" to set per rule: rule1={len(by_rule[1])} rule2={len(by_rule[2])} rule3={len(by_rule[3])}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\nDRY-RUN: no writes performed. Use --apply to execute.")
|
||||
return 0
|
||||
|
||||
total_written = 0
|
||||
for rule, ids in by_rule.items():
|
||||
if not ids:
|
||||
continue
|
||||
print(f"\nWriting license_rule={rule} to {len(ids)} points (batch {args.batch_size})…")
|
||||
for i in range(0, len(ids), args.batch_size):
|
||||
chunk = ids[i:i + args.batch_size]
|
||||
n = set_payload_batch(args.qdrant, args.collection, chunk, rule, args.qdrant_api_key)
|
||||
total_written += n
|
||||
print(f" batch {i // args.batch_size + 1}: {n} points (cumulative {total_written})")
|
||||
time.sleep(0.05)
|
||||
print(f"\nWrote license_rule on {total_written} Qdrant points in {args.collection}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -34,6 +34,27 @@ export default function ImpressumPage() {
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Quellen und Lizenzen der Compliance-Inhalte</h2>
|
||||
<p>
|
||||
Die BreakPilot Compliance-Plattform stuetzt sich auf rund 315.000 klassifizierte
|
||||
Controls aus oeffentlichen Quellen: EU-Recht (EUR-Lex), deutsches und oesterreichisches
|
||||
Bundesrecht, US Federal Code (OSHA, NIST), Behoerden-Leitfaeden (ENISA, EDPB, BAuA),
|
||||
freie Sicherheits-Frameworks unter CC-BY-SA (OWASP-Familie, OECD AI Principles) und
|
||||
eigene Texte. Jeder Control traegt eine deterministische Lizenzregel (R1 woertlich, R2
|
||||
mit Attribution, R3 nur Identifier-Verweis), die das Render-Verhalten in Berichten,
|
||||
PDF-Exports und Frontend steuert. Die vollstaendige Quellenliste mit Aufschluesselung
|
||||
pro Lizenzklasse ist im SDK unter <code className="text-white/80">/sdk/licenses</code>
|
||||
eingesehen. Pflicht-Attributionen fuer R2-Quellen erscheinen automatisch im
|
||||
Quellen-Footer jedes generierten Berichts.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Hinweis: Dieser Pauschalvermerk ersetzt nicht die werknahe Attribution. Jede
|
||||
Berichts- oder Frontend-Ausgabe nennt die konkret verwendeten Quellen direkt am
|
||||
Werk (Auto-Footer in PDFs, Inline-Citation im Frontend).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
|
||||
// Stärken / USP-Seite — sieben Verkaufsargumente aus der IACE-Strategie
|
||||
// (Memory: project_marketing_website_3014_themes.md). Aufgebaut als
|
||||
// Long-Form-Page mit Anker-Sprungmarken — eine Nummerierte Differenzierung
|
||||
// pro Sektion, damit Sales-Calls über tiefe Links arbeiten können.
|
||||
|
||||
const usps = [
|
||||
{
|
||||
id: 'engine',
|
||||
no: '1',
|
||||
title: 'Engine, nicht Checkliste',
|
||||
sub: 'Wir leiten Gefährdungen ab. Wettbewerb fragt aus einer Liste.',
|
||||
body:
|
||||
'Marktstandard (DesignSafe, Pilz, Sick) ist Excel-aufgewertete Checkliste: der Engineer wählt aus einer Hazard-Bibliothek aus. ' +
|
||||
'BreakPilot betreibt eine deterministische Pattern-Engine mit über 1.200 Hazard-Patterns. Aus der Maschinenbeschreibung leitet sie ' +
|
||||
'die Gefährdungen ab — keine Auswahllisten, keine vergessenen Punkte.',
|
||||
proof: 'Audit-Suite cmd/iace-audit erkennt eigene Lücken (Methode A–E)',
|
||||
},
|
||||
{
|
||||
id: 'multi-markt',
|
||||
no: '2',
|
||||
title: 'Eine Risikobeurteilung — alle Märkte',
|
||||
sub: 'CE + OSHA + ANSI + GB + JIS aus einem Datenmodell.',
|
||||
body:
|
||||
'Die gleiche Pattern-Engine generiert pro Maschinenbeschreibung mehrere Compliance-Anhänge. Hersteller wählt seine Zielmärkte. ' +
|
||||
'EU-Recht zitieren wir wörtlich (Rule 1). OWASP unter CC-BY-SA mit Pflicht-Attribution (Rule 2). DIN/EN nur per Identifier (Rule 3). ' +
|
||||
'Norm-Cross-Reference-Bibliothek mappt ISO 12100 ↔ DIN EN ISO 12100 ↔ ANSI B11.0 ↔ GB/T 15706 ↔ JIS B 9700.',
|
||||
proof: '252 Regulationen klassifiziert · 314.811 Controls audited',
|
||||
},
|
||||
{
|
||||
id: 'folgegefahren',
|
||||
no: '3',
|
||||
title: 'Vom Bediener bis zum Endkunden',
|
||||
sub: 'Folgegefahren-Modell mit Sekundärschadens-Kette.',
|
||||
body:
|
||||
'Klassische Risikobeurteilung schaut nur den Bediener an. Wir modellieren die Schadenskette weiter: Glasbruch in der Abfüllanlage ' +
|
||||
'verletzt nicht nur den Bediener, sondern erreicht über Restsplitter den Endkunden. BreakPilot verbindet CE-Sicherheit mit ' +
|
||||
'Produkthaftung nach ProdHaftG, Lebensmittelrecht nach VO 178/2002 und ISO 31000 Unternehmensrisiko in einem Datenmodell.',
|
||||
proof: 'SecondaryHarm-Modell live für consumer_safety, product_liability, food_safety, environmental, reputation, financial',
|
||||
},
|
||||
{
|
||||
id: 'public-domain',
|
||||
no: '4',
|
||||
title: 'Public Domain als Rechtsanker',
|
||||
sub: 'Werte aus OSHA, NIST, EUR-Lex, BAuA — auditfähig zitiert.',
|
||||
body:
|
||||
'Mindestabstände der Maschinensicherheit kommen bei uns aus OSHA 29 CFR 1910 Subpart O — US Federal Public Domain, lizenzrechtlich ' +
|
||||
'unbedenklich. Engineering-Rundung auf safe-side mm-Raster wird transparent dokumentiert. EU-Normen erscheinen nur als Identifier-Verweis ' +
|
||||
'mit einer menschlich kuratierten "Strenger/Gleich/Weicher"-Annotation — kein Copyright-Risiko.',
|
||||
proof: 'OSHA Table O-10 + §1910.217 PSDI-Formel verbatim · DIN nur Identifier · 6 DGUV-Publikationen referenziert',
|
||||
},
|
||||
{
|
||||
id: 'audit-suite',
|
||||
no: '5',
|
||||
title: 'Audit findet Lücken, die der Fachmann übersieht',
|
||||
sub: 'Fünf deterministische Audits ohne Ground Truth.',
|
||||
body:
|
||||
'Unsere Engine kennt ihre eigenen Lücken. Methode A bis E (Reachability, Consistency, Vocabulary, Echo, Hierarchy) finden Gaps ' +
|
||||
'ohne Fachmann-Vergleich. Bei einem Test fanden wir 100 strukturell unerreichbare Patterns und 46 unvollständige Component-Tags — ' +
|
||||
'Probleme, die ein menschlicher Auditor in einem Einzelfall nie gesehen hätte.',
|
||||
proof: 'cmd/iace-audit · 1.213 Patterns transparent · 99,94% Recall verifiziert',
|
||||
},
|
||||
{
|
||||
id: 'made-in-germany',
|
||||
no: '6',
|
||||
title: 'Made in Germany meets US Federal Public Domain',
|
||||
sub: 'Deutscher Maschinenbau, der gleichzeitig US-Compliance liefert.',
|
||||
body:
|
||||
'Deutscher Exportweltmeister-Maschinenbau braucht UL/NRTL-Zulassung für die USA. Die gleichen Daten, die wir für CE generieren, ' +
|
||||
'liefern dem US-Auditor 80 % der Vorarbeit. Risikobeurteilung in einer Sprache, Compliance in zwei Märkten — ohne Mehraufwand für den Hersteller.',
|
||||
proof: 'OSHA-Anker im RAG · NRTL-fähige Compliance-Spur · DesignSafe-Marktstandard wird hier erweitert, nicht imitiert',
|
||||
},
|
||||
{
|
||||
id: 'tooling',
|
||||
no: '7',
|
||||
title: 'LLM-Gap-Review als Co-Pilot, nicht als Roboter-Anwalt',
|
||||
sub: 'Pattern-Engine als Audit-Spur, LLM als Lücken-Suchhund.',
|
||||
body:
|
||||
'Die deterministische Engine bleibt die auditfähige Quelle der Wahrheit. Ein nachgelagerter LLM-Gap-Review (Qwen / Claude) prüft, ' +
|
||||
'was die Engine übersehen hat — mit klarer Quellen-Provenance (R3 LLM-Review) und Adopt/Reject-UX. Halluzinationen können nicht in ' +
|
||||
'die finale Risikobeurteilung schlüpfen.',
|
||||
proof: 'POST /projects/:id/llm-gap-review · Konfidenz-Stufen · Fallback auf statische Checkliste',
|
||||
},
|
||||
] as const
|
||||
|
||||
const competitors = [
|
||||
{ feature: 'Pattern-Engine statt Checkliste', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'Multi-Markt CE / US / CN / JP', bp: '✓', ds: 'nur US', pilz: 'nur EU', sick: 'nur EU', sphera: 'enterprise' },
|
||||
{ feature: 'Folgegefahren-Modell', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: 'Process' },
|
||||
{ feature: 'Audit-Suite (Engine-Lücken-Erkennung)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'OSHA-Anker (Public Domain Werte)', bp: '✓', ds: '✓', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'LLM-Gap-Review (Co-Pilot)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
]
|
||||
|
||||
export default function StaerkenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="bg-enterprise-dark text-white pt-32 pb-24">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<header className="mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4">Was uns differenziert</h1>
|
||||
<p className="text-white/60 text-lg max-w-3xl">
|
||||
Sieben konkrete Punkte, die BreakPilot von DesignSafe, Pilz, Sick, TÜV-Tools und Sphera trennen.
|
||||
Jede Differenzierung ist im Produkt umgesetzt — kein Marketing-Versprechen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="space-y-12">
|
||||
{usps.map((u) => (
|
||||
<li id={u.id} key={u.id} className="border-l-2 border-accent-electric pl-6">
|
||||
<div className="flex items-baseline gap-3 mb-2">
|
||||
<span className="text-accent-electric font-mono text-3xl font-bold">#{u.no}</span>
|
||||
<h2 className="text-2xl font-semibold">{u.title}</h2>
|
||||
</div>
|
||||
<p className="text-accent-electric/80 text-sm mb-3">{u.sub}</p>
|
||||
<p className="text-white/70 leading-relaxed mb-3">{u.body}</p>
|
||||
<p className="text-white/40 text-xs">
|
||||
<span className="text-white/60">Belegt durch:</span> {u.proof}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<section className="mt-20">
|
||||
<h2 className="text-3xl font-bold mb-4">Direktvergleich</h2>
|
||||
<p className="text-white/60 mb-6 max-w-3xl">
|
||||
Stand 2026. Marktangaben basieren auf öffentlicher Produktinformation der genannten Anbieter.
|
||||
</p>
|
||||
<div className="overflow-x-auto border border-white/10 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.04] border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium">Feature</th>
|
||||
<th className="text-left p-3 font-medium text-accent-electric">BreakPilot</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">DesignSafe</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Pilz PASS</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sick SD</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sphera</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{competitors.map((c) => (
|
||||
<tr key={c.feature} className="border-t border-white/[0.06]">
|
||||
<td className="p-3 text-white/80">{c.feature}</td>
|
||||
<td className="p-3 text-accent-electric font-medium">{c.bp}</td>
|
||||
<td className="p-3 text-white/50">{c.ds}</td>
|
||||
<td className="p-3 text-white/50">{c.pilz}</td>
|
||||
<td className="p-3 text-white/50">{c.sick}</td>
|
||||
<td className="p-3 text-white/50">{c.sphera}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-20 border-t border-white/10 pt-12">
|
||||
<h2 className="text-2xl font-bold mb-3">Quellen & Lizenz-Architektur</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Die Plattform stützt sich auf öffentliche Quellen: EU-Recht (EUR-Lex), Bundesrecht (BetrSichV, ArbSchG),
|
||||
US Federal Code (OSHA, NIST), Behörden-Leitfäden (ENISA, EDPB, BAuA), freie Sicherheits-Frameworks unter
|
||||
CC-BY-SA (OWASP). Jeder Inhalt trägt eine deterministische Lizenzregel R1/R2/R3 und löst die
|
||||
entsprechende Attribution im Ausgabe-PDF und im Frontend automatisch aus. Vollständige Quellenliste
|
||||
im SDK unter <code className="bg-white/[0.06] px-1.5 py-0.5 rounded">/sdk/licenses</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatFAB />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Navbar links — route-based navigation
|
||||
export const navLinks = [
|
||||
{ href: '/plattform', labelDe: 'Plattform', labelEn: 'Platform' },
|
||||
{ href: '/staerken', labelDe: 'Stärken', labelEn: 'Differentiators' },
|
||||
{ href: '/ce-prozess', labelDe: 'CE-Prozess', labelEn: 'CE Process' },
|
||||
{ href: '/product-compliance', labelDe: 'Product Compliance', labelEn: 'Product Compliance' },
|
||||
{ href: '/architektur', labelDe: 'Architektur', labelEn: 'Architecture' },
|
||||
|
||||
@@ -137,23 +137,43 @@ const PILLARS_EN = [
|
||||
export function PrintRegulatoryPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const pillars = de ? PILLARS_DE : PILLARS_EN
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
// Alternate tints — pillars 1 & 3 violet, 2 & 4 amber for visual rhythm.
|
||||
const tints = [
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
]
|
||||
return (
|
||||
<Page kicker="19" section={de ? 'ANHANG · REGULATORISCHE DETAILS' : 'APPENDIX · REGULATORY DETAILS'} title={de ? 'Vier Säulen der EU-Compliance für Maschinenbauer.' : 'Four pillars of EU compliance for manufacturers.'} subtitle={de ? 'Jede Säule deckt 4–6 verbindliche Regelwerke ab. BreakPilot mappt diese auf 25.000+ atomare Controls.' : 'Each pillar covers 4–6 binding regulations. BreakPilot maps these to 25,000+ atomic controls.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{pillars.map((p, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.indigo600}`, padding: '4mm 5mm', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{de ? `Säule ${String(i + 1).padStart(2, '0')}` : `Pillar ${String(i + 1).padStart(2, '0')}`}</span>
|
||||
<span style={{ fontSize: '6.5pt', color: COLORS.slate400 }}>{de ? '4-6 Regelwerke' : '4-6 regulations'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '3mm', lineHeight: 1.2 }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5, flex: 1, marginBottom: '3mm' }}>{p.d}</div>
|
||||
<div style={{ borderTop: `1px solid ${COLORS.slate100}`, paddingTop: '2mm', fontSize: '7pt', color: COLORS.slate500, fontFamily: 'monospace' }}>
|
||||
{p.laws.join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Architectural row: 4 pillars side-by-side */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', flex: 1, minHeight: 0, alignItems: 'stretch' }}>
|
||||
{pillars.map((p, i) => {
|
||||
const c = tints[i]
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* CAPITAL (top) */}
|
||||
<div style={{ background: `linear-gradient(180deg, ${c.mid} 0%, ${c.dark} 100%)`, padding: '3mm 3mm 3.5mm', borderTopLeftRadius: '2pt', borderTopRightRadius: '2pt', margin: '0 -1.5mm', boxShadow: `0 2mm 2mm -1.5mm ${c.dark}55`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', textTransform: 'uppercase', letterSpacing: '0.22em', opacity: 0.85 }}>{de ? 'SÄULE' : 'PILLAR'}</div>
|
||||
<div style={{ fontSize: '20pt', fontWeight: 800, color: '#ffffff', lineHeight: 1, marginTop: '1mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{String(i + 1).padStart(2, '0')}</div>
|
||||
</div>
|
||||
{/* SHAFT (middle) */}
|
||||
<div style={{ background: c.light, borderLeft: `2mm solid ${c.mid}`, borderRight: `1px solid ${c.border}`, borderTop: `1px solid ${c.border}`, borderBottom: `1px solid ${c.border}`, padding: '3mm 3mm 3mm 2.5mm', flex: 1, display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontSize: '11.5pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '2.5mm', lineHeight: 1.2, letterSpacing: '-0.005em' }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.55, flex: 1 }}>{p.d}</div>
|
||||
</div>
|
||||
{/* BASE (bottom) */}
|
||||
<div style={{ background: c.dark, margin: '0 -1.5mm', padding: '2mm 3mm', borderBottomLeftRadius: '2pt', borderBottomRightRadius: '2pt', fontFamily: MONO, fontSize: '6pt', color: '#ffffff', opacity: 0.95, lineHeight: 1.5, textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', boxShadow: `0 -1mm 1.5mm -1mm ${c.dark}66 inset` }}>{p.laws.join(' · ')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Shared ground line — architectural reference */}
|
||||
<div style={{ marginTop: '2mm', height: '1px', background: COLORS.violet300, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '0.6mm', height: '1px', background: COLORS.violet200, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function PrintCompetitionPage2({ lang, pageNum, totalPages, versionName }
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 2 / 2, APPSEC' : 'COMPETITION · 2 / 2, APPSEC'} title={de ? 'Application Security: BreakPilot deckt SAST + DAST + SCA + Pentesting in einer Plattform ab.' : 'Application Security: BreakPilot covers SAST + DAST + SCA + pentesting in one platform.'} subtitle={de ? 'Acht etablierte AppSec-Anbieter, keiner kombiniert SAST + DAST + Auto-Fix + Self-Hosted für KMU.' : 'Eight established AppSec vendors, none combines SAST + DAST + auto-fix + self-hosted for SMEs.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 2 / 2, APPSEC' : 'COMPETITION · 2 / 2, APPSEC'} title={de ? 'Cyber-Security: BreakPilot ersetzt das ganze AppSec-Stack.' : 'Cyber Security: BreakPilot replaces the entire AppSec stack.'} subtitle={de ? 'Acht etablierte Code-Security-Anbieter, jeder mit einer Disziplin. BreakPilot vereint sie auf einer EU-souveränen Plattform, zum Bruchteil der Kosten.' : 'Eight established code-security vendors, each with one discipline. BreakPilot combines them on one EU-sovereign platform, at a fraction of the cost.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Competitor profile table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '7.5pt', fontVariantNumeric: 'tabular-nums', marginBottom: '4mm' }}>
|
||||
|
||||
@@ -159,10 +159,40 @@ export function ArchitectureDiagram({
|
||||
const productLayerBg = `linear-gradient(135deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`
|
||||
const proxyLayerBg = `linear-gradient(135deg, ${COLORS.amber50} 0%, #fffaf0 60%, ${COLORS.amber50} 100%)`
|
||||
|
||||
/**
|
||||
* Faked-3D layer wrapper: shadow on the bottom edge (heavier than top), a 1px
|
||||
* top highlight, a 1px darker bottom seam, and a stagger indent on the right
|
||||
* to suggest the stack tilts slightly away from the viewer. This renders
|
||||
* crisply in Chromium's print-to-PDF, unlike `transform: rotateX(...)` which
|
||||
* has print-pipeline quirks.
|
||||
*/
|
||||
const layerWrap = (indentRight: string, shadowTint: string, glowTop: string, seamBottom: string): React.CSSProperties => ({
|
||||
marginRight: indentRight,
|
||||
boxShadow: `inset 0 1px 0 ${glowTop}, inset 0 -1px 0 ${seamBottom}, 0 5mm 7mm -4mm ${shadowTint}`,
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
})
|
||||
|
||||
/** Connector that reads as "the upper plane resting on the next" — a soft chevron with a shadow. */
|
||||
const PlaneConnector = ({ color }: { color: string }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '5mm', position: 'relative' }}>
|
||||
<svg viewBox="0 0 40 20" width="14mm" height="5mm" style={{ overflow: 'visible' }}>
|
||||
<polygon points="4,2 36,2 30,16 10,16" fill={color} opacity="0.18" />
|
||||
<polyline points="10,4 20,14 30,4" fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2mm' }}>
|
||||
{/* APPLICATION (PRODUCT) LAYER */}
|
||||
<div style={{ background: productLayerBg, border: `1px solid ${COLORS.violet200}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{/* APPLICATION (PRODUCT) LAYER — top plane, smallest indent footprint */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('0mm', 'rgba(59,26,122,0.20)', 'rgba(255,255,255,0.85)', COLORS.violet200),
|
||||
}}>
|
||||
<LayerChip n="01" label={de ? 'Application Layer' : 'Application Layer'} sub={de ? 'Kundenseitige Services' : 'User-facing services'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{product.map((p, i) => (
|
||||
@@ -171,20 +201,16 @@ export function ArchitectureDiagram({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* compact connector strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm', padding: '0 5mm', height: '5mm', alignItems: 'center' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 12 16" width="3mm" height="5mm">
|
||||
<line x1="6" y1="0" x2="6" y2="12" stroke={COLORS.violet500} strokeWidth="1.2" />
|
||||
<polyline points="3,11 6,15 9,11" fill="none" stroke={COLORS.violet500} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlaneConnector color={COLORS.violet500} />
|
||||
|
||||
{/* GATEWAY LAYER — compact: title row + features in 1 row */}
|
||||
<div style={{ background: proxyLayerBg, border: `1.5px solid ${COLORS.amber600}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* GATEWAY LAYER — middle plane, slight indent, slightly heavier shadow */}
|
||||
<div style={{
|
||||
background: proxyLayerBg,
|
||||
border: `1.5px solid ${COLORS.amber600}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('2mm', 'rgba(180,83,9,0.22)', 'rgba(255,255,255,0.85)', '#e9b56a'),
|
||||
}}>
|
||||
<LayerChip n="02" label={de ? 'Gateway Layer' : 'Gateway Layer'} sub={de ? 'Routing & Guardrails' : 'Routing & guardrails'} tint={COLORS.amber700} />
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4mm', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, letterSpacing: '-0.005em' }}>{proxy.title}</span>
|
||||
@@ -197,20 +223,16 @@ export function ArchitectureDiagram({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* compact connector strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm', padding: '0 5mm', height: '5mm', alignItems: 'center' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 12 16" width="3mm" height="5mm">
|
||||
<line x1="6" y1="0" x2="6" y2="12" stroke={COLORS.amber600} strokeWidth="1.2" />
|
||||
<polyline points="3,11 6,15 9,11" fill="none" stroke={COLORS.amber600} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlaneConnector color={COLORS.amber600} />
|
||||
|
||||
{/* INFRASTRUCTURE (INFERENCE) LAYER */}
|
||||
<div style={{ background: productLayerBg, border: `1px solid ${COLORS.violet200}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* INFRASTRUCTURE (INFERENCE) LAYER — foundation, deepest indent + shadow */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet300}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3.5mm 4mm',
|
||||
...layerWrap('4mm', 'rgba(59,26,122,0.28)', 'rgba(255,255,255,0.85)', COLORS.violet300),
|
||||
}}>
|
||||
<LayerChip n="03" label={de ? 'Infrastructure Layer' : 'Infrastructure Layer'} sub={de ? 'Compute & Daten · lokal · air-gap-fähig' : 'Compute & data · local · air-gap capable'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{inference.map((p, i) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Language, PitchCompany, PitchFunding, PitchMarket } from '@/lib/types'
|
||||
import { Page, KpiRow, TwoCol, ThreeCol, FourCol, Panel, Bullets, Callout, COLORS, Divider } from './PrintLayout'
|
||||
import { Page, KpiRow, TwoCol, ThreeCol, FourCol, Panel, Bullets, Callout, COLORS, Divider, ComplAI } from './PrintLayout'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SlideBase { lang: Language; pageNum: number; totalPages: number; versi
|
||||
export function PrintCoverPage({ company, funding, lang, versionName }: { company: PitchCompany; funding: PitchFunding; lang: Language; versionName: string }) {
|
||||
const de = lang === 'de'
|
||||
const instrument = funding?.instrument || 'Pre-Seed'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const tagline = de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')
|
||||
const amountLabel = amount >= 1_000_000
|
||||
? '€' + (amount / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
@@ -128,7 +128,7 @@ export function PrintExecSummaryPage1({ market, lang, pageNum, totalPages, versi
|
||||
const fmt = (v?: number) => v ? (v >= 1e9 ? `${(v / 1e9).toFixed(1).replace('.', ',')} Mrd.` : `${(v / 1e6).toFixed(0)} Mio.`) : '—'
|
||||
|
||||
return (
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY' : 'EXECUTIVE SUMMARY'} title={de ? 'BreakPilot COMPL/AI/' : 'BreakPilot COMPL/AI/'} subtitle={de ? 'DSGVO-konforme KI-Plattform, kontinuierliches Sicherheitsscanning und intelligente Compliance-Automatisierung. 25.000+ atomare Prüfaspekte, 380+ Regularien, EU-souverän gehostet.' : 'GDPR-compliant AI platform, continuous security scanning and intelligent compliance automation. 25,000+ atomic audit aspects, 380+ regulations, EU-sovereign hosted.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY' : 'EXECUTIVE SUMMARY'} title={<>BreakPilot <ComplAI /></>} subtitle={de ? 'DSGVO-konforme KI-Plattform, kontinuierliches Sicherheitsscanning und intelligente Compliance-Automatisierung. 25.000+ atomare Prüfaspekte, 380+ Regularien, EU-souverän gehostet.' : 'GDPR-compliant AI platform, continuous security scanning and intelligent compliance automation. 25,000+ atomic audit aspects, 380+ regulations, EU-sovereign hosted.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<KpiRow items={[
|
||||
{ n: '25k+', label: de ? 'Prüfaspekte' : 'Audit aspects', tone: 'accent' },
|
||||
|
||||
@@ -58,13 +58,29 @@ export const COLORS = {
|
||||
const FONT = "'Inter', 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif"
|
||||
const MONO_FONT = "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace"
|
||||
|
||||
/**
|
||||
* Brand wordmark: `Compl` rendered in the inherited text color, `AI` in violet.
|
||||
* No slashes, no separators — matches the BreakPilot/ComplAI brand guideline.
|
||||
* Use this anywhere the product name appears in body or display text.
|
||||
*
|
||||
* Use the optional `prefix` to prepend "BreakPilot " in the same style.
|
||||
*/
|
||||
export function ComplAI({ prefix = false }: { prefix?: boolean } = {}) {
|
||||
return (
|
||||
<>
|
||||
{prefix && <>BreakPilot </>}
|
||||
Compl<span style={{ color: COLORS.violet600 }}>AI</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PAGE WRAPPER ===== */
|
||||
|
||||
interface PageProps {
|
||||
kicker: string // "03"
|
||||
section: string // "DAS PROBLEM"
|
||||
title: string // "Deutsche Unternehmen wollen KI ..."
|
||||
subtitle?: string
|
||||
title: React.ReactNode // string or JSX (e.g. <ComplAI prefix /> usage)
|
||||
subtitle?: React.ReactNode
|
||||
pageNum: number
|
||||
totalPages: number
|
||||
versionName: string
|
||||
@@ -236,12 +252,23 @@ export function Bullets({ items, dense, tone = 'neutral' }: BulletsProps) {
|
||||
: tone === 'negative' ? COLORS.red600
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate500
|
||||
/**
|
||||
* Em-dash marker uses display:flex to keep the rule vertically centered on
|
||||
* the first line of text — previously the `top: 4pt` absolute positioning
|
||||
* drifted relative to font size (looked off-center on the rendered PDF).
|
||||
* The marker now sits in a fixed-width column at the line height of the text.
|
||||
*/
|
||||
const fontSize = dense ? '8.5pt' : '9pt'
|
||||
const lineH = 1.5
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{items.map((item, i) => (
|
||||
<li key={i} style={{ position: 'relative', paddingLeft: '5mm', marginBottom: dense ? '1.5mm' : '2.5mm', fontSize: dense ? '8.5pt' : '9pt', color: COLORS.slate700, lineHeight: 1.5 }}>
|
||||
<span style={{ position: 'absolute', left: 0, top: dense ? '4pt' : '4.5pt', width: '3mm', height: '0.5pt', background: dotColor }} />
|
||||
{item}
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm', marginBottom: dense ? '1.5mm' : '2.5mm', fontSize, color: COLORS.slate700, lineHeight: lineH }}>
|
||||
{/* The dash sits on the first line: line-height pad above + 0.5pt rule */}
|
||||
<span style={{ flexShrink: 0, width: '3mm', display: 'inline-flex', alignItems: 'center', height: `${parseFloat(fontSize) * lineH}pt` }}>
|
||||
<span style={{ width: '100%', height: '0.5pt', background: dotColor, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Language, PitchMarket, PitchTeamMember, PitchMilestone, PitchFunding } from '@/lib/types'
|
||||
import { Page, Callout, COLORS, DataTable, StatLine } from './PrintLayout'
|
||||
import { MarketFunnel, ComparisonBars, DonutChart } from './PrintCharts'
|
||||
import { Page, Callout, COLORS, StatLine } from './PrintLayout'
|
||||
import { ComparisonBars, DonutChart } from './PrintCharts'
|
||||
import {
|
||||
Briefcase, RefreshCw, Handshake, Scale, Lightbulb,
|
||||
Code, TrendingUp, CreditCard, ShieldCheck, Cpu,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
@@ -19,42 +23,72 @@ export function PrintMarketPage({ market, lang, pageNum, totalPages, versionName
|
||||
const sam = market.find(m => m.market_segment === 'SAM')
|
||||
const som = market.find(m => m.market_segment === 'SOM')
|
||||
|
||||
return (
|
||||
<Page kicker="09" section={de ? 'MARKT' : 'MARKET'} title={de ? 'Compliance & Code-Security für produzierende Unternehmen.' : 'Compliance & code security for manufacturing companies.'} subtitle={de ? 'Validierter Markt: Top-10 Compliance-Anbieter erwirtschaften >$1,1 Mrd. ARR. Kein Anbieter bedient den Maschinenbau spezifisch.' : 'Validated market: top-10 compliance vendors generate >$1.1B ARR. No vendor specifically serves manufacturing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote={de ? 'Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista' : 'Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista'}>
|
||||
// Fallbacks if data missing
|
||||
const tamValue = tam?.value_eur ?? 340_000_000_000
|
||||
const samValue = sam?.value_eur ?? 48_000_000_000
|
||||
const somValue = som?.value_eur ?? 2_100_000_000
|
||||
const cards = [
|
||||
{ key: 'TAM', value: fmtEur(tamValue, de), growth: tam?.growth_rate_pct ?? 14, accent: 'violet' as const,
|
||||
desc: de ? 'Globaler Compliance- und GRC-Markt, alle Branchen, alle Größen.' : 'Global compliance and GRC market, all industries, all sizes.' },
|
||||
{ key: 'SAM', value: fmtEur(samValue, de), growth: sam?.growth_rate_pct ?? 18, accent: 'violet-soft' as const,
|
||||
desc: de ? 'DACH + EU: regulierte Branchen, KMU und Enterprise.' : 'DACH + EU: regulated industries, SMB and enterprise.' },
|
||||
{ key: 'SOM', value: fmtEur(somValue, de), growth: som?.growth_rate_pct ?? 25, accent: 'amber' as const, core: true,
|
||||
desc: de ? 'Anlagen- und Maschinenbau DACH, unser Kernsegment.' : 'Machine and plant manufacturing DACH, our core segment.' },
|
||||
]
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
<div>
|
||||
// SVG nested-circles geometry — viewBox unitless, render up to ~130mm wide
|
||||
const CX = 65, CY = 65, R_TAM = 60, R_SAM = 36, R_SOM = 14
|
||||
|
||||
return (
|
||||
<Page kicker="09" section={de ? 'MARKT' : 'MARKET'} title={de ? 'Compliance & Code-Security für produzierende Unternehmen.' : 'Compliance & code security for manufacturing companies.'} subtitle={de ? 'Validierter Markt: Top-10 Compliance-Anbieter erwirtschaften >$1,1 Mrd. ARR. Kein Anbieter bedient den Maschinenbau spezifisch.' : 'Validated market: top-10 compliance vendors generate >$1.1B ARR. No vendor specifically serves manufacturing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote="Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista">
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.05fr', gap: '10mm', flex: 1, minHeight: 0 }}>
|
||||
{/* LEFT: nested-circles diagram */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Marktdimensionierung' : 'Market sizing'}</div>
|
||||
{tam && sam && som && (
|
||||
<MarketFunnel
|
||||
tam={{ value: tam.value_eur, label: de ? 'Total Addressable' : 'Total Addressable', growth: tam.growth_rate_pct ?? 14, note: de ? 'Globaler Compliance- und GRC-Markt (alle Branchen, alle Größen).' : 'Global compliance and GRC market (all industries, all sizes).' }}
|
||||
sam={{ value: sam.value_eur, label: de ? 'Serviceable Addressable' : 'Serviceable Addressable', growth: sam.growth_rate_pct ?? 18, note: de ? 'DACH + EU: regulierte Branchen, KMU und Enterprise.' : 'DACH + EU: regulated industries, SMB and enterprise.' }}
|
||||
som={{ value: som.value_eur, label: de ? 'Kernmarkt 5 Jahre' : 'Core market 5 yrs', growth: som.growth_rate_pct ?? 25, note: de ? 'Anlagen- und Maschinenbau DACH, unser Kernsegment.' : 'Machine and plant manufacturing DACH, our core segment.' }}
|
||||
fmt={(v) => fmtEur(v, de)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 130 130" style={{ width: '100%', maxWidth: '130mm', height: 'auto', display: 'block' }} aria-hidden>
|
||||
<circle cx={CX} cy={CY} r={R_TAM} fill={COLORS.violet50} stroke={COLORS.violet400} strokeWidth="0.6" />
|
||||
<circle cx={CX} cy={CY} r={R_SAM} fill={COLORS.violet100} stroke={COLORS.violet500} strokeWidth="0.7" />
|
||||
<circle cx={CX} cy={CY} r={R_SOM} fill={COLORS.amber50} stroke={COLORS.amber600} strokeWidth="0.9" />
|
||||
<text x={CX} y={CY - R_TAM + 7} textAnchor="middle" fontSize="3.2" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet700}>TAM</text>
|
||||
<text x={CX} y={CY - R_TAM + 11.5} textAnchor="middle" fontSize="3.6" fontWeight={700} fill={COLORS.slate700}>{fmtEur(tamValue, de)}</text>
|
||||
<text x={CX} y={CY - R_SAM + 6} textAnchor="middle" fontSize="3" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet800}>SAM</text>
|
||||
<text x={CX} y={CY - R_SAM + 10} textAnchor="middle" fontSize="3.4" fontWeight={700} fill={COLORS.slate800}>{fmtEur(samValue, de)}</text>
|
||||
<text x={CX} y={CY - 1.2} textAnchor="middle" fontSize="2.8" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.amber700}>SOM</text>
|
||||
<text x={CX} y={CY + 3} textAnchor="middle" fontSize="3.4" fontWeight={800} fill={COLORS.slate900}>{fmtEur(somValue, de)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ marginTop: '3mm', fontSize: '7.5pt', color: COLORS.slate500, lineHeight: 1.4, textAlign: 'center' }}>
|
||||
{de ? 'Verschachtelte Marktanteile (TAM ⊃ SAM ⊃ SOM)' : 'Nested market shares (TAM ⊃ SAM ⊃ SOM)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Kernsegment: Maschinen- und Anlagenbau DACH' : 'Core segment: Machine & plant manufacturing DACH'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Kennzahl' : 'Metric', width: '50%' },
|
||||
{ header: de ? 'Wert' : 'Value', numeric: true },
|
||||
]}
|
||||
rows={[
|
||||
[de ? 'Unternehmen DACH' : 'Companies DACH', '~6.500'],
|
||||
[de ? 'Davon 10–500 MA (Zielgröße)' : 'Of which 10–500 emp. (target)', '~4.200'],
|
||||
[de ? 'Beschäftigte gesamt' : 'Total employees', '~1,3 Mio.'],
|
||||
[de ? 'Umsatz Branche p.a.' : 'Industry revenue p.a.', de ? '~€280 Mrd.' : '~€280B'],
|
||||
[de ? 'Compliance-Budget Ø' : 'Avg. compliance budget', de ? '€50–150k / Jahr' : '€50–150k / yr'],
|
||||
[de ? 'Validierte ARR Top-10' : 'Validated ARR top-10', '>$1,1 Mrd.'],
|
||||
]}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
{/* RIGHT: stacked info cards + callout */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: '3.5mm' }}>
|
||||
{cards.map((c) => {
|
||||
const isAmber = c.accent === 'amber'
|
||||
const isSoft = c.accent === 'violet-soft'
|
||||
const stroke = isAmber ? COLORS.amber600 : isSoft ? COLORS.violet500 : COLORS.violet700
|
||||
const bg = isAmber ? COLORS.amber50 : isSoft ? COLORS.violet50 : 'transparent'
|
||||
const kickerColor = isAmber ? COLORS.amber700 : COLORS.violet700
|
||||
const valueColor = isAmber ? COLORS.amber700 : COLORS.slate900
|
||||
return (
|
||||
<div key={c.key} style={{ borderLeft: `3px solid ${stroke}`, background: bg, padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '3mm' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.18em', color: kickerColor, textTransform: 'uppercase' }}>{c.key}</span>
|
||||
{c.core && (<span style={{ fontSize: '7pt', fontWeight: 700, color: COLORS.amber700, background: '#fff', border: `1px solid ${COLORS.amber600}`, padding: '0.5mm 1.5mm', letterSpacing: '0.06em', textTransform: 'uppercase' }}>{de ? '← unser Kernmarkt' : '← our core market'}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginTop: '1mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: valueColor, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{c.value}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>+{c.growth}% CAGR</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.4 }}>{c.desc}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: '5mm' }}>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Callout tone="accent" label={de ? 'Warum Maschinenbau zuerst' : 'Why manufacturing first'}>
|
||||
{de
|
||||
? 'Höchste Regulierungsdichte (DSGVO + AI Act + CRA + Maschinen-VO + ProdSG + LkSG) bei gleichzeitig kleinem Compliance-Team. Klare Schmerzpunkte. Bekannte Vertriebskanäle (VDMA, IHK, Messen).'
|
||||
@@ -152,11 +186,43 @@ export function PrintMilestonesPage({ milestones, lang, pageNum, totalPages, ver
|
||||
|
||||
/* ===== TEAM ===== */
|
||||
|
||||
// Tuple shape: [LucideIcon, de_label, en_label]
|
||||
type TeamBullet = [typeof Briefcase, string, string]
|
||||
|
||||
const TEAM_INFO: Array<{ tagline: [string, string]; bullets: TeamBullet[] }> = [
|
||||
{
|
||||
tagline: [
|
||||
'Diplom-Ökonom mit 20+ Jahren Industrie- und Digitalisierungs-Erfahrung.',
|
||||
'Business economist with 20+ years in industry and digital transformation.',
|
||||
],
|
||||
bullets: [
|
||||
[Briefcase, '20+ Jahre Industrie, Strategie & Digitalisierung', '20+ yrs industry, strategy & digital transformation'],
|
||||
[RefreshCw, 'Aufbau IoT-, Blockchain- & KI-Plattformen', 'Built IoT, blockchain & AI platforms'],
|
||||
[Handshake, 'M&A: 4 Übernahmen & Beteiligungen geführt', 'M&A: led 4 acquisitions & investments'],
|
||||
[Scale, 'Regulatorik: DSGVO, MiCAR, CRA, Data Act', 'Regulatory: GDPR, MiCAR, CRA, Data Act'],
|
||||
[Lightbulb, '12 erteilte Patente (Erfinder/Miterfinder)', '12 granted patents (inventor / co-inventor)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
tagline: [
|
||||
'Engineering Leader mit 15+ Jahren in Fintech, Web3 und Enterprise-KI.',
|
||||
'Engineering leader with 15+ years across fintech, Web3 and enterprise AI.',
|
||||
],
|
||||
bullets: [
|
||||
[Code, '15+ Jahre Engineering Leadership — Fintech, Web3, KI', '15+ yrs engineering leadership — fintech, Web3, AI'],
|
||||
[TrendingUp, 'Engineering-Org skaliert: 6 → 60 in 18 Monaten', 'Scaled engineering org: 6 → 60 in 18 months'],
|
||||
[CreditCard, 'ETOPay SaaS-Payment-Infrastruktur entwickelt', 'Built ETOPay SaaS payment infrastructure'],
|
||||
[ShieldCheck, 'MiCA-Compliance-Strategie ViviSwap', 'MiCA compliance strategy for ViviSwap'],
|
||||
[Cpu, 'Embedded Rust (Cortex-M) + Full-Stack TypeScript', 'Embedded Rust (Cortex-M) + full-stack TypeScript'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }: SlideBase & { team: PitchTeamMember[] }) {
|
||||
const de = lang === 'de'
|
||||
const members = team && team.length ? team : [
|
||||
{ id: 1, name: 'Benjamin Bönisch', role_de: 'CEO & Co-Founder', role_en: 'CEO & Co-Founder', bio_de: 'Mehrfacher Gründer mit Fokus auf B2B-SaaS und Vertrieb. Ehemals Geschäftsführer und Vertriebsleiter. Tiefe Verankerung im Maschinenbau-Netzwerk DACH.', bio_en: 'Serial founder focused on B2B SaaS and sales. Former CEO and VP Sales. Deep network in DACH manufacturing.', equity_pct: 37.3, expertise: ['B2B Sales', 'Go-to-Market', 'Manufacturing', 'Operations'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 2, name: 'Sharang Parnerkar', role_de: 'CTO & Co-Founder', role_en: 'CTO & Co-Founder', bio_de: 'Ex-Anthropic, Ex-Google. Distributed systems, KI-Infrastruktur, RAG-Pipelines. Open-Source-Contributor. Hat die gesamte Plattform-Architektur entworfen und 500K+ LoC implementiert.', bio_en: 'Ex-Anthropic, ex-Google. Distributed systems, AI infrastructure, RAG pipelines. Open-source contributor. Designed the entire platform architecture and implemented 500K+ LoC.', equity_pct: 37.3, expertise: ['AI Infrastructure', 'Distributed Systems', 'RAG', 'Go/Python/TypeScript'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 1, name: 'Benjamin Bönisch', role_de: 'CEO & Co-Founder', role_en: 'CEO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['B2B Sales', 'Go-to-Market', 'Manufacturing', 'Operations'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 2, name: 'Sharang Parnerkar', role_de: 'CTO & Co-Founder', role_en: 'CTO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['AI Infrastructure', 'Distributed Systems', 'RAG', 'Go/Python/TypeScript'], linkedin_url: '', photo_url: '' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -166,6 +232,10 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
|
||||
{[0, 1].map(idx => {
|
||||
const m = members[idx]
|
||||
if (!m) return null
|
||||
const info = TEAM_INFO[idx]
|
||||
// Icon tile palette: violet for first founder, amber for second
|
||||
const tileBg = idx === 0 ? COLORS.violet50 : COLORS.amber50
|
||||
const tileColor = idx === 0 ? COLORS.violet700 : COLORS.amber700
|
||||
return (
|
||||
<div key={idx} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.indigo600}`, padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', gap: '5mm', alignItems: 'flex-start', marginBottom: '4mm' }}>
|
||||
@@ -190,7 +260,20 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
|
||||
<div style={{ fontSize: '10pt', fontWeight: 600, color: COLORS.indigo600 }}>{de ? m.role_de : m.role_en}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.55, flex: 1 }}>{de ? m.bio_de : m.bio_en}</div>
|
||||
{info && (
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, fontWeight: 600, lineHeight: 1.4, marginBottom: '3mm' }}>{info.tagline[de ? 0 : 1]}</div>
|
||||
)}
|
||||
{/* Bulleted skill list with icons */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{info?.bullets.map(([IconComp, deLabel, enLabel], bi) => (
|
||||
<div key={bi} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', flexShrink: 0, background: tileBg, color: tileColor, display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<IconComp size={13} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.4, paddingTop: '1mm' }}>{de ? deLabel : enLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '4mm', paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}` }}>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Expertise' : 'Expertise'}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.5mm' }}>
|
||||
@@ -238,7 +321,7 @@ function formatFunding(amount: number): string {
|
||||
|
||||
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const instrument = funding?.instrument || (de ? 'Wandeldarlehen' : 'Convertible Loan')
|
||||
const isConvertible = (instrument || '').toLowerCase().includes('wandeldarlehen') ||
|
||||
(instrument || '').toLowerCase().includes('convertible') ||
|
||||
|
||||
@@ -113,22 +113,58 @@ function fmtEur(n: number): string {
|
||||
return `${sign}€${Math.round(abs)}`
|
||||
}
|
||||
|
||||
/* Small inline SVG sparkline. Renders an empty-state em-dash if all values are zero. */
|
||||
function Sparkline({ values, width = 56, height = 22, stroke = COLORS.violet600 }: { values: number[]; width?: number; height?: number; stroke?: string }) {
|
||||
const allZero = values.every(v => v === 0)
|
||||
if (allZero || values.length < 2) {
|
||||
return <span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate300, fontWeight: 700, lineHeight: 1 }}>—</span>
|
||||
}
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min || 1
|
||||
const stepX = width / (values.length - 1)
|
||||
const padY = 2
|
||||
const innerH = height - padY * 2
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * stepX
|
||||
const y = padY + innerH - ((v - min) / range) * innerH
|
||||
return { x, y }
|
||||
})
|
||||
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
const last = points[points.length - 1]
|
||||
// Area fill polygon for soft tone under the line
|
||||
const area = `${path} L${last.x.toFixed(2)},${height} L0,${height} Z`
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||
<path d={area} fill={stroke} fillOpacity={0.08} />
|
||||
<path d={path} fill="none" stroke={stroke} strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={last.x} cy={last.y} r={1.6} fill={stroke} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintKPIHeroPage({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const kpis = computeAnnualKPIs(fmResults)
|
||||
const k26 = kpis.find(k => k.year === 2026)
|
||||
const k30 = kpis.find(k => k.year === 2030)
|
||||
const breakEvenYear = kpis.find(k => k.ebit > 0)?.year
|
||||
const series = (pick: (k: typeof kpis[number]) => number): number[] =>
|
||||
[2026, 2027, 2028, 2029, 2030].map(y => {
|
||||
const row = kpis.find(k => k.year === y)
|
||||
return row ? pick(row) : 0
|
||||
})
|
||||
|
||||
const tiles = k26 && k30 ? [
|
||||
{ label: 'ARR', start: fmtEur(k26.arr), end: fmtEur(k30.arr), endColor: COLORS.violet600 },
|
||||
{ label: de ? 'Kunden' : 'Customers', start: k26.customers.toLocaleString('de-DE'), end: k30.customers.toLocaleString('de-DE'), endColor: COLORS.violet600 },
|
||||
{ label: de ? 'ARPU / Mo' : 'ARPU / mo', start: fmtEur(k26.arpu), end: fmtEur(k30.arpu), endColor: COLORS.slate900 },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', start: String(k26.employees), end: String(k30.employees), endColor: COLORS.slate900 },
|
||||
{ label: de ? 'Bruttomarge' : 'Gross margin', start: `${k26.grossMargin}%`, end: `${k30.grossMargin}%`, endColor: COLORS.emerald700 },
|
||||
{ label: 'EBIT', start: fmtEur(k26.ebit), end: fmtEur(k30.ebit), endColor: k30.ebit >= 0 ? COLORS.emerald700 : COLORS.red700 },
|
||||
{ label: de ? 'Netto-Ergebnis' : 'Net income', start: fmtEur(k26.netIncome), end: fmtEur(k30.netIncome), endColor: k30.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700 },
|
||||
{ label: de ? 'Cash (Dez)' : 'Cash (Dec)', start: fmtEur(k26.cashBalance), end: fmtEur(k30.cashBalance), endColor: COLORS.emerald700 },
|
||||
type Tile = { label: string; start: string; end: string; endColor: string; series: number[]; hideStart?: boolean; stroke?: string }
|
||||
const tiles: Tile[] = k26 && k30 ? [
|
||||
{ label: 'ARR', start: fmtEur(k26.arr), end: fmtEur(k30.arr), endColor: COLORS.violet600, series: series(k => k.arr), hideStart: k26.arr === 0 },
|
||||
{ label: de ? 'Kunden' : 'Customers', start: k26.customers.toLocaleString('de-DE'), end: k30.customers.toLocaleString('de-DE'), endColor: COLORS.violet600, series: series(k => k.customers), hideStart: k26.customers === 0 },
|
||||
{ label: de ? 'ARPU / Mo' : 'ARPU / mo', start: fmtEur(k26.arpu), end: fmtEur(k30.arpu), endColor: COLORS.slate900, series: series(k => k.arpu), hideStart: k26.arpu === 0 },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', start: String(k26.employees), end: String(k30.employees), endColor: COLORS.slate900, series: series(k => k.employees), hideStart: k26.employees === 0 },
|
||||
{ label: de ? 'Bruttomarge' : 'Gross margin', start: `${k26.grossMargin}%`, end: `${k30.grossMargin}%`, endColor: COLORS.emerald700, series: series(k => k.grossMargin), hideStart: k26.grossMargin === 0, stroke: COLORS.emerald600 },
|
||||
{ label: 'EBIT', start: fmtEur(k26.ebit), end: fmtEur(k30.ebit), endColor: k30.ebit >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.ebit), hideStart: k26.ebit === 0, stroke: k30.ebit >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Netto-Ergebnis' : 'Net income', start: fmtEur(k26.netIncome), end: fmtEur(k30.netIncome), endColor: k30.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.netIncome), hideStart: k26.netIncome === 0, stroke: k30.netIncome >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Cash (Dez)' : 'Cash (Dec)', start: fmtEur(k26.cashBalance), end: fmtEur(k30.cashBalance), endColor: COLORS.emerald700, series: series(k => k.cashBalance), hideStart: k26.cashBalance === 0, stroke: COLORS.emerald600 },
|
||||
] : []
|
||||
|
||||
return (
|
||||
@@ -140,13 +176,22 @@ export function PrintKPIHeroPage({ fmResults, lang, pageNum, totalPages, version
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{tiles.map((t, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.violet600}`, background: '#ffffff', padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '4mm' }}>{t.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: 'auto' }}>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 600, color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{t.start}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate400, fontWeight: 700 }}>→</span>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '3.5mm' }}>{t.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: '3.5mm' }}>
|
||||
{!t.hideStart && (
|
||||
<>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 600, color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{t.start}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate400, fontWeight: 700 }}>→</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ fontSize: '24pt', fontWeight: 800, color: t.endColor, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.025em' }}>{t.end}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: '3mm', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate100}`, fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>2026 · 2030</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
<Sparkline values={t.series} stroke={t.stroke ?? COLORS.violet600} />
|
||||
<div style={{ paddingTop: '1.5mm', borderTop: `1px solid ${COLORS.slate100}`, fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>2026</span><span>2030</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,42 +208,67 @@ export function PrintTechStackPage({ lang, pageNum, totalPages, versionName }: S
|
||||
const de = lang === 'de'
|
||||
|
||||
const cats = de ? [
|
||||
{ name: 'Frontend', icon: ScanLine, items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, items: ['PostgreSQL 16', 'MongoDB', 'Qdrant Vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'KI / RAG', icon: Brain, items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code-Scanning', icon: Shield, items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Kommunikation', icon: MessageSquare, items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing Oberflächen', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & Business-Logik', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistenter Zustand', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant Vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'KI / RAG', icon: Brain, blurb: 'Inferenz & Retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code-Scanning', icon: Shield, blurb: 'Schwachstellen-Erkennung', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identität & Rechte', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Kommunikation', icon: MessageSquare, blurb: 'Echtzeit-Kanäle', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & Ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
] : [
|
||||
{ name: 'Frontend', icon: ScanLine, items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, items: ['PostgreSQL 16', 'MongoDB', 'Qdrant vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'AI / RAG', icon: Brain, items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code scanning', icon: Shield, items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Communication', icon: MessageSquare, items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing surfaces', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & business logic', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistent state', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'AI / RAG', icon: Brain, blurb: 'Inference & retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code scanning', icon: Shield, blurb: 'Vulnerability detection', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identity & permissions', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Communication', icon: MessageSquare, blurb: 'Real-time channels', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="26" section={de ? 'ANHANG · TECH-STACK' : 'APPENDIX · TECH STACK'} title={de ? '8 Kategorien. Polyglott. 100 % Open Source.' : '8 categories. Polyglot. 100 % open source.'} subtitle={de ? 'Alle Komponenten mit kommerziell nutzbarer Lizenz (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). Keine GPL/AGPL. Keine US-SaaS-Abhängigkeit.' : 'All components carry a commercially usable license (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). No GPL/AGPL. No US SaaS dependency.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="26" section={de ? 'ANHANG · TECH-STACK' : 'APPENDIX · TECH STACK'} title={de ? '8 Kategorien. Polyglott. 100% Open Source.' : '8 categories. Polyglot. 100% open source.'} subtitle={de ? 'Alle Komponenten mit kommerziell nutzbarer Lizenz (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). Keine GPL/AGPL. Keine US-SaaS-Abhängigkeit.' : 'All components carry a commercially usable license (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). No GPL/AGPL. No US SaaS dependency.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '4mm', flex: 1, minHeight: 0 }}>
|
||||
{cats.map((c, i) => {
|
||||
const Icon = c.icon
|
||||
const num = String(i + 1).padStart(2, '0')
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.violet600}`, background: '#ffffff', padding: '4mm 5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '3mm', marginBottom: '3mm' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.violet50, borderRadius: '2pt', display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<Icon size={15} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div style={{ fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15 }}>{c.name}</div>
|
||||
<div key={i} style={{
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
background: `linear-gradient(160deg, ${COLORS.violet50} 0%, #ffffff 55%)`,
|
||||
padding: '4mm 4mm 3.5mm',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(124,58,237,0.08)',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '14mm', height: '14mm', borderRadius: '3pt',
|
||||
background: `linear-gradient(135deg, ${COLORS.violet400} 0%, ${COLORS.violet600} 100%)`,
|
||||
boxShadow: '0 4px 10px rgba(124,58,237,0.28)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#ffffff', flexShrink: 0, marginBottom: '2.5mm',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<Icon size={24} strokeWidth={1.6} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '6.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.22em', marginBottom: '0.5mm' }}>{num}</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1.1, letterSpacing: '-0.005em', textAlign: 'center' }}>{c.name}</div>
|
||||
<div style={{ fontSize: '7pt', fontStyle: 'italic', color: COLORS.slate500, lineHeight: 1.3, textAlign: 'center', marginTop: '0.8mm', marginBottom: '2.5mm' }}>{c.blurb}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.2mm', justifyContent: 'center', marginTop: 'auto' }}>
|
||||
{c.items.map((it, j) => (
|
||||
<div key={j} style={{ fontFamily: MONO, fontSize: '8pt', color: COLORS.slate700, padding: '1.2mm 0', borderTop: j > 0 ? `1px solid ${COLORS.slate100}` : 'none', lineHeight: 1.3 }}>{it}</div>
|
||||
<span key={j} style={{
|
||||
fontFamily: MONO, fontSize: '7.5pt', fontWeight: 600,
|
||||
color: COLORS.slate700,
|
||||
background: COLORS.violet50,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '999px',
|
||||
padding: '0.8mm 2mm',
|
||||
lineHeight: 1.15,
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>{it}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Language, PitchProduct } from '@/lib/types'
|
||||
import { Page, Bullets, Callout, COLORS, DataTable } from './PrintLayout'
|
||||
import { Page, Bullets, Callout, COLORS } from './PrintLayout'
|
||||
import { LoopDiagram } from './PrintDiagrams'
|
||||
import { getDetails } from '@/components/slides/USPSlide.data'
|
||||
import {
|
||||
ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck,
|
||||
AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare,
|
||||
Shield, Layers, Globe, FileSearch, Sparkles, Repeat, ArrowLeftRight, Infinity,
|
||||
Lock, Heart, Banknote, ShoppingCart, Wifi, BookOpen, Landmark, Building2,
|
||||
Factory, Cpu, CheckCircle2, Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -105,15 +107,16 @@ export function PrintUSPPage2({ lang, pageNum, totalPages, versionName }: SlideB
|
||||
|
||||
/* ===== REGULATORY LANDSCAPE ===== */
|
||||
|
||||
const CATEGORY_ICONS: LucideIcon[] = [Lock, Shield, Brain, Globe, ShieldCheck, Banknote, Heart, Users]
|
||||
const RL_CATEGORIES_DE = [
|
||||
{ name: 'Datenschutz', sample: 'DSGVO · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
{ name: 'Cybersicherheit', sample: 'NIS2 · IT-SiG · BSIG · KRITIS-Verordnung', count: 47 },
|
||||
{ name: 'Cybersicherheit', sample: 'NIS2 · IT-SiG · BSIG · KRITIS-VO', count: 47 },
|
||||
{ name: 'KI-Regulierung', sample: 'AI Act · KI-Haftungsrichtlinie', count: 18 },
|
||||
{ name: 'Digitale Märkte', sample: 'DMA · DSA · Data Act · Data Governance Act', count: 24 },
|
||||
{ name: 'Produktsicherheit', sample: 'CRA · Maschinenverordnung · Produktsicherheitsgesetz', count: 41 },
|
||||
{ name: 'Digitale Märkte', sample: 'DMA · DSA · Data Act · DGA', count: 24 },
|
||||
{ name: 'Produktsicherheit', sample: 'CRA · MaschinenVO · ProdSG', count: 41 },
|
||||
{ name: 'Finanzregulierung', sample: 'DORA · MiCA · FinmadiG · KWG', count: 53 },
|
||||
{ name: 'Gesundheitsdaten', sample: 'MDR · IVDR · PatDG · Krankenhausgesetz', count: 28 },
|
||||
{ name: 'Verbraucherschutz', sample: 'UWG · BGB · Geschäftsgeheimnisschutz · HinSchG', count: 36 },
|
||||
{ name: 'Gesundheitsdaten', sample: 'MDR · IVDR · PatDG · KHG', count: 28 },
|
||||
{ name: 'Verbraucherschutz', sample: 'UWG · BGB · GeschGehG · HinSchG', count: 36 },
|
||||
]
|
||||
const RL_CATEGORIES_EN = [
|
||||
{ name: 'Data Privacy', sample: 'GDPR · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
@@ -125,6 +128,7 @@ const RL_CATEGORIES_EN = [
|
||||
{ name: 'Health Data', sample: 'MDR · IVDR · PatDG · Hospital Act', count: 28 },
|
||||
{ name: 'Consumer Prot.', sample: 'UWG · BGB · Trade Secrets · HinSchG', count: 36 },
|
||||
]
|
||||
const INDUSTRY_ICONS: LucideIcon[] = [Building2, Factory, Heart, Banknote, ShoppingCart, Cpu, Wifi, Brain, ShieldCheck, BookOpen, Landmark]
|
||||
const INDUSTRIES_DE = ['Alle Unternehmen', 'Maschinenbau', 'Gesundheit', 'Finanzsektor', 'E-Commerce', 'Technologie', 'IoT / Hardware', 'KI-Anbieter', 'Krit. Infrastruktur', 'Medien', 'Öffentl. Sektor']
|
||||
const INDUSTRIES_EN = ['All companies', 'Manufacturing', 'Healthcare', 'Finance', 'E-Commerce', 'Technology', 'IoT / Hardware', 'AI Providers', 'Critical Infra.', 'Media', 'Public Sector']
|
||||
|
||||
@@ -151,32 +155,46 @@ export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versio
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '6mm' }}>
|
||||
{/* Categories */}
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.55fr 1fr', gap: '6mm' }}>
|
||||
{/* Categories — 2x4 card grid with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Acht Regulierungs-Kategorien' : 'Eight regulatory categories'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Kategorie' : 'Category', width: '32%' },
|
||||
{ header: de ? 'Beispiele' : 'Examples' },
|
||||
{ header: '#', width: '12%', numeric: true },
|
||||
]}
|
||||
rows={cats.map(c => [c.name, c.sample, c.count])}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{cats.map((c, i) => {
|
||||
const CIcon = CATEGORY_ICONS[i]
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2mm 2.5mm', display: 'flex', alignItems: 'flex-start', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><CIcon size={14} strokeWidth={1.6} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '2mm' }}>
|
||||
<div style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2 }}>{c.name}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7pt', fontWeight: 700, color: COLORS.violet600, fontVariantNumeric: 'tabular-nums' }}>{c.count}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '6.5pt', color: COLORS.slate500, lineHeight: 1.3, marginTop: '0.5mm' }}>{c.sample}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industries */}
|
||||
{/* Industries — cards with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Zehn Branchen-Profile' : 'Ten industry profiles'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{industries.map((ind, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2.5mm 3mm', fontSize: '8.5pt', color: COLORS.slate800, fontWeight: i === 1 ? 700 : 500 }}>
|
||||
{i === 1 && <span style={{ fontSize: '6.5pt', color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, display: 'block', marginBottom: '1mm' }}>{de ? 'Kernfokus' : 'Core focus'}</span>}
|
||||
{ind}
|
||||
</div>
|
||||
))}
|
||||
{industries.map((ind, i) => {
|
||||
const IIcon = INDUSTRY_ICONS[i]
|
||||
const isFocus = i === 1
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${isFocus ? COLORS.violet300 : COLORS.slate200}`, background: isFocus ? COLORS.violet50 : '#ffffff', padding: '2mm 2.5mm', display: 'flex', alignItems: 'center', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '6mm', height: '6mm', display: 'flex', alignItems: 'center', justifyContent: 'center', color: isFocus ? COLORS.violet700 : COLORS.slate500, flexShrink: 0 }}><IIcon size={12} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{isFocus && <div style={{ fontSize: '6pt', color: COLORS.violet700, textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, lineHeight: 1 }}>{de ? 'Kernfokus' : 'Core focus'}</div>}
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate800, fontWeight: isFocus ? 700 : 500, lineHeight: 1.2, marginTop: isFocus ? '0.5mm' : 0 }}>{ind}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,10 +212,7 @@ export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versio
|
||||
|
||||
/* ===== PRODUCT / MODULAR TOOLKIT ===== */
|
||||
|
||||
const MODULE_ICONS: LucideIcon[] = [
|
||||
ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck,
|
||||
AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare,
|
||||
]
|
||||
const MODULE_ICONS: LucideIcon[] = [ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck, AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare]
|
||||
const MODULES_FULL_DE = [
|
||||
{ name: 'Code Security', desc: 'SAST · DAST · SBOM · Container · Secrets · Pentesting', features: ['Bei jedem Push', 'Auto-Fix LLM', 'CI/CD-integriert'] },
|
||||
{ name: 'CE-SW-Risikobeurteilung', desc: 'CE-Kennzeichnung für Maschinen mit Software-Anteil', features: ['Maschinen-VO', 'CRA-konform', 'Code-Basis-Analyse'] },
|
||||
@@ -294,48 +309,64 @@ export function PrintHowItWorksPage({ lang, pageNum, totalPages, versionName }:
|
||||
return (
|
||||
<Page kicker="08" section={de ? 'SO FUNKTIONIERT\'S' : 'HOW IT WORKS'} title={de ? 'In 4 Schritten zur kontinuierlichen Compliance.' : 'Continuous compliance in 4 steps.'} subtitle={de ? 'Vom Vertrag bis zur Audit-Bereitschaft in der Regel <30 Tage. Kein Excel, kein Pentest-Vendor, keine manuelle Dokumentenpflege.' : 'From contract to audit-ready typically in <30 days. No Excel, no pentest vendor, no manual document maintenance.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* 4-step rail: numbered violet circles on a horizontal connector line,
|
||||
title + body underneath each. Replaces the floating-arrow StepStrip. */}
|
||||
<div style={{ position: 'relative', marginTop: '4mm', flexShrink: 0 }}>
|
||||
{/* connector line (behind the circles) */}
|
||||
<div style={{ position: 'absolute', top: '7mm', left: '7mm', right: '7mm', height: '2px', background: `linear-gradient(90deg, ${COLORS.violet600} 0%, ${COLORS.violet400} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{/* 4-step rail: numbered violet circles on a horizontal connector line */}
|
||||
<div style={{ position: 'relative', marginTop: '3mm', flexShrink: 0 }}>
|
||||
<div style={{ position: 'absolute', top: '6mm', left: '6mm', right: '6mm', height: '2px', background: `linear-gradient(90deg, ${COLORS.violet600} 0%, ${COLORS.violet400} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm' }}>
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
{/* number circle on the rail */}
|
||||
<div style={{ width: '14mm', height: '14mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14pt', fontWeight: 800, letterSpacing: '-0.01em', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{s.n}</div>
|
||||
<div style={{ marginTop: '3mm', fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ marginTop: '2mm', fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5 }}>{s.d}</div>
|
||||
<div style={{ width: '12mm', height: '12mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12pt', fontWeight: 800, letterSpacing: '-0.01em', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{s.n}</div>
|
||||
<div style={{ marginTop: '2.5mm', fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.45 }}>{s.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fill space between steps and footer with a visual timeline */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div style={{ flexShrink: 0, marginBottom: '2mm' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '4mm' }}>
|
||||
{de ? 'Time-to-Value · Median 14 Tage · Worst Case 28 Tage' : 'Time-to-Value · Median 14 days · Worst case 28 days'}
|
||||
</div>
|
||||
{/* dotted timeline with 5 day markers */}
|
||||
<div style={{ position: 'relative', height: '14mm' }}>
|
||||
{/* the rail */}
|
||||
{/* Day-marker timeline — directly under steps, ~3mm gap */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<div style={{ position: 'relative', height: '13mm' }}>
|
||||
<div style={{ position: 'absolute', left: '7mm', right: '7mm', top: '5mm', height: '1.5px', background: `repeating-linear-gradient(90deg, ${COLORS.violet400} 0, ${COLORS.violet400} 2mm, transparent 2mm, transparent 4mm)`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', height: '100%' }}>
|
||||
{days.map((d, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
|
||||
{/* day marker pill */}
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.violet700, background: COLORS.violet50, padding: '1mm 3mm', borderRadius: '99pt', border: `1px solid ${COLORS.violet300}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{d}</div>
|
||||
{/* dot on rail */}
|
||||
<div style={{ width: '3mm', height: '3mm', borderRadius: '50%', background: COLORS.violet600, marginTop: '1mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{/* label below */}
|
||||
<div style={{ marginTop: '2mm', fontSize: '7.5pt', color: COLORS.slate700, textAlign: 'center', fontWeight: 600 }}>{labels[i]}</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '7pt', color: COLORS.slate700, textAlign: 'center', fontWeight: 600 }}>{labels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time-to-value callout */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<Callout tone="accent" label={de ? 'Typische Time-to-Value' : 'Typical time-to-value'}>
|
||||
{de
|
||||
? 'Median 14 Tage · Worst Case 28 Tage. Vom Vertrag bis zur Audit-Bereitschaft typischerweise unter 30 Tagen.'
|
||||
: 'Median 14 days · worst case 28 days. From contract to audit-readiness typically under 30 days.'}
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
{/* What you get on day N — 4-column benefit block, fills bottom third */}
|
||||
<div style={{ flex: 1, minHeight: 0, marginTop: '4mm', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>
|
||||
{de ? 'Was Sie wann bekommen' : 'What you get, when'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3mm' }}>
|
||||
{([
|
||||
[Shield, de ? 'Risikoanalysen automatisch ab Tag 3' : 'Risk analyses automatic from day 3'],
|
||||
[FileText, de ? 'VVT / TOMs / DSFA generiert ab Tag 14' : 'RoPA / TOMs / DPIA generated from day 14'],
|
||||
[CheckCircle2, de ? 'Audit-Trail vollständig ab Tag 30' : 'Audit trail complete from day 30'],
|
||||
[Zap, de ? 'Continuous Scanning bei jedem Push' : 'Continuous scanning on every push'],
|
||||
] as [LucideIcon, string][]).map(([Icon, t], i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.violet600}`, padding: '2.5mm 3mm', display: 'flex', alignItems: 'flex-start', gap: '2.5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><Icon size={13} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, fontSize: '8pt', color: COLORS.slate800, fontWeight: 600, lineHeight: 1.35 }}>{t}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -347,42 +378,9 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
target: de ? '< 25 Mitarbeiter · Basis-Module' : '< 25 employees · basic modules',
|
||||
price: '€3.600',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['DSGVO + Audit + DSR-Workflow', 'Compliance Scanner (CI/CD)', 'EU-Hosting · BSI C5', 'E-Mail-Support']
|
||||
: ['GDPR + Audit + DSR workflow', 'Compliance Scanner (CI/CD)', 'EU hosting · BSI C5', 'Email support'],
|
||||
tint: COLORS.violet400,
|
||||
bg: COLORS.violet50,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
target: de ? '25–250 Mitarbeiter · alle Module' : '25–250 employees · all modules',
|
||||
price: '€18.000',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['Alle 12 Module', 'Priority-Support · Onboarding-Call', 'CE-Software-Risiko + Tender Matching', 'Dedicated CSM · 14-tägige Reviews', 'Custom-Integrationen (Jira, GitLab)']
|
||||
: ['All 12 modules', 'Priority support · onboarding call', 'CE software risk + tender matching', 'Dedicated CSM · biweekly reviews', 'Custom integrations (Jira, GitLab)'],
|
||||
tint: COLORS.violet600,
|
||||
bg: `linear-gradient(180deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
target: de ? '250+ Mitarbeiter · maßgeschneidert' : '250+ employees · custom',
|
||||
price: de ? 'ab €50.000' : 'from €50k',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['Alles aus Professional', 'SLA · Custom Contract', 'On-Premise / Air-Gap (Mac Mini/Studio)', 'Dedicated Customer Engineering', 'Multi-Region Audit-Trail']
|
||||
: ['Everything in Professional', 'SLA · custom contract', 'On-premise / air-gap (Mac Mini/Studio)', 'Dedicated customer engineering', 'Multi-region audit trail'],
|
||||
tint: COLORS.amber600,
|
||||
bg: COLORS.amber50,
|
||||
featured: false,
|
||||
},
|
||||
{ name: 'Starter', target: de ? '< 25 Mitarbeiter · Basis-Module' : '< 25 employees · basic modules', price: '€3.600', unit: de ? '/ Jahr' : '/ year', features: de ? ['DSGVO + Audit + DSR-Workflow', 'Compliance Scanner (CI/CD)', 'EU-Hosting · BSI C5', 'E-Mail-Support'] : ['GDPR + Audit + DSR workflow', 'Compliance Scanner (CI/CD)', 'EU hosting · BSI C5', 'Email support'], tint: COLORS.violet400, bg: COLORS.violet50, featured: false },
|
||||
{ name: 'Professional', target: de ? '25–250 Mitarbeiter · alle Module' : '25–250 employees · all modules', price: '€18.000', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alle 12 Module', 'Priority-Support · Onboarding-Call', 'CE-Software-Risiko + Tender Matching', 'Dedicated CSM · 14-tägige Reviews', 'Custom-Integrationen (Jira, GitLab)'] : ['All 12 modules', 'Priority support · onboarding call', 'CE software risk + tender matching', 'Dedicated CSM · biweekly reviews', 'Custom integrations (Jira, GitLab)'], tint: COLORS.violet600, bg: `linear-gradient(180deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`, featured: true },
|
||||
{ name: 'Enterprise', target: de ? '250+ Mitarbeiter · maßgeschneidert' : '250+ employees · custom', price: de ? 'ab €50.000' : 'from €50k', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alles aus Professional', 'SLA · Custom Contract', 'On-Premise / Air-Gap (Mac Mini/Studio)', 'Dedicated Customer Engineering', 'Multi-Region Audit-Trail'] : ['Everything in Professional', 'SLA · custom contract', 'On-premise / air-gap (Mac Mini/Studio)', 'Dedicated customer engineering', 'Multi-region audit trail'], tint: COLORS.amber600, bg: COLORS.amber50, featured: false },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -391,18 +389,7 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
{/* 3 product cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm', marginBottom: '5mm', position: 'relative' }}>
|
||||
{tiers.map((t) => (
|
||||
<div key={t.name} style={{
|
||||
background: t.bg,
|
||||
border: `${t.featured ? '2px' : '1px'} solid ${t.tint}`,
|
||||
borderRadius: '6pt',
|
||||
padding: '5mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
boxShadow: t.featured ? `0 6px 18px ${COLORS.violet600}25` : 'none',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div key={t.name} style={{ background: t.bg, border: `${t.featured ? '2px' : '1px'} solid ${t.tint}`, borderRadius: '6pt', padding: '5mm', display: 'flex', flexDirection: 'column', position: 'relative', boxShadow: t.featured ? `0 6px 18px ${COLORS.violet600}25` : 'none', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* Featured badge */}
|
||||
{t.featured && (
|
||||
<div style={{ position: 'absolute', top: '-3.5mm', left: '50%', transform: 'translateX(-50%)', fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', background: COLORS.violet600, padding: '1mm 4mm', borderRadius: '99pt', textTransform: 'uppercase', letterSpacing: '0.18em', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{de ? 'Beliebt' : 'Popular'}</div>
|
||||
@@ -449,14 +436,37 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: COLORS.emerald50, border: `1px solid ${COLORS.emerald600}`, borderRadius: '4pt', padding: '4mm 5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>{de ? 'Netto-Effekt · KMU 50 MA / Jahr 1' : 'Net effect · SME 50 emp. / Y1'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm' }}>
|
||||
<div style={{ fontSize: '26pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>+€30k</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.emerald700, fontWeight: 600 }}>{de ? 'pro KMU / Jahr' : 'per SME / yr'}</div>
|
||||
<div style={{ background: COLORS.emerald50, border: `1px solid ${COLORS.emerald600}`, borderRadius: '4pt', padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '1.5mm' }}>{de ? 'Netto-Effekt · KMU 50 MA / Jahr 1' : 'Net effect · SME 50 emp. / Y1'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '2.5mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>+€30k</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.emerald700, fontWeight: 600 }}>{de ? 'pro KMU / Jahr' : 'per SME / yr'}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, marginTop: '2mm', lineHeight: 1.4 }}>
|
||||
{de ? 'Kunde spart €55k (Pentests, CE-Risiko, Compliance-Zeit), zahlt €25k. ROI ab Tag 1.' : 'Customer saves €55k (pentests, CE risk, compliance time), pays €25k. ROI from day 1.'}
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate700, marginTop: '1.5mm', lineHeight: 1.4 }}>
|
||||
{de ? 'Kunde spart €55k, zahlt €25k. ROI ab Tag 1.' : 'Customer saves €55k, pays €25k. ROI from day 1.'}
|
||||
</div>
|
||||
|
||||
{/* Itemized breakdown */}
|
||||
<div style={{ marginTop: '2.5mm', display: 'flex', flexDirection: 'column' }}>
|
||||
{([
|
||||
[de ? 'Pentests (kontinuierlich, inkl.)' : 'Pentests (continuous, incl.)', '+€13k'],
|
||||
[de ? 'CE-Risiko (Code-basiert, inkl.)' : 'CE risk (code-based, incl.)', '+€9k'],
|
||||
[de ? 'Compliance-Zeit (−60%)' : 'Compliance time (−60%)', '+€15k'],
|
||||
[de ? 'Audit-Vorbereitung (auto)' : 'Audit prep (auto)', '+€9k'],
|
||||
[de ? 'Legal-Stunden (−40%)' : 'Legal hours (−40%)', '+€5k'],
|
||||
[de ? 'Schulungen (Academy inkl.)' : 'Training (Academy incl.)', '+€4k'],
|
||||
] as [string, string][]).map(([l, v], i, arr) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '2mm', padding: '1.2mm 0', borderBottom: i < arr.length - 1 ? `0.5px solid ${COLORS.emerald600}33` : 'none' }}>
|
||||
<span style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.3 }}>{l}</span>
|
||||
<span style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.emerald700, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2mm', fontSize: '7pt', color: COLORS.slate600, fontStyle: 'italic', lineHeight: 1.35 }}>
|
||||
{de
|
||||
? 'Plus Vermeidung von Bußgeldern (bis 4% Jahresumsatz) und gewonnene RFQs.'
|
||||
: 'Plus avoided fines (up to 4% annual revenue) and won RFQs.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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