Files
breakpilot-compliance/backend-compliance/compliance/api/founding_wizard_routes.py
T
Benjamin Admin 7335f64f4f
CI / loc-budget (push) Failing after 20s
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 19s
CI / nodejs-build (push) Successful in 3m17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(founding-wizard): Per-Person IP-Assignment + Prefill + E2E-Tests
Wizard unterstuetzt jetzt 2-4 Gesellschafter mit individuellem IP-Bereich:
- Pro Gruender ein IP-Assignment-Vertrag (z.B. Benjamin: Compliance+RAG;
  Sharang: Security+Infrastruktur). Pro GF ein eigener Dienstvertrag.
- Step 1: Prefill-Button aus Unternehmensprofil + Felder Registergericht
  und HRB-Nr.
- Step 2: Rollen-Dropdown (CEO/CTO/CFO/COO/CPO/GF/Sonstige) statt freie
  Texteingabe, IP-Bereiche-Textarea pro Person.

Backend:
- generate_documents() iteriert pro Person fuer PER_PERSON_DOCS.
- _build_person_context() injiziert ASSIGNOR_*, GF_*, IP_LIST_DETAILS
  aus person.ip_areas.
- base_context() propagiert basics.register_court und basics.hrb_number.

Tests:
- 30/30 Pytest gruen (6 neue: Per-Person-Context, Slug-Helper,
  Registergericht-Propagation).
- 4 neue Playwright-E2E-Specs (hermetisch via route.fulfill, mit
  Console-/Page-Error-Traps): kompletter 8-Step-Flow, Prefill-Fehlerpfad,
  Step-Navigation/Reset, Rollen-Dropdown + IP-Areas.
- Spec setzt 'bp-sdk-cookie-consent' im addInitScript damit der
  CookieBannerOverlay nicht die Wizard-Buttons ueberlagert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:49:10 +02:00

284 lines
10 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 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 _safe_slug(name: str) -> str:
"""Erzeugt einen filename-tauglichen Slug aus einem Namen."""
import re as _re
s = _re.sub(r"[^a-zA-Z0-9_-]+", "_", name.strip())
return s.strip("_") or "Person"
def _render_one(
db: Session,
doc_type: str,
context: dict[str, Any],
name_suffix: str = "",
) -> 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)
if name_suffix:
title = f"{title}{name_suffix}"
docx_bytes = markdown_to_docx_bytes(rendered_md, title=None)
from datetime import datetime
suffix_slug = f"_{_safe_slug(name_suffix)}" if name_suffix else ""
company_slug = _safe_slug(context.get("COMPANY_NAME", "Unternehmen"))
return DocumentResult(
document_type=doc_type,
title=title,
filename=f"{doc_type}{suffix_slug}_{company_slug}.docx",
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 []),
)
# Dokumente die PRO Person (Gründer/GF) generiert werden
PER_PERSON_DOCS = {
"ip_assignment_agreement", # Pro Gründer einer (individuelles IP)
"managing_director_employment_contract", # Pro GF einer
}
def _build_person_context(
base_ctx: dict[str, Any],
person: dict[str, Any],
doc_type: str,
gf_contract: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Erweitert base_context um person-spezifische Felder fuer Per-Person-Dokumente."""
ctx = dict(base_ctx)
name = person.get("name", "")
ctx["ASSIGNOR_NAME"] = name
ctx["ASSIGNOR_BIRTHDATE"] = person.get("geburtsdatum", "")
ctx["ASSIGNOR_ADDRESS"] = person.get("adresse", "")
ctx["ASSIGNOR_ROLE"] = person.get("internal_role") or "Gründer und Geschäftsführer"
ctx["HAS_ACADEMIC_BACKGROUND"] = bool(person.get("has_academic_background"))
# GF-Vertrag spezifisch
ctx["GF_NAME"] = name
ctx["GF_BIRTHDATE"] = person.get("geburtsdatum", "")
ctx["GF_ADDRESS"] = person.get("adresse", "")
ctx["GF_INTERNAL_TITLE"] = person.get("internal_role", "Geschäftsführer")
# IP-Bereiche: Person-spezifisch wenn vorhanden
ip_areas = person.get("ip_areas") or []
if ip_areas:
if isinstance(ip_areas, list):
ctx["IP_LIST_DETAILS"] = "\n".join(
f"- {area}" for area in ip_areas
)
else:
ctx["IP_LIST_DETAILS"] = str(ip_areas)
# GF-Contract Daten anwenden wenn vorhanden
if gf_contract:
if gf_contract.get("gross_annual_salary_eur"):
ctx["GROSS_ANNUAL_SALARY_EUR"] = f"{gf_contract['gross_annual_salary_eur']:,}".replace(",", ".")
ctx["HAS_BONUS"] = bool(gf_contract.get("has_bonus"))
ctx["HAS_COMPANY_CAR"] = bool(gf_contract.get("has_company_car"))
ctx["HAS_BAV"] = bool(gf_contract.get("has_bav"))
ctx["VACATION_DAYS"] = gf_contract.get("vacation_days", 30)
ctx["KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE"] = gf_contract.get("kuendigungsfrist_gesellschaft_monate", 6)
ctx["KUENDIGUNGSFRIST_GF_MONATE"] = gf_contract.get("kuendigungsfrist_gf_monate", 3)
ctx["HAS_PARA_181_RELEASE"] = bool(gf_contract.get("para_181_release"))
ctx["SV_STATUS"] = gf_contract.get("sv_status", "sozialversicherungsfrei")
return ctx
@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] = []
# Gesellschafter + GF-Listen aus Request
gesellschafter = req.gesellschafter
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
gf_contracts_map = {
c["gesellschafter_id"]: c
for c in req.gf_contracts
if c.get("gesellschafter_id")
}
for doc_type in req.selected_documents:
if doc_type in PER_PERSON_DOCS:
# Pro Person ein Dokument
if doc_type == "ip_assignment_agreement":
# IP-Assignment: pro Gründer (alle Gesellschafter, nicht nur GFs)
persons = gesellschafter or [{}]
elif doc_type == "managing_director_employment_contract":
# GF-Vertrag: nur pro GF
persons = gf_list or [{}]
else:
persons = [{}]
if not persons:
warnings.append(f"Keine Personen für '{doc_type}' vorhanden")
continue
for p in persons:
contract = gf_contracts_map.get(p.get("id"))
person_ctx = _build_person_context(context, p, doc_type, contract)
result = _render_one(
db, doc_type, person_ctx,
name_suffix=p.get("name", "")
)
if result is None:
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
break
results.append(result)
else:
# Standard: ein Dokument pro Auswahl
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()