9 Commits

Author SHA1 Message Date
Benjamin Admin ec3b0e26fd Merge branch 'chore/license-mapping-audit' — license mapping + audit script + DGUV + /staerken marketing page
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 37s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 34s
2026-05-22 00:54:49 +02:00
Benjamin Admin 19d1a56df4 feat(marketing): /staerken page with 7 USPs from IACE strategy — Task #19
Long-form differentiator page covering the seven sales arguments from
project_marketing_website_3014_themes.md, all anchor-linkable for
sales decks:

  #1 engine          — Pattern-engine vs Excel-checklist
  #2 multi-markt     — One risk assessment, all markets (CE+US+CN+JP)
  #3 folgegefahren   — Operator-to-end-customer harm chain
  #4 public-domain   — OSHA/NIST/EUR-Lex/BAuA as legal anchor
  #5 audit-suite     — Engine self-introspection (cmd/iace-audit A-E)
  #6 made-in-germany — German export meets US Federal PD
  #7 tooling         — LLM gap-review as co-pilot, not robo-lawyer

Each section carries a "Belegt durch:" line pointing at the actual
codebase artifact behind the claim, so the page reads as audit-friendly
proof, not marketing fluff.

Below the 7 differentiators a competitor comparison table (BreakPilot
vs DesignSafe vs Pilz PASS vs Sick SD vs Sphera) and a closing block
explaining the R1/R2/R3 license architecture with a pointer to
/sdk/licenses.

Navbar updated to surface the page between Plattform and CE-Prozess.

This closes Task #19. With Task #29 + #7/#8 already in, the roadmap
post-licence-classification work is fully landed.
2026-05-22 00:36:09 +02:00
Benjamin Admin 3934bdf814 docs(impressum): add Quellen & Lizenzen section with /sdk/licenses ref
Adds a "Quellen und Lizenzen der Compliance-Inhalte" section to the
marketing-website Impressum naming the public sources the platform
draws on (EUR-Lex, US Federal Code, ENISA/EDPB/BAuA, OWASP, OECD,
eigene Texte) and pointing to /sdk/licenses for the full per-source
breakdown.

The Datenschutz and Impressum audit (Task #24 in breakpilot-compliance)
confirmed no spurious license claims were buried in these pages.
This change adds explicit transparency rather than removing anything,
and is paired with the explicit disclaimer that the Pauschalvermerk
does NOT replace work-level attribution — that is handled by the
auto-footer in PDFs and the <SourceBadge> in the SDK frontend.
2026-05-21 22:19:24 +02:00
Benjamin Admin dbd44ecc20 feat(licenses): postgres + qdrant license_rule backfill scripts
Two idempotent scripts that complete Task #22 (300k atomic_controls
reclassification) across both Postgres DBs and all Qdrant collections
on Mac Mini + Production.

backfill_license_rule.py
- iterative parent_control_uuid inheritance with cycle cap
- dry-run + apply modes, per-iteration row counts
- residual-orphan cluster report for manual review

backfill_qdrant_license_payload.py
- joins canonical_controls.id (or regulation_id) → license_rule
- scrolls + grouped set_payload per rule (3 batches per collection)
- supports both lookup tables (canonical_controls / regulation_registry)
- supports managed Qdrant via --qdrant-api-key (Production)

Backfill bilance:
- Mac Mini canonical_controls: 0 NULL (was 279,384) across 314,811 rows
- Mac Mini Qdrant atomic_controls_dedup: 44,987 points patched
- Mac Mini bp_compliance_gesetze: 37,634 points patched
- Mac Mini bp_compliance_datenschutz: 11,338 points patched
- Production canonical_controls: 0 NULL (was 259,914) across 294,027 rows
- Production Qdrant bp_compliance_gesetze: 55,836 patched
- Production Qdrant bp_compliance_datenschutz: 18,980 patched
- Production Qdrant bp_compliance_ce: 23,239 patched

Schema migration 002_regulation_registry.sql + 252 registry rows were
replicated to Production (was missing — only existed on Mac Mini).
20 BSI/DE-Gesetz entries added to registry to close Qdrant lookup gap.

100% deterministic classification achieved on both DBs via:
- parent_control_uuid inheritance (94% coverage)
- control_parent_links.source_regulation → regulation_registry
- source_citation->>'source' → regulation_registry
- canonical_processed_chunks ground truth (chunk-validated)
- ungrouped LLM-aggregate Vorfahren → own works (Rule 3)

[migration-approved]
2026-05-21 18:46:57 +02:00
Benjamin Admin 93687a32fe docs(licenses): freeze 3-rule license mapping + audit script
Defines the authoritative mapping from license_type to license_rule
in docs/LICENSE_RULES.md, and adds scripts/audit_license_classification.py
to surface classification gaps in registry/canonical_controls/Qdrant.

Key finding from first audit run against bp-core-postgres + Qdrant:

- regulation_registry: 232 rows, 224 rule=1, 8 rule=2, 0 rule=3;
  36 rows without license_type (need backfill)
