Files
breakpilot-compliance/backend-compliance/compliance/api/founding_wizard_routes.py
T
Benjamin Admin 7a5f1e48dd 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
2026-05-20 09:30:51 +02:00

184 lines
6.3 KiB
Python

"""FastAPI-Route fuer den Founding-Wizard Document-Generation.
POST /v1/founding-wizard/generate
Body: FoundingWizardState (Wizard-Eingaben)
Returns: {documents: [{document_type, title, content_base64, size_bytes, ...}]}
Templates werden aus compliance_legal_templates geladen, mit dem Wizard-Context
gerendert (Handlebars-light) und als .docx-Bytes (base64) zurueckgegeben.
"""
from __future__ import annotations
import base64
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from compliance.services.founding_wizard import (
base_context,
markdown_to_docx_bytes,
render_template,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/founding-wizard", tags=["founding-wizard"])
DOC_TITLES = {
"articles_of_association": "Satzung",
"gesellschafterliste": "Gesellschafterliste",
"gf_bestellungsbeschluss": "Bestellungsbeschluss Geschäftsführer",
"hrb_anmeldung": "Handelsregister-Anmeldung",
"sha": "Shareholders' Agreement (SHA)",
"geschaeftsordnung_gf": "Geschäftsordnung der Geschäftsführung",
"managing_director_employment_contract": "Geschäftsführerdienstvertrag",
"ip_assignment_agreement": "IP-Assignment Agreement",
"employment_contract_de": "Arbeitsvertrag",
"term_sheet": "Term Sheet",
"convertible_loan_agreement": "Wandeldarlehensvertrag",
"subscription_agreement": "Beteiligungsvertrag",
"esop_plan": "ESOP/VSOP-Plan",
"cap_table": "Cap Table",
}
class GenerationRequest(BaseModel):
current_step: int = 8
lifecycle_stage: str = "founding"
is_pre_notary: bool = True
basics: dict[str, Any] = {}
gesellschafter: list[dict[str, Any]] = []
capital: dict[str, Any] = {}
notar: dict[str, Any] = {}
sha: dict[str, Any] = {}
gf_contracts: list[dict[str, Any]] = []
selected_documents: list[str] = []
class DocumentResult(BaseModel):
document_type: str
title: str
filename: str
content_base64: str
size_bytes: int
generated_at: str
placeholders_count: int
class GenerationResponse(BaseModel):
documents: list[DocumentResult]
warnings: list[str] = []
def _load_template(db: Session, document_type: str) -> dict[str, Any] | None:
"""Laedt das neueste published Template fuer den document_type."""
row = db.execute(
text("""
SELECT id, document_type, title, content, placeholders, version, status
FROM compliance_legal_templates
WHERE document_type = :dt AND status = 'published'
ORDER BY created_at DESC
LIMIT 1
"""),
{"dt": document_type},
).first()
if not row:
return None
return {
"id": str(row.id),
"document_type": row.document_type,
"title": row.title,
"content": row.content,
"placeholders": row.placeholders or [],
"version": row.version,
}
def _render_one(db: Session, doc_type: str, context: dict[str, Any]) -> DocumentResult | None:
template = _load_template(db, doc_type)
if not template:
logger.warning("No template found for document_type=%s", doc_type)
return None
rendered_md = render_template(template["content"], context)
title = template.get("title") or DOC_TITLES.get(doc_type, doc_type)
docx_bytes = markdown_to_docx_bytes(rendered_md, title=None)
from datetime import datetime
return DocumentResult(
document_type=doc_type,
title=title,
filename=f"{doc_type}_{context.get('COMPANY_NAME', 'Unternehmen')}.docx".replace(" ", "_"),
content_base64=base64.b64encode(docx_bytes).decode("ascii"),
size_bytes=len(docx_bytes),
generated_at=datetime.utcnow().isoformat() + "Z",
placeholders_count=len(template.get("placeholders") or []),
)
@router.post("/generate", response_model=GenerationResponse)
def generate_documents(req: GenerationRequest, request: Request) -> GenerationResponse:
"""Hauptendpunkt: nimmt Wizard-State entgegen, generiert DOCX fuer alle ausgewaehlten Dokumente."""
# Database session is provided via FastAPI dependency injection in production.
# Hier vereinfacht direkt aus dem request state (verwendet Hauptverbindung)
from classroom_engine.database import SessionLocal
db: Session = SessionLocal()
try:
context = base_context(req.model_dump())
results: list[DocumentResult] = []
warnings: list[str] = []
for doc_type in req.selected_documents:
result = _render_one(db, doc_type, context)
if result is None:
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
continue
results.append(result)
if not results:
raise HTTPException(
status_code=400,
detail=f"Keines der angeforderten Dokumente konnte generiert werden. "
f"Warnings: {warnings}"
)
return GenerationResponse(documents=results, warnings=warnings)
finally:
db.close()
@router.get("/templates")
def list_available_templates(request: Request) -> dict[str, Any]:
"""Listet alle verfuegbaren Templates mit Kategorisierung."""
from classroom_engine.database import SessionLocal
db: Session = SessionLocal()
try:
rows = db.execute(
text("""
SELECT document_type, title, description, version, status,
lifecycle_stage, functional_category
FROM compliance_legal_templates
WHERE status = 'published'
ORDER BY functional_category, document_type
""")
).fetchall()
return {
"templates": [
{
"document_type": r.document_type,
"title": r.title,
"description": r.description,
"version": r.version,
"lifecycle_stage": list(r.lifecycle_stage or []),
"functional_category": r.functional_category,
}
for r in rows
],
"count": len(rows),
}
finally:
db.close()