feat(founding-wizard): Gründungs-Wizard für 2-Mann GmbH + 14 Notar-Templates
[migration-approved]
Templates (Migrations 123-136):
- 123 GO-GF (Geschäftsordnung Geschäftsführung)
- 124 SHA (Shareholders' Agreement, 56 Platzhalter)
- 125 Satzung (Articles of Association mit UG-Variante)
- 126 GF-Dienstvertrag (Trennungsprinzip Organ/Anstellung)
- 127 Arbeitsvertrag (AGG-neutral, NachwG, eAU)
- 128 Gesellschafterliste (§ 40 GmbHG)
- 129 GF-Bestellungsbeschluss (mit § 6 Abs. 2 Versicherung)
- 130 HRB-Anmeldung (§§ 7, 8, 39 GmbHG, § 12 HGB)
- 131 IP-Assignment Agreement (Gründer→GmbH)
- 132 Term Sheet (Pre-Seed/Seed VC-Standard)
- 133 Wandeldarlehensvertrag (Convertible Loan)
- 134 Beteiligungsvertrag (Subscription Agreement)
- 135 ESOP/VSOP-Plan (3 Varianten)
- 136 Cap Table
Kategorisierung (Migrations 137-138):
- ALTER TABLE compliance_legal_templates ADD lifecycle_stage TEXT[],
functional_category TEXT (mit CHECK Constraints + GIN-Index)
- Backfill aller 105 Templates: lifecycle_stage (pre_founding|founding|
startup|kmu|konzern) + functional_category (founding_legal|employment|
investor_funding|...)
Backend Founding-Wizard Service:
- template_renderer.py: Handlebars-light ({{VAR}}, {{#IF FLAG}}...{{/IF}})
- wizard_to_context.py: Mapping Wizard-State → SCREAMING_SNAKE_CASE Vars
- markdown_to_docx.py: Markdown → DOCX via python-docx
- founding_wizard_routes.py: POST /v1/founding-wizard/generate
→ liefert base64-DOCX-Files für ausgewählte Templates
Frontend Founding-Wizard (/sdk/founding-wizard):
- 8-Step Wizard (Basics, Gesellschafter, GF, Kapital, Notar, SHA, GF-Verträge, Generate)
- useFoundingWizardForm Hook mit localStorage-Persistenz
- TypeScript Code-Registry (template-categories.ts) als Backup zur DB
- Word-Download via data:URLs (base64)
Tests:
- 20 Unit-Tests grün (Renderer, Context-Mapping, DOCX-Conversion)
- Playwright E2E-Test mit 2-Mann GmbH (Benjamin + Sharang) Test-Daten
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Handlebars-light Template-Renderer fuer die compliance_legal_templates.
|
||||
|
||||
Unterstuetzte Syntax:
|
||||
- {{VARIABLE_NAME}} - einfache String-Substitution
|
||||
- {{#IF FLAG}}...{{/IF}} - bedingter Block (truthy)
|
||||
- {{#IF NOT FLAG}}...{{/IF}} - negierter bedingter Block
|
||||
|
||||
Bewusst minimal gehalten — keine Loops oder Verschachtelung tiefer Logik.
|
||||
Komplexere Sachen werden im Context vorberechnet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Pattern fuer {{#IF FLAG}}...{{/IF}} und {{#IF NOT FLAG}}...{{/IF}}
|
||||
# Greedy / non-overlapping. Inhalt darf alles enthalten ausser einem geschlossenen {{/IF}}.
|
||||
IF_BLOCK = re.compile(
|
||||
r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}(.*?)\{\{/IF\}\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
VAR_PATTERN = re.compile(r"\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}")
|
||||
|
||||
|
||||
def _is_truthy(val: Any) -> bool:
|
||||
"""Pythonische Truthiness, mit Special-Case: leeres dict/list/str = False."""
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, (int, float)):
|
||||
return val != 0
|
||||
if isinstance(val, str):
|
||||
return val.strip() != "" and val.lower() not in ("false", "0", "no", "nein")
|
||||
if isinstance(val, (list, dict, tuple, set)):
|
||||
return len(val) > 0
|
||||
return True
|
||||
|
||||
|
||||
def render_template(template: str, context: dict[str, Any]) -> str:
|
||||
"""Rendert ein Template mit dem gegebenen Kontext.
|
||||
|
||||
Algorithmus:
|
||||
1. IF-Bloecke iterativ aufloesen (max 10 Durchlaeufe, damit Nesting funktioniert)
|
||||
2. Variablen substituieren
|
||||
|
||||
Args:
|
||||
template: Markdown-Template mit {{VAR}} und {{#IF FLAG}}...{{/IF}}
|
||||
context: dict mit Variablen — Keys SCREAMING_SNAKE_CASE
|
||||
|
||||
Returns:
|
||||
Gerendetes Markdown
|
||||
"""
|
||||
output = template
|
||||
|
||||
for _ in range(10): # max 10 Levels Nesting
|
||||
def replace_if(match: re.Match[str]) -> str:
|
||||
negated = bool(match.group(1))
|
||||
flag_name = match.group(2)
|
||||
content = match.group(3)
|
||||
flag_val = context.get(flag_name)
|
||||
condition = _is_truthy(flag_val)
|
||||
if negated:
|
||||
condition = not condition
|
||||
return content if condition else ""
|
||||
|
||||
new_output = IF_BLOCK.sub(replace_if, output)
|
||||
if new_output == output:
|
||||
break
|
||||
output = new_output
|
||||
|
||||
def replace_var(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
val = context.get(name)
|
||||
if val is None:
|
||||
# Leere Platzhalter sichtbar machen fuer Debugging
|
||||
return f"[{name} fehlt]"
|
||||
if isinstance(val, bool):
|
||||
return "ja" if val else "nein"
|
||||
return str(val)
|
||||
|
||||
output = VAR_PATTERN.sub(replace_var, output)
|
||||
return output
|
||||
|
||||
|
||||
def find_undefined_placeholders(template: str, context: dict[str, Any]) -> list[str]:
|
||||
"""Listet alle Variablen-Platzhalter ohne Wert im Context."""
|
||||
placeholders: set[str] = set()
|
||||
for match in VAR_PATTERN.finditer(template):
|
||||
placeholders.add(match.group(1))
|
||||
for match in IF_BLOCK.finditer(template):
|
||||
placeholders.add(match.group(2))
|
||||
return sorted([p for p in placeholders if p not in context])
|
||||
Reference in New Issue
Block a user