- canonical_controls: 314,811 rows, 279,384 (89%) have NULL
  license_rule (target of Task #22 reclassification)
- Qdrant atomic_controls_dedup: 100% of sampled points lack both
  license and license_rule payload fields
- Qdrant bp_compliance_gesetze: 80.6% lack both fields
- Qdrant bp_compliance_ce + bp_compliance: nearly clean

Rule definitions clarified (was loosely remembered as
"law / cite / rewrite"):
- Rule 1 = verbatim, sovereign law (EU/DE/AT/CH/US, TRBS/TRGS/ASR,
  OSHA, NIST, EU guidelines, DGUV UVV)
- Rule 2 = verbatim with attribution (CC-BY, Apache, OWASP,
  OECD AI Principles, ENISA)
- Rule 3 = identifier citation only, no full text (DIN/EN/ISO,
  ANSI/UL/IEC, DGUV Regeln/Informationen/Grundsaetze, BSI,
  proprietary standards). Pipeline drops chunk_text when rule=3
  in pipeline_adapter.py:147.

The 4th category I had proposed ("R1-A") turned out to be already
implemented as rule=2; the mapping doc reflects the actual code
behaviour rather than the original 3-name verbal model.

No schema change. No data migration in this commit — reclassification
of the 279k controls is staged as Task #22 and will be cluster-based
by source/regulation_id.
2026-05-21 11:29:38 +02:00
Sharang Parnerkar 2d9fec3a6d feat(pitch-print): 10 slide redesigns from parallel agent review
Build pitch-deck / build-push-deploy (push) Successful in 1m53s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 34s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 36s
Per the user's batch review of the rendered PDF. Five subagents ran in parallel,
each owning a different slide file; this is the merged result.

Slide 10 — Regulatory Landscape (PrintProductSlides)
  8 regulatory categories now render as a 2×4 icon-tile grid (was a DataTable):
  Lock / Shield / Brain / Globe / ShieldCheck / Banknote / Heart / Users.
  10 industry profiles now each show an icon next to the name
  (Factory for Maschinenbau Kernfokus, plus Heart, Banknote, ShoppingCart, Cpu,
  Wifi, Brain, ShieldCheck, BookOpen, Landmark, Building2).

Slide 12 — How It Works (PrintProductSlides)
  Step rail and day timeline pulled together (was a big empty middle).
  Added a "Was Sie wann bekommen" 4-column benefit block in the bottom third
  (Shield/FileText/CheckCircle2/Zap), with mid-page "Median 14 Tage" callout.

Slide 13 — Market TAM / SAM / SOM (PrintMarketSlides)
  Dropped MarketFunnel primitive. Left column: SVG nested concentric circles
  (TAM r=60 violet, SAM r=36 violet, SOM r=14 amber as Kernmarkt). Right column:
  three stacked TAM/SAM/SOM info cards with mono kicker, big EUR value, growth
  rate, one-line description; SOM card carries amber accent + "← unser Kernmarkt".

Slide 14 — Pricing green box (PrintProductSlides)
  Net-effect callout expanded from 2 lines to a full breakdown:
  Pentests +€13k / CE-Risiko +€9k / Compliance-Zeit (−60%) +€15k /
  Audit-Vorber. (auto) +€9k / Legal-Stunden (−40%) +€5k / Schulungen +€4k.
  Italic footnote: "Plus Vermeidung von Bußgeldern und gewonnene RFQs."

Slide 17 — Competition AppSec title (PrintCompetitionSlides)
  Title rewritten to investor-friendly framing — "Cyber-Security: BreakPilot
  ersetzt das ganze AppSec-Stack" (was SAST + DAST + SCA + Pentesting).

Slide 18 — Team founder bios (PrintMarketSlides)
  Prose paragraphs replaced with 5 icon-bulleted skill/achievement lines per
  founder. Benjamin gets violet-50 tiles (Briefcase, RefreshCw, Handshake,
  Scale, Lightbulb). Sharang gets amber-50 tiles (Code, TrendingUp, CreditCard,
  ShieldCheck, Cpu). Photo + name + role + equity header preserved.

Slide 23 — KPIs trajectory (PrintNewSlides)
  Each of the 8 KPI tiles now has a 15mm × 8mm SVG sparkline at the bottom
  showing the 5-year progression. Stroke color adapts per metric (violet
  default, emerald for cash/margin, red→emerald for EBIT/net-income across
  break-even). All-zero series fall back to em-dash. Awkward "0 → 0" prefix
  suppressed on missing-data tiles.

Slide 28 — Regulatory Pillars (PrintAnnexSlides)
  Rebuilt as 4 actual vertical pillars (was 2×2 box grid). Each pillar has:
  capital (top, gradient tint, mono kicker + 01-04 number), shaft (white card
  with title + description + 2mm colored left border), base (bottom, darker
  tint, mono law citations). A shared horizontal "ground line" below all four
  pillars completes the architectural reference.

Slide 29 — Architecture 3D (PrintDiagrams)
  Faked 3D depth via staggered right indent (0/2/4mm), inset top highlight
  and bottom seam shadows, per-layer drop-shadow with rising opacity. Layer 03
  reads as the foundation; layer 01 floats on top. PlaneConnector chevrons
  replace the simple SVG down-arrows between tiers. Text stays horizontal.

Slide 31 — Tech Stack (PrintNewSlides)
  Cards now have 14mm violet-gradient icon tiles (was 8mm flat), mono kicker
  number, 12pt category name, italic one-line blurb, and the techs as rounded
  chip tags (violet-50 / violet-200, mono 7.5pt) instead of a flat mono list.
  Title cleaned: "100 % " → "100%".

All files under 500 LOC except PrintIntroSlides (515, preexisting issue).
TypeScript clean, next build green, all 38 routes compile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 18:11:58 +02:00
Sharang Parnerkar a6f4ca88a4 fix(pitch-print): ComplAI brand, em-dash centering, fund fallback 400k
Build pitch-deck / build-push-deploy (push) Successful in 2m47s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 43s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 35s
Three universal fixes before per-slide redesigns:

1. Brand: new <ComplAI /> JSX component renders the product name correctly —
   'Compl' in inherited text color, 'AI' in violet (#7c3aed), no slashes.
   Replaces the previous 'BreakPilot COMPL/AI/' literal in the Executive
   Summary p1 title. Page primitive's title prop now accepts ReactNode so
   JSX brand wordmarks work anywhere a title would.

2. Em-dash centering: Bullets primitive previously placed each em-dash
   marker via absolute positioning with a hardcoded 'top: 4pt', which drifted
   relative to font-size and looked off-center in the rendered PDF. Now uses
   display:flex on the <li> with a fixed-width column that vertically centers
   the 0.5pt rule on the first line height of the text.

3. Funding fallback: cover + The Ask now default to 400_000 (was 1_000_000)
   when no funding amount is in the data. New base case is a €400k
   Wandeldarlehen, not €1M equity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 17:58:55 +02:00
Benjamin Admin 297eff949e Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
Build pitch-deck / build-push-deploy (push) Successful in 2m3s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 40s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 35s
2026-05-20 16:24:00 +02:00
Benjamin Admin 01e2e0fc4b feat(pitch-deck): Finanzplan-Tooling + formel-getriebene Versionen Base/Bull/Bear
8 neue Skripte erweitern die Excel-Finanzpläne deutlich:
- add-kunden-formulas: Neukunden-Lookup + kumulativer Churn (SUMPRODUCT-basiert)
- add-price-formulas: jährliche Preiserhöhung Jan via Treiber
- add-inflation-formulas: Inflation auf Betriebskosten + Büromiete-Logik
- add-tantieme-and-explanations: Gründer-Tantieme 2028-2030 + Erläuterungen
  in Cohort-Analyse + Sensitivity-Sheets
- apply-bueromiete: 1000€/Monat ab Sep 2026 mit Inflation
- apply-number-formatting: Euro / Count / Percent per Label-Klassifikation
- cleanup-finanzplan-labels: 'kategorie — '-Präfix entfernt
- copy-extra-sheets: Charts/Cohort/Sensitivity/Hiring-Plan von Series-A
  auf 400k Base/Bull/Bear übertragen (inkl. 12 Chart-Objekten)

Neue Excel-Dateien (für L-Bank Wandeldarlehen 400k Pitch):
- Finanzplan-Wandeldarlehen-400k.xlsx (Base)
- Finanzplan-Wandeldarlehen-400k-Bull.xlsx
- Finanzplan-Wandeldarlehen-400k-Bear.xlsx
- Finanzplan-Series-A-Ambitioniert.xlsx (Series-A Variante)

Inhaltliche Anpassungen (400k Base/Bull/Bear):
- Channel-Provision Bechtle/Cancom → Channel-Partner Provision, Format Euro
- GuV: 'Steuerbares Einkommen' → 'Zu versteuerndes Einkommen (nach Verlustvortrag)',
  Formel um Zinserträge/-aufwand erweitert
- IT-Recht/Datenschutzjurist auf 100% (6666 € statt 3333 €)
- Series-A-Investor in WD-Sheet auf 0 € (nicht eingeplant in 400k Variante)
- Mitarbeiter +1 Monat verschoben (außer Gründer = Okt 2026)
- 3 Enterprise-Neukunden zusätzlich (Apr 2027, Jun 2027, Okt 2029)
- Marketing-Agentur Cut ~33% pro Szenario (Base 4%, Bull 5%, Bear 2%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:23:12 +02:00
33 changed files with 3249 additions and 235 deletions
+83
View File
@@ -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())
+21
View File
@@ -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>
+177
View File
@@ -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 AE)',
},
{
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 &amp; 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
View File
@@ -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 46 verbindliche Regelwerke ab. BreakPilot mappt diese auf 25.000+ atomare Controls.' : 'Each pillar covers 46 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 10500 MA (Zielgröße)' : 'Of which 10500 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 ? '€50150k / Jahr' : '€50150k / 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 }}>&mdash;</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 &middot; 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 ? '25250 Mitarbeiter · alle Module' : '25250 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 ? '25250 Mitarbeiter · alle Module' : '25250 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.
@@ -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())
+323
View File
@@ -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())
+275
View File
@@ -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())
+99
View File
@@ -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())
+225
View File
@@ -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())