"""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()