diff --git a/PROGRESS.md b/PROGRESS.md
index 2736964..b3a58a8 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -58,6 +58,24 @@
- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul)
- Weitere Module können Blöcke/Platzhalter beisteuern (Contract-Muster dokumentiert in plugin-development-guide.md)
+## Phase L4-L5 — KI-Vorschlag + XRechnung-Format-Layer (2026-08-29) ✅
+
+**User-Klärung:** Verkaufsmodul kommt später — aber das XRechnung-FORMAT ist jetzt implementiert (reiner Format-Layer, kein Rechnungs-CRUD).
+
+**Umgesetzt:**
+- L5 Format-Layer: `einvoice.py` — EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes (BT-1/2/3/5, BT-10, BT-27, BT-31/32, BG-25, BT-126/146), Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (Guideline urn:xoev-de:kosit:standard:xrechnung_3.0)
+- Endpoints: `/einvoice/render` (inline → XML), `/einvoice/validate` (422 mit Fehlliste), `/einvoice/render-for` (Contract-Resolver `einvoice_data()` — Andockpunkt Verkaufsmodul, ohne Beitrag 404 no_data_source)
+- L4 KI-Steuerung: `/documents/suggest` — natürliche Sprache → Block-Komposition via zentralem llm_complete (gpt-4o-mini, Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungültige KI-Blöcke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
+- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles, Prompt-Textarea, Vorschläge werden an Blöcke angehängt), i18n de/en
+
+**Verifiziert:**
+- ✅ TDD: Rot 25 failed → ✅ Grün **25/25** (tests/test_einvoice_generator.py: Validierung 6 Unit, XML-Struktur 5 Unit inkl. Escaping/Profil/Summen, Contract-Resolution 2 mit Mock-Registry, API 6: 200-XML/422-BT-Codes/403/404, Suggest 6: Mock-LLM/Filter/Fence/502/403)
+- ✅ tsc exit 0 (useMutation-Typisierung SuggestResult,Error,SuggestInput), Production-Build BUILD_EXIT=0
+- ✅ ruff clean
+
+**Offen:** Verkaufsmodul dockt später via `einvoice_data()` an — Contract + Doku (plugin-development-guide.md) fertig.
+
+
## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅
**Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate.
diff --git a/app/plugins/builtins/report_generator/documents.py b/app/plugins/builtins/report_generator/documents.py
index 33d609a..53eca35 100644
--- a/app/plugins/builtins/report_generator/documents.py
+++ b/app/plugins/builtins/report_generator/documents.py
@@ -10,12 +10,14 @@ from __future__ import annotations
import io
import uuid as uuid_mod
+from typing import Any
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
+from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.ai.llm_client import llm_complete
from app.core.audit import log_audit
from app.core.db import get_db, set_tenant_context
from app.core.storage import get_storage_backend
@@ -27,10 +29,17 @@ from app.plugins.builtins.report_generator.document_blocks import (
)
from app.plugins.builtins.report_generator.document_renderer import (
collect_block_asset_ids,
+ collect_placeholder_defaults,
load_assets_data_urls,
merge_placeholder_defaults,
render_document_html,
)
+from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ render_einvoice_xml,
+ resolve_einvoice_data,
+ validate_einvoice_data,
+)
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
@@ -42,6 +51,11 @@ from app.plugins.builtins.report_generator.schemas import (
DocumentPreviewRequest,
DocumentPreviewResponse,
DocumentRenderRequest,
+ DocumentSuggestRequest,
+ DocumentSuggestResponse,
+ EInvoiceRenderForRequest,
+ EInvoiceRenderRequest,
+ EInvoiceValidationResponse,
LetterheadCreate,
LetterheadResponse,
LetterheadUpdate,
@@ -708,3 +722,234 @@ async def render_print_template(
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+
+
+# ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ────────────────────
+
+
+@router.post("/einvoice/render")
+async def render_einvoice(
+ body: EInvoiceRenderRequest,
+ current_user: dict = Depends(require_permission("reports:generate")),
+):
+ """Render inline invoice data to EN16931/XRechnung CII XML.
+
+ Pure format endpoint - the sales module later uses render-for with
+ its einvoice_data() contract hook.
+ """
+ data = body.model_dump(exclude_none=True)
+
+ try:
+ validate_einvoice_data(data)
+ except EInvoiceValidationError as exc:
+ raise HTTPException(
+ 422,
+ detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
+ ) from exc
+
+ xml = render_einvoice_xml(data)
+ filename = f"{data['invoice_number'].replace(' ', '_')}.xml"
+ return Response(
+ content=xml,
+ media_type="application/xml",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+@router.post("/einvoice/validate")
+async def validate_einvoice(
+ body: EInvoiceRenderRequest,
+ current_user: dict = Depends(require_permission("reports:read")),
+):
+ """Validate invoice data without rendering (missing BT/BG terms -> 422)."""
+ data = body.model_dump(exclude_none=True)
+
+ try:
+ validate_einvoice_data(data)
+ except EInvoiceValidationError as exc:
+ raise HTTPException(
+ 422,
+ detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
+ ) from exc
+ return EInvoiceValidationResponse(valid=True).model_dump()
+
+
+@router.post("/einvoice/render-for")
+async def render_einvoice_for_entity(
+ body: EInvoiceRenderForRequest,
+ db: AsyncSession = Depends(get_db),
+ current_user: dict = Depends(require_permission("reports:generate")),
+):
+ """Render an e-invoice for an entity via the einvoice_data() contract.
+
+ Docking point for the future sales module: it contributes
+ einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint
+ handles validation + XML generation. No contribution -> 404.
+ """
+ tenant_id = uuid_mod.UUID(current_user["tenant_id"])
+ entity_id = _parse_uuid(body.entity_id, "entity_id")
+
+ data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id)
+ if data is None:
+ raise HTTPException(
+ 404,
+ detail={
+ "detail": (
+ f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - "
+ "Modul nicht aktiv oder Entitaet unbekannt"
+ ),
+ "code": "no_data_source",
+ },
+ )
+
+ try:
+ validate_einvoice_data(data)
+ except EInvoiceValidationError as exc:
+ raise HTTPException(
+ 422,
+ detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
+ ) from exc
+
+ xml = render_einvoice_xml(data)
+ invoice_number = str(data.get("invoice_number") or "einvoice")
+ filename = f"{invoice_number.replace(' ', '_')}.xml"
+ return Response(
+ content=xml,
+ media_type="application/xml",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
+
+
+_SUGGEST_HEADER = (
+ "Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n"
+ "Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n"
+ "Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n"
+ '{"blocks": [{"id": "b1", "type": "", "config": {}}], "notes": "kurze Erklaerung"}\n\n'
+ "Verfuegbare Block-Typen mit ihren config-Feldern:\n"
+)
+
+_SUGGEST_MIDDLE = "\n\nVerfuegbare Platzhalter (in Text-Bloecken in der Form {key} verwendbar):\n"
+
+_SUGGEST_FOOTER = (
+ "\n\nRegeln:\n"
+ "- Nutze nur gelistete Block-Typen.\n"
+ "- Jeder Block braucht eine eindeutige id (b1, b2, ...).\n"
+ "- Text-Inhalte koennen Jinja2-Platzhalter wie {firstname} enthalten.\n"
+ '- shape-Bloecke benoetigen "shape": "line", "rect" oder "circle".\n'
+ "- Antworte nur mit dem JSON-Objekt, kein Markdown, keine Code-Fences."
+)
+
+
+def _strip_code_fences(text: str) -> str:
+ """Strip ```json ...``` fences LLMs like to add."""
+ stripped = text.strip()
+ if stripped.startswith("```"):
+ first_newline = stripped.find("\n")
+ if first_newline != -1:
+ stripped = stripped[first_newline + 1 :]
+ if stripped.rstrip().endswith("```"):
+ stripped = stripped.rstrip()[:-3]
+ return stripped.strip()
+
+
+def _sanitize_suggested_blocks(blocks: Any) -> list[dict]:
+ """Filter AI blocks down to registry-valid entries with server ids."""
+ if not isinstance(blocks, list):
+ return []
+ from app.plugins.builtins.report_generator.document_blocks import (
+ BlockValidationError,
+ _known_types,
+ validate_block,
+ )
+
+ known = _known_types()
+ result: list[dict] = []
+ for i, block in enumerate(blocks):
+ if not isinstance(block, dict):
+ continue
+ candidate = {
+ "id": str(block.get("id") or f"ai_{uuid_mod.uuid4().hex[:12]}"),
+ "type": block.get("type"),
+ "config": block.get("config") or {},
+ }
+ try:
+ validate_block(candidate, index=i, known_types=known)
+ except BlockValidationError:
+ continue # drop invalid AI output instead of failing the request
+ result.append(candidate)
+ return result
+
+
+@router.post("/documents/suggest")
+async def suggest_document_blocks(
+ body: DocumentSuggestRequest,
+ db: AsyncSession = Depends(get_db),
+ current_user: dict = Depends(require_permission("reports:manage_templates")),
+):
+ """KI-Steuerung (L4): natural language -> block composition suggestion.
+
+ Goes through the central llm_complete (cost tracking, tenant budget).
+ The AI response is sanitized against the block registry - invalid
+ blocks are dropped, ids are assigned server-side. LLM failures -> 502.
+ """
+ import json as _json
+
+ tenant_id = uuid_mod.UUID(current_user["tenant_id"])
+
+ block_types = get_document_blocks()
+ block_types_desc = _json.dumps(
+ [
+ {"type": b["type"], "label": b["label"], "fields": b.get("fields", {})}
+ for b in block_types
+ ],
+ ensure_ascii=False,
+ )
+ placeholders = collect_placeholder_defaults(body.entity_type) if body.entity_type else {}
+ placeholders_desc = _json.dumps(placeholders, ensure_ascii=False)
+
+ system_prompt = (
+ _SUGGEST_HEADER + block_types_desc + _SUGGEST_MIDDLE + placeholders_desc + _SUGGEST_FOOTER
+ )
+
+ try:
+ result = await llm_complete(
+ model="gpt-4o-mini",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": body.prompt},
+ ],
+ temperature=0.3,
+ max_tokens=2000,
+ tenant_id=tenant_id,
+ db=db,
+ )
+ except Exception as exc:
+ raise HTTPException(
+ 502,
+ detail={"detail": f"KI-Antwort fehlgeschlagen: {exc}", "code": "ai_unavailable"},
+ ) from exc
+
+ raw_content = result.get("content") or ""
+ try:
+ parsed = _json.loads(_strip_code_fences(raw_content))
+ except (ValueError, TypeError) as exc:
+ raise HTTPException(
+ 502,
+ detail={
+ "detail": "KI-Antwort war kein valides JSON",
+ "code": "invalid_ai_response",
+ },
+ ) from exc
+
+ if not isinstance(parsed, dict):
+ raise HTTPException(
+ 502,
+ detail={"detail": "KI-Antwort-Struktur ungueltig", "code": "invalid_ai_response"},
+ )
+
+ blocks = _sanitize_suggested_blocks(parsed.get("blocks"))
+ notes = str(parsed.get("notes") or "")
+ return DocumentSuggestResponse(blocks=blocks, notes=notes).model_dump()
diff --git a/app/plugins/builtins/report_generator/einvoice.py b/app/plugins/builtins/report_generator/einvoice.py
new file mode 100644
index 0000000..71d0b5f
--- /dev/null
+++ b/app/plugins/builtins/report_generator/einvoice.py
@@ -0,0 +1,339 @@
+"""E-Invoice format layer — EN16931 / XRechnung CII XML generation (Phase L5).
+
+This module is deliberately a pure FORMAT layer: it turns validated invoice
+data (plain dicts) into Cross Industry Invoice XML. It knows nothing about
+how invoices are stored — the future sales module will own the entities
+and dock via the ``einvoice_data()`` contract hook
+(``resolve_einvoice_data``).
+
+Profiles:
+- ``en16931``: GuidelineID ``urn:cen.eu:en16931:2017``
+- ``xrechnung``: ``...#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0``
+
+All monetary math uses ``Decimal`` quantized to 2 places (commercial
+rounding) so header sums are consistent with line tax calculations.
+XML escaping is delegated to ElementTree — no manual string concatenation.
+"""
+
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass, field
+from datetime import date
+from decimal import ROUND_HALF_UP, Decimal
+from xml.etree import ElementTree as ET
+
+# ─── CII namespaces (XRechnung 3.0 / D16B) ──────────────────────────────────
+
+NS_RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
+NS_RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
+NS_UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
+
+GUIDELINE_EN16931 = "urn:cen.eu:en16931:2017"
+GUIDELINE_XRECHNUNG = (
+ "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
+)
+
+_CENT = Decimal("0.01")
+
+
+def _money(value) -> Decimal:
+ """Quantize to 2 decimal places (commercial rounding, BR-CL-16)."""
+ return Decimal(str(value)).quantize(_CENT, rounding=ROUND_HALF_UP)
+
+
+def _fmt_date(value: str) -> str:
+ """ISO date (YYYY-MM-DD) → CII format 102 (YYYYMMDD). Raises ValueError."""
+ parsed = date.fromisoformat(str(value).strip())
+ return parsed.strftime("%Y%m%d")
+
+
+# ─── Validation ──────────────────────────────────────────────────────────────
+
+
+@dataclass
+class EInvoiceValidationError(ValueError):
+ """Raised when mandatory EN16931 fields are missing or invalid.
+
+ ``missing`` carries human-readable entries including BT/BG field codes
+ so the API can surface exactly which business terms fail.
+ """
+
+ missing: list[str] = field(default_factory=list)
+
+
+def _non_empty(data: dict, key: str) -> str | None:
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+def validate_einvoice_data(data: dict) -> None:
+ """Validate mandatory EN16931 business terms (subset enforced today).
+
+ Raises EInvoiceValidationError with the collected BT/BG entries.
+ """
+ if not isinstance(data, dict):
+ raise EInvoiceValidationError(missing=["payload: Objekt erwartet"])
+
+ missing: list[str] = []
+
+ if not _non_empty(data, "invoice_number"):
+ missing.append("BT-1: Rechnungsnummer fehlt")
+ if not _non_empty(data, "issue_date"):
+ missing.append("BT-2: Rechnungsdatum fehlt")
+ else:
+ try:
+ _fmt_date(data["issue_date"])
+ except ValueError:
+ missing.append("BT-2: Rechnungsdatum muss YYYY-MM-DD sein")
+ if not _non_empty(data, "type_code"):
+ missing.append("BT-3: Rechnungsart fehlt")
+ if not _non_empty(data, "currency"):
+ missing.append("BT-5: Währung fehlt")
+
+ # Seller: name + at least one tax registration (BT-31 USt-IdNr or
+ # BT-32 Steuernummer — BR-DE-16 for German invoices).
+ if not _non_empty(data, "seller_name"):
+ missing.append("BT-27: Verkäufername fehlt")
+ seller_vat = _non_empty(data, "seller_vat_id")
+ seller_tax = _non_empty(data, "seller_tax_id")
+ if not seller_vat and not seller_tax:
+ missing.append("BT-31: USt-IdNr. oder BT-32: Steuernummer des Verkäufers fehlt")
+
+ if not _non_empty(data, "buyer_name"):
+ missing.append("BT-10: Empfängername fehlt")
+
+ if data.get("due_date"):
+ try:
+ _fmt_date(str(data["due_date"]))
+ except ValueError:
+ missing.append("BT-9: Fälligkeitsdatum muss YYYY-MM-DD sein")
+
+ lines = data.get("line_items")
+ if not isinstance(lines, list) or len(lines) == 0:
+ missing.append("BG-25: mindestens eine Rechnungsposition erforderlich")
+ else:
+ for i, line in enumerate(lines, start=1):
+ if not isinstance(line, dict):
+ missing.append(f"Position {i}: Objekt erwartet")
+ continue
+ if not _non_empty(line, "name"):
+ missing.append(f"BT-126: Positionsname fehlt (Position {i})")
+ try:
+ Decimal(str(line.get("unit_net_price", "0")))
+ except Exception: # noqa: BLE001
+ missing.append(f"BT-146: Einzelpreis ungültig (Position {i})")
+
+ if missing:
+ raise EInvoiceValidationError(missing=missing)
+
+
+# ─── XML rendering ───────────────────────────────────────────────────────────
+
+
+def _sub(parent: ET.Element, tag: str, text: str | None = None, **attrib) -> ET.Element:
+ el = ET.SubElement(parent, f"{{{NS_RAM}}}{tag}", {k: str(v) for k, v in attrib.items()})
+ if text is not None:
+ el.text = str(text)
+ return el
+
+
+def _date_el(parent: ET.Element, tag: str, iso_date: str) -> None:
+ wrapper = _sub(parent, tag)
+ dt = ET.SubElement(wrapper, f"{{{NS_UDT}}}DateTimeString")
+ dt.set("format", "102")
+ dt.text = _fmt_date(iso_date)
+
+
+def _address(parent: ET.Element, addr: dict | None) -> None:
+ addr = addr or {}
+ postal = _sub(parent, "PostalTradeAddress")
+ if addr.get("street"):
+ _sub(postal, "LineOne", str(addr["street"]))
+ if addr.get("postal_code"):
+ _sub(postal, "PostcodeCode", str(addr["postal_code"]))
+ if addr.get("city"):
+ _sub(postal, "CityName", str(addr["city"]))
+ _sub(postal, "CountryID", str(addr.get("country") or "DE"))
+
+
+def _line_sums(data: dict) -> tuple[list[dict], Decimal, dict[str, list[Decimal]]]:
+ """Compute per-line and total sums.
+
+ Returns (line_amounts, line_total, taxes) where line_amounts[i] is the
+ net amount of line i (supplied ``line_net_amount`` wins over
+ quantity × unit price — discounts already applied), and taxes maps
+ vat_rate → [basis, tax_amount] aggregated for the header breakdown.
+ """
+ line_amounts: list[Decimal] = []
+ line_total = Decimal("0.00")
+ taxes: dict[str, list[Decimal]] = {}
+
+ for line in data.get("line_items") or []:
+ qty = Decimal(str(line.get("quantity", 1)))
+ price = _money(line.get("unit_net_price", 0))
+ if line.get("line_net_amount") is not None:
+ net = _money(line["line_net_amount"])
+ else:
+ net = _money(qty * price)
+ line_amounts.append(net)
+ line_total = _money(line_total + net)
+
+ rate = str(line.get("vat_rate", 0))
+ basis, tax = taxes.get(rate, [Decimal("0.00"), Decimal("0.00")])
+ tax_amount = _money(net * Decimal(rate) / Decimal(100))
+ taxes[rate] = [_money(basis + net), _money(tax + tax_amount)]
+
+ return line_amounts, _money(line_total), taxes
+
+
+def render_einvoice_xml(data: dict) -> str:
+ """Render validated invoice data to CII XML (UTF-8, declaration header)."""
+ validate_einvoice_data(data)
+
+ profile = str(data.get("profile") or "en16931").lower()
+ guideline = GUIDELINE_XRECHNUNG if profile == "xrechnung" else GUIDELINE_EN16931
+
+ ET.register_namespace("rsm", NS_RSM)
+ ET.register_namespace("ram", NS_RAM)
+ ET.register_namespace("udt", NS_UDT)
+ root = ET.Element(f"{{{NS_RSM}}}CrossIndustryInvoice")
+
+ # ── ExchangedDocumentContext (BT-24) ──
+ ctx = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocumentContext")
+ guideline_param = _sub(ctx, "GuidelineSpecifiedDocumentContextParameter")
+ _sub(guideline_param, "ID", guideline)
+
+ # ── ExchangedDocument (BT-1..BT-22) ──
+ doc = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocument")
+ _sub(doc, "ID", data["invoice_number"])
+ _sub(doc, "TypeCode", data["type_code"])
+ _date_el(doc, "IssueDateTime", data["issue_date"])
+ if data.get("note"):
+ note = _sub(doc, "IncludedNote")
+ _sub(note, "Content", str(data["note"]))
+
+ # ── SupplyChainTradeTransaction ──
+ txn = ET.SubElement(root, f"{{{NS_RSM}}}SupplyChainTradeTransaction")
+
+ line_amounts, line_total, taxes = _line_sums(data)
+ for idx, (line, net) in enumerate(zip(data["line_items"], line_amounts, strict=True), start=1):
+ item = ET.SubElement(txn, f"{{{NS_RSM}}}IncludedSupplyChainTradeLineItem")
+ line_doc = _sub(item, "AssociatedDocumentLineDocument")
+ _sub(line_doc, "LineID", str(idx))
+
+ product = _sub(item, "SpecifiedTradeProduct")
+ _sub(product, "Name", line["name"])
+
+ agreement = _sub(item, "SpecifiedLineTradeAgreement")
+ net_price = _sub(agreement, "NetPriceProductTradePrice")
+ _sub(net_price, "ChargeAmount", _money(line.get("unit_net_price", 0)))
+
+ delivery = _sub(item, "SpecifiedLineTradeDelivery")
+ _sub(delivery, "BilledQuantity", Decimal(str(line.get("quantity", 1))), unitCode=line.get("unit") or "HUR")
+
+ settlement = _sub(item, "SpecifiedLineTradeSettlement")
+ line_tax = _sub(settlement, "ApplicableTradeTax")
+ _sub(line_tax, "TypeCode", "VAT")
+ rate = Decimal(str(line.get("vat_rate", 0)))
+ _sub(line_tax, "RateApplicablePercent", rate)
+ _sub(line_tax, "BasisAmount", net)
+ _sub(line_tax, "CalculatedAmount", _money(net * rate / Decimal(100)))
+ line_sum = _sub(settlement, "SpecifiedTradeSettlementLineMonetarySummation")
+ _sub(line_sum, "LineTotalAmount", net)
+
+ # ── ApplicableHeaderTradeAgreement ──
+ agreement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeAgreement")
+ if data.get("buyer_reference"):
+ _sub(agreement_h, "BuyerReference", str(data["buyer_reference"]))
+
+ seller = _sub(agreement_h, "SellerTradeParty")
+ _sub(seller, "Name", data["seller_name"])
+ _address(seller, data.get("seller_address"))
+ tax_reg = _sub(seller, "SpecifiedTaxRegistration")
+ if data.get("seller_vat_id"):
+ _sub(tax_reg, "ID", str(data["seller_vat_id"]), schemeID="VA")
+ elif data.get("seller_tax_id"):
+ _sub(tax_reg, "ID", str(data["seller_tax_id"]), schemeID="FC")
+
+ buyer = _sub(agreement_h, "BuyerTradeParty")
+ _sub(buyer, "Name", data["buyer_name"])
+ _address(buyer, data.get("buyer_address"))
+
+ # ── ApplicableHeaderTradeDelivery (BT-72) ──
+ delivery_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeDelivery")
+ event = _sub(delivery_h, "ActualDeliverySupplyChainEvent")
+ _date_el(event, "OccurrenceDateTime", data.get("delivery_date") or data["issue_date"])
+
+ # ── ApplicableHeaderTradeSettlement ──
+ settlement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeSettlement")
+ currency = str(data["currency"])
+ _sub(settlement_h, "InvoiceCurrencyCode", currency)
+
+ if data.get("payment_means_code"):
+ means = _sub(settlement_h, "SpecifiedTradeSettlementPaymentMeans")
+ _sub(means, "TypeCode", str(data["payment_means_code"]))
+
+ # header tax breakdown per VAT rate (BG-23)
+ tax_total = Decimal("0.00")
+ for rate in sorted(taxes, key=Decimal):
+ basis, amount = taxes[rate]
+ header_tax = _sub(settlement_h, "ApplicableTradeTax")
+ _sub(header_tax, "TypeCode", "VAT")
+ _sub(header_tax, "BasisAmount", basis)
+ _sub(header_tax, "CalculatedAmount", amount)
+ _sub(header_tax, "RateApplicablePercent", Decimal(rate))
+ tax_total = _money(tax_total + amount)
+
+ grand_total = _money(line_total + tax_total)
+
+ terms = _sub(settlement_h, "SpecifiedTradePaymentTerms")
+ if data.get("payment_terms_text"):
+ _sub(terms, "Description", str(data["payment_terms_text"]))
+ if data.get("due_date"):
+ _date_el(terms, "DueDateDateTime", str(data["due_date"]))
+
+ sums = _sub(settlement_h, "SpecifiedTradeSettlementHeaderMonetarySummation")
+ _sub(sums, "LineTotalAmount", line_total)
+ _sub(sums, "TaxTotalAmount", tax_total, currencyID=currency)
+ _sub(sums, "GrandTotalAmount", grand_total)
+ _sub(sums, "DuePayableAmount", grand_total)
+
+ return ET.tostring(root, encoding="unicode", xml_declaration=False)
+
+
+# ─── Contract resolution (sales module docking point) ───────────────────────
+
+
+async def resolve_einvoice_data(
+ db,
+ tenant_id: uuid.UUID,
+ entity_type: str,
+ entity_id: uuid.UUID,
+) -> dict | None:
+ """Resolve invoice data for an entity via the ``einvoice_data()`` contract.
+
+ The future sales module will expose::
+
+ async def einvoice_data(db, tenant_id, entity_id, entity_type) -> dict
+
+ Returning validated-shaped invoice data (same fields as the inline
+ render endpoint). No contribution → None (caller answers 404).
+ """
+ from app.plugins.builtins.contracts import get_contract_registry
+ from app.plugins.registry import get_registry
+
+ for plugin_name in get_registry().list_discovered():
+ contract = get_contract_registry().get_contract(plugin_name)
+ fn = getattr(contract, "einvoice_data", None)
+ if fn is None:
+ continue
+ try:
+ result = await fn(db, tenant_id, entity_id, entity_type)
+ except Exception: # noqa: BLE001 — broken contribution must not 500
+ continue
+ if result:
+ return result
+ return None
diff --git a/app/plugins/builtins/report_generator/schemas.py b/app/plugins/builtins/report_generator/schemas.py
index 8fe12b2..71599e1 100644
--- a/app/plugins/builtins/report_generator/schemas.py
+++ b/app/plugins/builtins/report_generator/schemas.py
@@ -162,3 +162,78 @@ class DocumentAssetResponse(BaseModel):
size_bytes: int
data_url: str | None = None
created_at: datetime | None = None
+
+
+# ─── E-Invoice (EN16931/XRechnung, Phase L5) ────────────────────────────────
+
+
+class EInvoiceAddress(BaseModel):
+ """Postal address (BT-50..53 seller, BT-65..68 buyer)."""
+
+ street: str | None = None
+ postal_code: str | None = None
+ city: str | None = None
+ country: str | None = None
+
+
+class EInvoiceLineItem(BaseModel):
+ """Invoice line (BG-25). ``line_net_amount`` wins over qty*price."""
+
+ name: str = ""
+ quantity: float = 1.0
+ unit: str = "HUR"
+ unit_net_price: float = 0.0
+ vat_rate: float = 0.0
+ line_net_amount: float | None = None
+
+
+class EInvoiceRenderRequest(BaseModel):
+ """Inline invoice data — field-level semantics validated by
+ ``einvoice.validate_einvoice_data`` (BT/BG business terms, 422)."""
+
+ invoice_number: str = ""
+ issue_date: str = ""
+ type_code: str = "380"
+ currency: str = "EUR"
+ due_date: str | None = None
+ delivery_date: str | None = None
+ buyer_name: str = ""
+ buyer_reference: str | None = None
+ buyer_address: EInvoiceAddress | None = None
+ seller_name: str = ""
+ seller_vat_id: str | None = None
+ seller_tax_id: str | None = None
+ seller_address: EInvoiceAddress | None = None
+ payment_means_code: str | None = None
+ payment_terms_text: str | None = None
+ note: str | None = None
+ line_items: list[EInvoiceLineItem] = Field(default_factory=list)
+ profile: str = Field("en16931", pattern="^(en16931|xrechnung)$")
+
+
+class EInvoiceRenderForRequest(BaseModel):
+ """Render an e-invoice for an entity via the ``einvoice_data()``
+ contract hook (future sales module docking point)."""
+
+ entity_type: str = Field(..., min_length=1, max_length=100)
+ entity_id: str = Field(..., min_length=1)
+
+
+class EInvoiceValidationResponse(BaseModel):
+ valid: bool
+ missing: list[str] = Field(default_factory=list)
+
+
+# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
+
+
+class DocumentSuggestRequest(BaseModel):
+ """Natural-language block composition request for the template editor."""
+
+ prompt: str = Field(..., min_length=1, max_length=2000)
+ entity_type: str | None = Field(None, max_length=100)
+
+
+class DocumentSuggestResponse(BaseModel):
+ blocks: list[dict]
+ notes: str = ""
diff --git a/docs/api-documentation.md b/docs/api-documentation.md
index ae27ca3..82483ef 100644
--- a/docs/api-documentation.md
+++ b/docs/api-documentation.md
@@ -520,6 +520,10 @@ Admin-only. Rebuild regenerates the embedding + TSV; purge sets embedding/TSV to
| GET | `/api/v1/reports/document-blocks` | Block-Registry (Built-in + Modul-Beiträge, Phase L1). |
| GET | `/api/v1/reports/document-placeholders` | Platzhalter-Registry pro Entity-Type (Modul-Beiträge). |
| POST | `/api/v1/reports/documents/preview` | Blöcke → HTML Live-Vorschau (Phase L2). |
+| POST | `/api/v1/reports/documents/suggest` | KI-Block-Vorschlag aus natuerlicher Sprache (Phase L4). |
+| POST | `/api/v1/reports/einvoice/render` | Rechnungsdaten -> EN16931/XRechnung CII-XML (Phase L5 Format-Layer). |
+| POST | `/api/v1/reports/einvoice/validate` | Rechnungsdaten pruefen (BT/BG-Pflichtfelder, 422 mit Fehlliste). |
+| POST | `/api/v1/reports/einvoice/render-for` | E-Invoice fuer Entitaet via einvoice_data()-Contract (Verkaufsmodul-Andockpunkt). |
### entity-links (Entity Linking)
diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md
index b878920..d5ef504 100644
--- a/docs/plugin-development-guide.md
+++ b/docs/plugin-development-guide.md
@@ -2690,3 +2690,23 @@ class MyContract:
- `document_placeholders`-Beispiele dienen als Preview-Fallbacks (StrictUndefined-vermeidend)
- Built-in-Bloecke duerfen nicht ueberschrieben werden (Contributions mit existierendem Typ werden ignoriert)
- Bilder laufen ausschliesslich als DocumentAsset/data:-URI (WeasyPrint-URL-Fetcher blockt extern)
+
+## E-Invoice-Beitrag (Phase L5 Format-Layer)
+
+Das Verkaufsmodul (spaeter) dockt an den fertigen XRechnung-Format-Layer an:
+
+```python
+class SalesContract:
+ @staticmethod
+ async def einvoice_data(db, tenant_id, entity_id, entity_type) -> dict:
+ invoice = await db.get(Invoice, entity_id)
+ if invoice is None or invoice.tenant_id != tenant_id:
+ return {}
+ return invoice.to_einvoice_dict() # invoice_number, issue_date, type_code,
+ # currency, buyer_name, seller_name, seller_vat_id,
+ # line_items[{name, quantity, unit, unit_net_price, vat_rate}], profile
+```
+
+Validierung + CII-XML-Generierung uebernimmt report_generator (`einvoice.py`,
+EN16931 BT/BG-Pflichtfelder, kommerzielles Rounding, XRechnung-3.0-Guideline).
+Kein Beitrag -> `POST /einvoice/render-for` antwortet 404 `no_data_source`.
diff --git a/frontend/src/api/documents.ts b/frontend/src/api/documents.ts
index 7306f28..c6c1a74 100644
--- a/frontend/src/api/documents.ts
+++ b/frontend/src/api/documents.ts
@@ -294,3 +294,25 @@ export async function renderPrintTemplate(input: RenderInput): Promise {
export function useRenderPrintTemplate() {
return useMutation({ mutationFn: renderPrintTemplate });
}
+
+// ─── AI Block Suggestion (L4) ───────────────────────────────────────────────
+
+export interface SuggestInput {
+ prompt: string;
+ entityType?: string | null;
+}
+
+export interface SuggestResult {
+ blocks: DocBlock[];
+ notes: string;
+}
+
+export function useSuggestDocumentBlocks() {
+ return useMutation({
+ mutationFn: (input: SuggestInput) =>
+ apiPost('/reports/documents/suggest', {
+ prompt: input.prompt,
+ entity_type: input.entityType ?? null,
+ }),
+ });
+}
diff --git a/frontend/src/components/documents/PrintTemplateEditor.tsx b/frontend/src/components/documents/PrintTemplateEditor.tsx
index ebfe1c5..544a76a 100644
--- a/frontend/src/components/documents/PrintTemplateEditor.tsx
+++ b/frontend/src/components/documents/PrintTemplateEditor.tsx
@@ -7,6 +7,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { Sparkles } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -15,6 +16,7 @@ import {
useCreatePrintTemplate,
useUpdatePrintTemplate,
useDocumentPlaceholders,
+ useSuggestDocumentBlocks,
type DocBlock,
type Letterhead,
type PrintTemplate,
@@ -36,6 +38,27 @@ export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTem
const [letterheadId, setLetterheadId] = useState(template?.letterhead_id ?? '');
const [entityType, setEntityType] = useState(template?.entity_type ?? 'contact');
const [blocks, setBlocks] = useState(template?.blocks ?? []);
+ const [aiPrompt, setAiPrompt] = useState('');
+ const [aiNotes, setAiNotes] = useState('');
+ const suggest = useSuggestDocumentBlocks();
+
+ const handleSuggest = async () => {
+ if (!aiPrompt.trim()) return;
+ setAiNotes('');
+ try {
+ const result = await suggest.mutateAsync({ prompt: aiPrompt.trim(), entityType });
+ if (result.blocks.length > 0) {
+ setBlocks((prev) => [...prev, ...result.blocks]);
+ setAiNotes(result.notes);
+ setAiPrompt('');
+ toast.success(t('documents.ai.suggested', '{{count}} Blöcke übernommen', { count: result.blocks.length }));
+ } else {
+ toast.error(t('documents.ai.noBlocks', 'KI hat keine gültigen Blöcke vorgeschlagen'));
+ }
+ } catch {
+ toast.error(t('documents.ai.failed', 'KI-Vorschlag fehlgeschlagen'));
+ }
+ };
const createMutation = useCreatePrintTemplate();
const updateMutation = useUpdatePrintTemplate();
@@ -140,6 +163,39 @@ export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTem
+ {/* AI suggestion (L4) */}
+
+
+
+ {t('documents.ai.hint', 'Beschreibe die Vorlage — die KI schlägt Blöcke vor und fügt sie hinzu.')}
+
+
+
+ {aiNotes && (
+
{aiNotes}
+ )}
+
+
{/* Block editor (content blocks + letterhead frame preview) */}
AsyncGenerator[AsyncClient, None]:
+ transport = ASGITransport(app=docs_app)
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+
+
+def _invoice_payload(**overrides) -> dict:
+ """Valid EN16931 invoice payload (2 × 100.00 net, 19% VAT)."""
+ payload = {
+ "invoice_number": "RE-2026-001",
+ "issue_date": "2026-08-29",
+ "type_code": "380",
+ "currency": "EUR",
+ "due_date": "2026-09-28",
+ "delivery_date": "2026-08-29",
+ "buyer_name": "Käufer AG",
+ "buyer_reference": "PO-2026-42",
+ "buyer_address": {
+ "street": "Käuferstr. 1",
+ "postal_code": "20095",
+ "city": "Hamburg",
+ "country": "DE",
+ },
+ "seller_name": "Verkäufer GmbH",
+ "seller_vat_id": "DE123456789",
+ "seller_address": {
+ "street": "Verkäuferweg 2",
+ "postal_code": "10115",
+ "city": "Berlin",
+ "country": "DE",
+ },
+ "payment_means_code": "58",
+ "payment_terms_text": "Zahlbar innerhalb von 30 Tagen",
+ "note": "Vielen Dank für Ihren Auftrag",
+ "line_items": [
+ {
+ "name": "Beratung",
+ "quantity": 2,
+ "unit": "HUR",
+ "unit_net_price": 100.00,
+ "vat_rate": 19,
+ },
+ ],
+ "profile": "en16931",
+ }
+ payload.update(overrides)
+ return payload
+
+
+# ─── Unit: validation ────────────────────────────────────────────────────────
+
+
+class TestEInvoiceUnitValidation:
+ def test_validate_ok(self):
+ from app.plugins.builtins.report_generator.einvoice import validate_einvoice_data
+
+ validate_einvoice_data(_invoice_payload()) # no raise
+
+ def test_missing_seller_tax_ids_raises_with_bt(self):
+ from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ validate_einvoice_data,
+ )
+
+ payload = _invoice_payload()
+ del payload["seller_vat_id"]
+ with pytest.raises(EInvoiceValidationError) as exc_info:
+ validate_einvoice_data(payload)
+ assert any("BT-31" in m or "BT-32" in m for m in exc_info.value.missing)
+
+ def test_missing_buyer_name_raises(self):
+ from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ validate_einvoice_data,
+ )
+
+ payload = _invoice_payload(buyer_name="")
+ with pytest.raises(EInvoiceValidationError):
+ validate_einvoice_data(payload)
+
+ def test_missing_lines_raises(self):
+ from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ validate_einvoice_data,
+ )
+
+ with pytest.raises(EInvoiceValidationError):
+ validate_einvoice_data(_invoice_payload(line_items=[]))
+
+ def test_bad_issue_date_raises(self):
+ from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ validate_einvoice_data,
+ )
+
+ with pytest.raises(EInvoiceValidationError):
+ validate_einvoice_data(_invoice_payload(issue_date="29.08.2026"))
+
+ def test_line_item_requires_name(self):
+ from app.plugins.builtins.report_generator.einvoice import (
+ EInvoiceValidationError,
+ validate_einvoice_data,
+ )
+
+ payload = _invoice_payload(
+ line_items=[{"name": "", "quantity": 1, "unit_net_price": 10, "vat_rate": 19}]
+ )
+ with pytest.raises(EInvoiceValidationError):
+ validate_einvoice_data(payload)
+
+
+# ─── Unit: XML rendering ─────────────────────────────────────────────────────
+
+
+class TestEInvoiceUnitRender:
+ def test_render_xml_structure(self):
+ from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml
+
+ xml = render_einvoice_xml(_invoice_payload())
+
+ # well-formed XML with correct root
+ root = ET.fromstring(xml)
+ assert root.tag.endswith("CrossIndustryInvoice")
+
+ assert "urn:cen.eu:en16931:2017" in xml # EN16931 guideline
+ assert "RE-2026-001" in xml # BT-1
+ assert "20260829" in xml # BT-2 in format 102
+ assert "380" in xml # BT-3
+ assert "Verkäufer GmbH" in xml # BT-11
+ assert "Käufer AG" in xml # BT-10
+ assert "DE123456789" in xml # BT-31
+ assert 'schemeID="VA"' in xml # VAT registration scheme
+ # computed sums: 200.00 net + 38.00 VAT = 238.00 grand
+ assert "200.00" in xml
+ assert "38.00" in xml
+ assert "238.00" in xml
+ assert "EUR" in xml # BT-5
+
+ def test_render_xrechnung_profile(self):
+ from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml
+
+ xml = render_einvoice_xml(_invoice_payload(profile="xrechnung"))
+ assert "urn:xoev-de:kosit:standard:xrechnung_3.0" in xml
+ assert "urn:cen.eu:en16931:2017" in xml # compliant base
+
+ def test_render_escapes_xml_special_chars(self):
+ from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml
+
+ payload = _invoice_payload(seller_name="A&B ")
+ xml = render_einvoice_xml(payload)
+ assert "A&B <GmbH>" in xml
+ # raw unescaped angle bracket must not appear in the name element
+ assert "" not in xml
+
+ def test_render_line_net_amount_supplied_used(self):
+ from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml
+
+ payload = _invoice_payload(
+ line_items=[
+ {
+ "name": "Pauschale",
+ "quantity": 1,
+ "unit_net_price": 99.99,
+ "vat_rate": 7,
+ "line_net_amount": 90.00,
+ }
+ ]
+ )
+ xml = render_einvoice_xml(payload)
+ assert "90.00" in xml # supplied amount wins over qty*price
+ # tax: 90 * 7% = 6.30, grand 96.30
+ assert "6.30" in xml
+ assert "96.30" in xml
+
+ def test_render_delivery_date_defaults_to_issue_date(self):
+ from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml
+
+ payload = _invoice_payload()
+ del payload["delivery_date"]
+ xml = render_einvoice_xml(payload)
+ assert "20260829" in xml
+
+
+# ─── Unit: contract resolution (sales module docking point) ─────────────────
+
+
+class TestEInvoiceContractResolution:
+ async def test_resolve_via_contract_hook(self, db_session):
+ """The future sales module provides einvoice_data() — the resolver
+ picks it up through the contract registry."""
+ seed = await seed_tenant_and_users(db_session)
+ from app.plugins.builtins.report_generator import einvoice
+
+ invoice_data = _invoice_payload()
+ fake_contract = MagicMock()
+ fake_contract.einvoice_data = AsyncMock(return_value=invoice_data)
+
+ fake_plugin_registry = MagicMock()
+ fake_plugin_registry.list_discovered.return_value = ["sales"]
+ fake_contract_registry = MagicMock()
+ fake_contract_registry.get_contract.return_value = fake_contract
+
+ with (
+ patch("app.plugins.registry.get_registry", return_value=fake_plugin_registry),
+ patch(
+ "app.plugins.builtins.contracts.get_contract_registry",
+ return_value=fake_contract_registry,
+ ),
+ ):
+ data = await einvoice.resolve_einvoice_data(
+ db_session,
+ seed["tenant_a"].id,
+ "invoice",
+ uuid_mod.uuid4(),
+ )
+ assert data is not None
+ assert data["invoice_number"] == "RE-2026-001"
+
+ async def test_resolve_without_hook_returns_none(self, db_session):
+ """No module contributes einvoice_data → None (→ 404 for caller)."""
+ seed = await seed_tenant_and_users(db_session)
+ from app.plugins.builtins.report_generator import einvoice
+
+ class NoHookContract:
+ contract_name = "legacy"
+
+ fake_plugin_registry = MagicMock()
+ fake_plugin_registry.list_discovered.return_value = ["legacy"]
+ fake_contract_registry = MagicMock()
+ fake_contract_registry.get_contract.return_value = NoHookContract()
+
+ with (
+ patch("app.plugins.registry.get_registry", return_value=fake_plugin_registry),
+ patch(
+ "app.plugins.builtins.contracts.get_contract_registry",
+ return_value=fake_contract_registry,
+ ),
+ ):
+ data = await einvoice.resolve_einvoice_data(
+ db_session,
+ seed["tenant_a"].id,
+ "invoice",
+ uuid_mod.uuid4(),
+ )
+ assert data is None
+
+
+# ─── API: e-invoice endpoints ────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+class TestEInvoiceAPI:
+ async def test_render_returns_xml_200(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/render",
+ json=_invoice_payload(),
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 200, f"{resp.status_code} {resp.text}"
+ assert resp.headers["content-type"].startswith("application/xml")
+ assert b"CrossIndustryInvoice" in resp.content
+ assert b"RE-2026-001" in resp.content
+ assert "attachment" in resp.headers.get("content-disposition", "")
+
+ async def test_render_missing_fields_422(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ payload = _invoice_payload()
+ del payload["seller_vat_id"]
+ del payload["buyer_name"]
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/render",
+ json=payload,
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 422
+ body = resp.json()
+ assert body["detail"]["code"] == "invalid_einvoice"
+ missing = json.dumps(body["detail"].get("missing", []))
+ assert "BT-10" in missing # buyer_name
+ assert "BT-31" in missing # vat/tax requirement
+
+ async def test_validate_endpoint_ok(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/validate",
+ json=_invoice_payload(),
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 200
+ assert resp.json()["valid"] is True
+
+ async def test_validate_endpoint_reports_missing(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ payload = _invoice_payload()
+ del payload["seller_vat_id"]
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/validate",
+ json=payload,
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 422
+ assert resp.json()["detail"]["code"] == "invalid_einvoice"
+
+ async def test_render_for_without_sales_module_404(
+ self, docs_client: AsyncClient, db_session
+ ):
+ """render-for needs a module einvoice_data() — none exists yet → 404."""
+ seed = await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/render-for",
+ json={
+ "entity_type": "invoice",
+ "entity_id": str(seed["company_a"].id),
+ },
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 404
+ assert resp.json()["detail"]["code"] == "no_data_source"
+
+ async def test_viewer_cannot_render_403(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "viewer@tenanta.com")
+ resp = await docs_client.post(
+ f"{DOC_BASE}/einvoice/render",
+ json=_invoice_payload(),
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 403
+
+
+# ─── API: AI block suggestion (L4) ────────────────────────────────────────────
+
+
+_SUGGEST_VALID_BLOCKS = {
+ "blocks": [
+ {
+ "id": "b1",
+ "type": "text",
+ "config": {
+ "content": "Sehr geehrte Damen und Herren,\n\nhiermit offerieren wir {{name}} folgende Leistungen:"
+ },
+ },
+ {
+ "id": "b2",
+ "type": "shape",
+ "config": {"shape": "line", "width": "100%", "height": 2, "color": "#111827"},
+ },
+ ],
+ "notes": "Anrede mit Firmenname + Trennlinie",
+}
+
+
+@pytest.mark.asyncio
+class TestDocumentSuggest:
+ async def test_suggest_returns_validated_blocks(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ with patch(
+ "app.plugins.builtins.report_generator.documents.llm_complete",
+ AsyncMock(return_value={"content": json.dumps(_SUGGEST_VALID_BLOCKS)}),
+ ) as mock_llm:
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={
+ "prompt": "Erstelle eine Angebotsvorlage mit Anrede und Trennlinie",
+ "entity_type": "company",
+ },
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 200, f"{resp.status_code} {resp.text}"
+ body = resp.json()
+ assert len(body["blocks"]) == 2
+ assert body["blocks"][0]["type"] == "text"
+ assert "{{name}}" in body["blocks"][0]["config"]["content"]
+ assert body["blocks"][1]["type"] == "shape"
+ assert body["notes"] != ""
+ # LLM call wired through the central client (cost tracking params)
+ assert mock_llm.await_count == 1
+ kwargs = mock_llm.await_args.kwargs
+ assert kwargs["model"] == "gpt-4o-mini"
+ assert kwargs["messages"][0]["role"] == "system"
+
+ async def test_suggest_filters_invalid_blocks(
+ self, docs_client: AsyncClient, db_session
+ ):
+ """Invalid AI blocks (unknown type, empty text) are filtered, valid
+ ones survive even without an id (id gets assigned server-side)."""
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ ai_response = {
+ "blocks": [
+ {"type": "text", "config": {"content": "gültiger Block"}},
+ {"type": "no_such_block_type", "config": {}},
+ {"type": "text", "config": {}}, # empty content → invalid
+ ],
+ "notes": "",
+ }
+ with patch(
+ "app.plugins.builtins.report_generator.documents.llm_complete",
+ AsyncMock(return_value={"content": json.dumps(ai_response)}),
+ ):
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={"prompt": "Angebot mit Betreffzeile"},
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert len(body["blocks"]) == 1
+ assert body["blocks"][0]["type"] == "text"
+ assert body["blocks"][0]["id"] # server-assigned id
+
+ async def test_suggest_handles_code_fenced_json(
+ self, docs_client: AsyncClient, db_session
+ ):
+ """LLMs love ```json fences — the endpoint must strip them."""
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ fenced = "```json\n" + json.dumps(_SUGGEST_VALID_BLOCKS) + "\n```"
+ with patch(
+ "app.plugins.builtins.report_generator.documents.llm_complete",
+ AsyncMock(return_value={"content": fenced}),
+ ):
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={"prompt": "Angebot erstellen"},
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 200
+ assert len(resp.json()["blocks"]) == 2
+
+ async def test_suggest_llm_failure_502(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ with patch(
+ "app.plugins.builtins.report_generator.documents.llm_complete",
+ AsyncMock(side_effect=RuntimeError("provider down")),
+ ):
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={"prompt": "Angebot erstellen"},
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 502
+ assert resp.json()["detail"]["code"] == "ai_unavailable"
+
+ async def test_suggest_non_json_response_502(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "admin@tenanta.com")
+ with patch(
+ "app.plugins.builtins.report_generator.documents.llm_complete",
+ AsyncMock(return_value={"content": "Tut mir leid, ich verstehe nicht."}),
+ ):
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={"prompt": "Angebot erstellen"},
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 502
+ assert resp.json()["detail"]["code"] == "invalid_ai_response"
+
+ async def test_suggest_viewer_forbidden_403(
+ self, docs_client: AsyncClient, db_session
+ ):
+ await seed_tenant_and_users(db_session)
+ await login_client(docs_client, "viewer@tenanta.com")
+ resp = await docs_client.post(
+ f"{DOC_BASE}/documents/suggest",
+ json={"prompt": "Angebot erstellen"},
+ headers=ORIGIN_HEADER,
+ )
+ assert resp.status_code == 403