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
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>
284 lines
10 KiB
Python
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()
|