feat(founding-wizard): Per-Person IP-Assignment + Prefill + E2E-Tests
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
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>
This commit is contained in:
@@ -98,19 +98,35 @@ def _load_template(db: Session, document_type: str) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _render_one(db: Session, doc_type: str, context: dict[str, Any]) -> DocumentResult | None:
|
||||
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}_{context.get('COMPANY_NAME', 'Unternehmen')}.docx".replace(" ", "_"),
|
||||
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",
|
||||
@@ -118,6 +134,56 @@ def _render_one(db: Session, doc_type: str, context: dict[str, Any]) -> Document
|
||||
)
|
||||
|
||||
|
||||
# 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."""
|
||||
@@ -130,12 +196,47 @@ def generate_documents(req: GenerationRequest, request: Request) -> GenerationRe
|
||||
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:
|
||||
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 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(
|
||||
|
||||
@@ -166,7 +166,8 @@ def base_context(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"ANMELDUNG_TYP": "Ersteintragung gemäß § 7 GmbHG",
|
||||
"ANMELDUNG_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
|
||||
"REGISTRY_COURT_ADDRESS": "[Adresse des zuständigen Registergerichts]",
|
||||
"COMPANY_REGISTRY_COURT": "[zuständiges Amtsgericht]",
|
||||
"COMPANY_REGISTRY_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
"REGISTER_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
# Common
|
||||
"DOCUMENT_VERSION": "1.0.0",
|
||||
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
|
||||
@@ -182,8 +183,8 @@ def base_context(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"HAS_TEXAS_SHOOTOUT": sha.get("has_texas_shootout", False),
|
||||
"HAS_CEO_DESIGNATION": sha.get("has_ceo_designation", False),
|
||||
"CEO_NAME": sha.get("ceo_name", ""),
|
||||
"HAS_HRB": False,
|
||||
"HRB_NUMBER": "[wird vergeben]",
|
||||
"HAS_HRB": bool(basics.get("hrb_number")),
|
||||
"HRB_NUMBER": basics.get("hrb_number") or "[wird vergeben]",
|
||||
"IS_UG": basics.get("legal_form") == "UG",
|
||||
# GO-GF dynamische §-Numerierung
|
||||
"P_INFO": 5,
|
||||
|
||||
@@ -227,6 +227,70 @@ class TestMarkdownToDocx:
|
||||
assert result[:4] == b"PK\x03\x04"
|
||||
|
||||
|
||||
class TestPerPersonContext:
|
||||
"""Tests fuer per-person Context-Building (IP-Assignment, GF-Vertrag)."""
|
||||
|
||||
def test_build_person_context_ip_areas_as_list(self):
|
||||
from compliance.api.founding_wizard_routes import _build_person_context
|
||||
base = {"COMPANY_NAME": "X GmbH"}
|
||||
person = {
|
||||
"name": "Benjamin Bönisch",
|
||||
"geburtsdatum": "1980-01-01",
|
||||
"adresse": "Test 1",
|
||||
"internal_role": "CEO",
|
||||
"ip_areas": ["Compliance-Engine", "RAG-Pipeline"],
|
||||
}
|
||||
ctx = _build_person_context(base, person, "ip_assignment_agreement")
|
||||
assert ctx["ASSIGNOR_NAME"] == "Benjamin Bönisch"
|
||||
assert "Compliance-Engine" in ctx["IP_LIST_DETAILS"]
|
||||
assert "RAG-Pipeline" in ctx["IP_LIST_DETAILS"]
|
||||
# Two distinct persons should yield distinct IP_LIST_DETAILS
|
||||
person2 = {**person, "name": "Sharang", "ip_areas": ["Security", "Infrastruktur"]}
|
||||
ctx2 = _build_person_context(base, person2, "ip_assignment_agreement")
|
||||
assert ctx["IP_LIST_DETAILS"] != ctx2["IP_LIST_DETAILS"]
|
||||
assert "Security" in ctx2["IP_LIST_DETAILS"]
|
||||
|
||||
def test_build_person_context_fallback_when_no_ip_areas(self):
|
||||
"""Wenn keine ip_areas gesetzt sind, behaelt der Context den Default aus base."""
|
||||
from compliance.api.founding_wizard_routes import _build_person_context
|
||||
base = {"COMPANY_NAME": "X GmbH", "IP_LIST_DETAILS": "- Default IP"}
|
||||
person = {"name": "Foo", "ip_areas": []}
|
||||
ctx = _build_person_context(base, person, "ip_assignment_agreement")
|
||||
assert ctx["IP_LIST_DETAILS"] == "- Default IP"
|
||||
|
||||
def test_safe_slug_handles_special_chars(self):
|
||||
from compliance.api.founding_wizard_routes import _safe_slug
|
||||
assert _safe_slug("Benjamin Bönisch") == "Benjamin_B_nisch"
|
||||
assert _safe_slug("Sharang Parnerkar") == "Sharang_Parnerkar"
|
||||
assert _safe_slug("") == "Person"
|
||||
assert _safe_slug(" ") == "Person"
|
||||
|
||||
def test_per_person_docs_set_contains_expected(self):
|
||||
from compliance.api.founding_wizard_routes import PER_PERSON_DOCS
|
||||
assert "ip_assignment_agreement" in PER_PERSON_DOCS
|
||||
assert "managing_director_employment_contract" in PER_PERSON_DOCS
|
||||
# Satzung etc. duerfen NICHT per-person sein:
|
||||
assert "articles_of_association" not in PER_PERSON_DOCS
|
||||
assert "sha" not in PER_PERSON_DOCS
|
||||
|
||||
|
||||
class TestBasicsRegisterCourt:
|
||||
def test_register_court_propagates(self):
|
||||
state = TestWizardToContext()._basic_state()
|
||||
state["basics"]["register_court"] = "Amtsgericht Stuttgart"
|
||||
state["basics"]["hrb_number"] = "HRB 12345"
|
||||
ctx = base_context(state)
|
||||
assert ctx["REGISTER_COURT"] == "Amtsgericht Stuttgart"
|
||||
assert ctx["COMPANY_REGISTRY_COURT"] == "Amtsgericht Stuttgart"
|
||||
assert ctx["HRB_NUMBER"] == "HRB 12345"
|
||||
assert ctx["HAS_HRB"] is True
|
||||
|
||||
def test_register_court_default_when_missing(self):
|
||||
ctx = base_context(TestWizardToContext()._basic_state())
|
||||
assert "[zuständiges Amtsgericht]" in ctx["REGISTER_COURT"]
|
||||
assert ctx["HAS_HRB"] is False
|
||||
|
||||
|
||||
class TestEndToEndRendering:
|
||||
"""Test mit echtem Template-aehnlichen Markdown + 2-Mann GmbH Daten."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user