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
This commit is contained in:
Benjamin Admin
2026-05-20 09:30:51 +02:00
parent 98ec6d4284
commit 7a5f1e48dd
33 changed files with 6725 additions and 0 deletions
@@ -71,6 +71,7 @@ _ROUTER_MODULES = [
"compliance_report_routes",
"whistleblower_routes",
"tcf_routes",
"founding_wizard_routes",
]
_loaded_count = 0
@@ -0,0 +1,183 @@
"""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()
@@ -0,0 +1,12 @@
"""Founding-Wizard Service: rendert Templates + generiert DOCX-Files."""
from .markdown_to_docx import markdown_to_docx_bytes
from .template_renderer import find_undefined_placeholders, render_template
from .wizard_to_context import base_context
__all__ = [
"base_context",
"find_undefined_placeholders",
"markdown_to_docx_bytes",
"render_template",
]
@@ -0,0 +1,176 @@
"""
Konvertiert gerendertes Markdown in eine .docx-Datei mittels python-docx.
Unterstuetzte Markdown-Elemente:
- # / ## / ### / #### / ##### Headings
- **bold** und _italic_ inline
- Tabellen (Pipe-Syntax)
- Listen mit - oder * oder Ziffer.)
- Horizontale Linien ---
- Code-Inline `code`
Bewusst minimal — fuer rechtliche Dokumente brauchen wir keine Bilder/Embeds.
"""
from __future__ import annotations
import io
import re
from typing import Optional
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
HEADING_RE = re.compile(r"^(#{1,5})\s+(.+)$")
HR_RE = re.compile(r"^[-_*]{3,}\s*$")
LIST_BULLET_RE = re.compile(r"^(\s*)([-*+])\s+(.+)$")
LIST_NUMBER_RE = re.compile(r"^(\s*)(\d+)[\.\)]\s+(.+)$")
TABLE_ROW_RE = re.compile(r"^\|(.+)\|\s*$")
TABLE_SEP_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
INLINE_BOLD = re.compile(r"\*\*([^*]+)\*\*")
INLINE_ITALIC = re.compile(r"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)|_([^_]+)_")
INLINE_CODE = re.compile(r"`([^`]+)`")
def _add_runs(paragraph, text: str) -> None:
"""Parse inline-Formatierung und fuege Runs hinzu."""
pos = 0
tokens: list[tuple[str, str]] = []
while pos < len(text):
m_bold = INLINE_BOLD.search(text, pos)
m_code = INLINE_CODE.search(text, pos)
m_italic = INLINE_ITALIC.search(text, pos)
candidates = [m for m in (m_bold, m_code, m_italic) if m]
if not candidates:
tokens.append(("plain", text[pos:]))
break
first = min(candidates, key=lambda m: m.start())
if first.start() > pos:
tokens.append(("plain", text[pos:first.start()]))
if first is m_bold:
tokens.append(("bold", first.group(1)))
elif first is m_code:
tokens.append(("code", first.group(1)))
else:
content = m_italic.group(1) or m_italic.group(2)
tokens.append(("italic", content))
pos = first.end()
for kind, content in tokens:
run = paragraph.add_run(content)
if kind == "bold":
run.bold = True
elif kind == "italic":
run.italic = True
elif kind == "code":
run.font.name = "Courier New"
run.font.size = Pt(10)
def _parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
"""Parst Markdown-Tabelle. Returns (rows, next_line_index)."""
rows: list[list[str]] = []
i = start
while i < len(lines):
line = lines[i].rstrip()
if not TABLE_ROW_RE.match(line) and not TABLE_SEP_RE.match(line):
break
if TABLE_SEP_RE.match(line):
i += 1
continue
cells = [c.strip() for c in line.strip("|").split("|")]
rows.append(cells)
i += 1
return rows, i
def _add_table(doc: Document, rows: list[list[str]]) -> None:
if not rows:
return
ncols = max(len(r) for r in rows)
table = doc.add_table(rows=len(rows), cols=ncols)
table.style = "Light Grid"
for r_idx, row in enumerate(rows):
for c_idx, cell_text in enumerate(row):
if c_idx < ncols:
cell = table.rows[r_idx].cells[c_idx]
cell.text = ""
p = cell.paragraphs[0]
_add_runs(p, cell_text)
if r_idx == 0:
for run in p.runs:
run.bold = True
def markdown_to_docx_bytes(markdown_text: str, title: Optional[str] = None) -> bytes:
"""Konvertiert Markdown nach DOCX und returns die Bytes."""
doc = Document()
# Basis-Style
style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
if title:
h = doc.add_heading(title, level=0)
h.alignment = WD_ALIGN_PARAGRAPH.LEFT
lines = markdown_text.split("\n")
i = 0
while i < len(lines):
line = lines[i].rstrip()
if not line.strip():
i += 1
continue
# Heading
h_match = HEADING_RE.match(line)
if h_match:
level = len(h_match.group(1))
text = h_match.group(2)
heading = doc.add_heading(level=min(level, 4))
_add_runs(heading, text)
i += 1
continue
# Horizontal Rule
if HR_RE.match(line):
doc.add_paragraph("" * 60)
i += 1
continue
# Tabelle
if TABLE_ROW_RE.match(line):
rows, i = _parse_table(lines, i)
_add_table(doc, rows)
doc.add_paragraph()
continue
# List Bullet
b_match = LIST_BULLET_RE.match(line)
if b_match:
p = doc.add_paragraph(style="List Bullet")
_add_runs(p, b_match.group(3))
i += 1
continue
# List Number
n_match = LIST_NUMBER_RE.match(line)
if n_match:
p = doc.add_paragraph(style="List Number")
_add_runs(p, n_match.group(3))
i += 1
continue
# Sonst: normaler Paragraph
p = doc.add_paragraph()
_add_runs(p, line)
i += 1
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
@@ -0,0 +1,95 @@
"""
Handlebars-light Template-Renderer fuer die compliance_legal_templates.
Unterstuetzte Syntax:
- {{VARIABLE_NAME}} - einfache String-Substitution
- {{#IF FLAG}}...{{/IF}} - bedingter Block (truthy)
- {{#IF NOT FLAG}}...{{/IF}} - negierter bedingter Block
Bewusst minimal gehalten — keine Loops oder Verschachtelung tiefer Logik.
Komplexere Sachen werden im Context vorberechnet.
"""
from __future__ import annotations
import re
from typing import Any
# Pattern fuer {{#IF FLAG}}...{{/IF}} und {{#IF NOT FLAG}}...{{/IF}}
# Greedy / non-overlapping. Inhalt darf alles enthalten ausser einem geschlossenen {{/IF}}.
IF_BLOCK = re.compile(
r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}(.*?)\{\{/IF\}\}",
re.DOTALL,
)
VAR_PATTERN = re.compile(r"\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}")
def _is_truthy(val: Any) -> bool:
"""Pythonische Truthiness, mit Special-Case: leeres dict/list/str = False."""
if val is None:
return False
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return val != 0
if isinstance(val, str):
return val.strip() != "" and val.lower() not in ("false", "0", "no", "nein")
if isinstance(val, (list, dict, tuple, set)):
return len(val) > 0
return True
def render_template(template: str, context: dict[str, Any]) -> str:
"""Rendert ein Template mit dem gegebenen Kontext.
Algorithmus:
1. IF-Bloecke iterativ aufloesen (max 10 Durchlaeufe, damit Nesting funktioniert)
2. Variablen substituieren
Args:
template: Markdown-Template mit {{VAR}} und {{#IF FLAG}}...{{/IF}}
context: dict mit Variablen — Keys SCREAMING_SNAKE_CASE
Returns:
Gerendetes Markdown
"""
output = template
for _ in range(10): # max 10 Levels Nesting
def replace_if(match: re.Match[str]) -> str:
negated = bool(match.group(1))
flag_name = match.group(2)
content = match.group(3)
flag_val = context.get(flag_name)
condition = _is_truthy(flag_val)
if negated:
condition = not condition
return content if condition else ""
new_output = IF_BLOCK.sub(replace_if, output)
if new_output == output:
break
output = new_output
def replace_var(match: re.Match[str]) -> str:
name = match.group(1)
val = context.get(name)
if val is None:
# Leere Platzhalter sichtbar machen fuer Debugging
return f"[{name} fehlt]"
if isinstance(val, bool):
return "ja" if val else "nein"
return str(val)
output = VAR_PATTERN.sub(replace_var, output)
return output
def find_undefined_placeholders(template: str, context: dict[str, Any]) -> list[str]:
"""Listet alle Variablen-Platzhalter ohne Wert im Context."""
placeholders: set[str] = set()
for match in VAR_PATTERN.finditer(template):
placeholders.add(match.group(1))
for match in IF_BLOCK.finditer(template):
placeholders.add(match.group(2))
return sorted([p for p in placeholders if p not in context])
@@ -0,0 +1,178 @@
"""
Mapping vom Wizard-State (frontend) auf den Template-Context (Render-Variablen).
Frontend liefert ein JSON-Payload mit den Wizard-Schritten. Hier konvertieren
wir es in eine flache Dict-Struktur, deren Keys SCREAMING_SNAKE_CASE sind und
zu den Platzhaltern in den Templates passen (z.B. {{COMPANY_NAME}}).
Pro Dokumenttyp (document_type) wird der jeweils benoetigte Subset gebaut.
"""
from __future__ import annotations
from typing import Any
def _gs_table(gesellschafter: list[dict[str, Any]], stammkapital: int) -> str:
"""Erzeugt eine Markdown-Tabelle der Gesellschafter."""
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
pct = (nb / max(stammkapital, 1)) * 100 if stammkapital else 0
rows.append(
f"| {g.get('anteil_nr', '')} | {g.get('name', '')} | "
f"{g.get('geburtsdatum') or g.get('adresse', '')} | "
f"{g.get('adresse', '')} | {g.get('anteil_nr', '')} | "
f"{nb:,} | {pct:.2f}% |".replace(",", ".")
)
return "\n".join(rows)
def _parties_list(gesellschafter: list[dict[str, Any]]) -> str:
"""Aufzaehlung der Parteien fuer SHA, IP-Assignment etc."""
lines = []
for idx, g in enumerate(gesellschafter):
letter = chr(ord("a") + idx)
line = f"{letter}) **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
lines.append(line + ",")
return "\n".join(lines)
def _parties_list_with_shares(gesellschafter: list[dict[str, Any]]) -> str:
"""Erzeugt nummerierte Liste der Gesellschafter mit Anteilen fuer § 3 Satzung."""
lines = []
for g in gesellschafter:
nr = g.get("anteil_nr", "?")
name = g.get("name", "")
nb = int(g.get("nennbetrag_eur") or 0)
lines.append(
f"{nr}. {name} übernimmt den Geschäftsanteil Nr. {nr} mit einem "
f"Nennbetrag von {nb:,} Euro.".replace(",", ".")
)
return "\n".join(lines)
def _gf_liste(gf: list[dict[str, Any]]) -> str:
"""Liste der Geschaeftsfuehrer fuer Bestellungsbeschluss / HRB-Anmeldung."""
lines = []
for g in gf:
line = f"- **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
if g.get("internal_role"):
line += f"{g['internal_role']}"
lines.append(line)
return "\n".join(lines)
def _company_purpose_bullets(bullets: list[str]) -> str:
return "\n".join(bullets) if bullets else "a) Allgemeine geschäftliche Tätigkeit"
def _einzahlungsaufstellung(gesellschafter: list[dict[str, Any]], quote_pct: int) -> str:
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
paid = int(nb * quote_pct / 100)
rows.append(f"- {g.get('name', '')}: {paid:,} EUR von {nb:,} EUR ({quote_pct}%)".replace(",", "."))
return "\n".join(rows)
def base_context(state: dict[str, Any]) -> dict[str, Any]:
"""Gemeinsamer Context fuer alle Dokumente."""
basics = state.get("basics", {})
capital = state.get("capital", {})
notar = state.get("notar", {})
gesellschafter = state.get("gesellschafter", [])
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
sha = state.get("sha", {})
stammkapital = int(capital.get("stammkapital_eur") or 25000)
num_gf = len(gf_list)
num_gs = len(gesellschafter)
has_academic = any(g.get("has_academic_background") for g in gesellschafter)
ctx: dict[str, Any] = {
# Company
"COMPANY_NAME": basics.get("company_name", ""),
"COMPANY_LEGAL_FORM": basics.get("legal_form", "GmbH"),
"COMPANY_SEAT": basics.get("company_seat", ""),
"COMPANY_ADDRESS": basics.get("company_address", ""),
"COMPANY_PURPOSE_DESCRIPTION": basics.get("company_purpose_description", ""),
"COMPANY_PURPOSE_BULLETS": _company_purpose_bullets(basics.get("company_purpose_bullets", [])),
"COMPANY_PURPOSE_SHORT": basics.get("industry", "")[:120],
"BUSINESS_YEAR": basics.get("business_year", "Kalenderjahr"),
"FIRST_YEAR_END": "31. Dezember des Eintragungsjahres",
"PUBLICATION_VENUE": "Bundesanzeiger",
# Capital
"STAMMKAPITAL_EUR": f"{stammkapital:,}".replace(",", "."),
"STAMMKAPITAL_HALF_EUR": f"{stammkapital // 2:,}".replace(",", "."),
"EINLAGE_METHOD": capital.get("einlage_method", "Geld"),
"EINLAGE_QUOTE_INITIAL_PCT": capital.get("einlage_quote_initial_pct", 50),
"EINLAGE_QUOTE_REMAINING_PCT": 100 - int(capital.get("einlage_quote_initial_pct") or 50),
"EINLAGE_QUOTE_INITIAL_LESS_THAN_100": (capital.get("einlage_quote_initial_pct") or 50) < 100,
"EINZAHLUNGSAUFSTELLUNG": _einzahlungsaufstellung(gesellschafter, capital.get("einlage_quote_initial_pct") or 50),
"HAS_SACHEINLAGE": capital.get("has_sacheinlage", False),
"VERZUGSFRIST_TAGE": 30,
"EINZIEHUNG_MEHRHEIT_PCT": 75,
"VORKAUFSRECHT_TAGE": 14,
"EINBERUFUNGSFRIST_TAGE": 7,
"VOTING_UNIT_EUR": "1,00",
"ERBFALL_AUFGRIFFSFRIST_MONATE": 6,
"ERBFALL_MEHRHEIT_PCT": 75,
"AUFLOESUNG_MEHRHEIT_PCT": 75,
"GRUENDUNGSKOSTEN_MAX_EUR": f"{int(stammkapital / 10):,}".replace(",", "."),
# Gesellschafter
"PARTIES_LIST": _parties_list(gesellschafter),
"PARTIES_LIST_WITH_SHARES": _parties_list_with_shares(gesellschafter),
"GESELLSCHAFTER_TABELLE": _gs_table(gesellschafter, stammkapital),
"GESCHAEFTSFUEHRER_LISTE": _gf_liste(gf_list),
"GESELLSCHAFTER_LISTE": _gf_liste(gesellschafter),
# GF
"NUM_GF": num_gf,
"NUM_GF_TEXT": {1: "einen", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf"}.get(num_gf, str(num_gf)),
"IS_SINGLE_GF": num_gf == 1,
"IS_MULTI_GF": num_gf > 1,
"NUM_GF_IS_2": num_gf == 2,
"NUM_GF_GT_2": num_gf > 2,
"IS_MULTI_GESELLSCHAFTER": num_gs > 1,
"IS_FOUNDER_GROUP": num_gs >= 2,
"VERTRETUNGSART": "Gesamtvertretung; bei nur einem Geschäftsführer Einzelvertretung",
# Notar
"NOTARY_NAME": notar.get("notary_name", ""),
"NOTARY_PLACE": notar.get("notary_place", ""),
"NOTARY_ADDRESS": notar.get("notary_address", ""),
"NOTARY_URNR": notar.get("urnr", "[wird beim Termin vergeben]"),
"NOTARIAL_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
"NOTARY_BEGLAUBIGUNG_URNR": "[wird beim Termin vergeben]",
"NOTARIAL_LOCATION": notar.get("notary_place", ""),
"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]",
# Common
"DOCUMENT_VERSION": "1.0.0",
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"RESOLUTION_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"NEXT_REVIEW_DATE": "[+ 12 Monate]",
"SIGNATURES_BLOCK": "Unterschriften gemäß notarieller Beurkundung",
# SHA Flags
"HAS_SHA": sha.get("has_sha", True),
"HAS_GO_GF": True,
"HAS_ACADEMIC_FOUNDER": has_academic,
"HAS_RESEARCH_FOCUS": basics.get("has_research_focus", False),
"HAS_BEIRAT": sha.get("has_beirat", False),
"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]",
"IS_UG": basics.get("legal_form") == "UG",
}
return ctx