feat(L4-L5): KI-Steuerung + XRechnung-Format-Layer (EN16931/CII)
Check Cross-Plugin Imports / check (push) Has been cancelled

L5 Format-Layer (User-Klaerung: Verkaufsmodul spaeter, Format JETZT):
- einvoice.py: EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes, Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (XRechnung 3.0)
- POST /einvoice/render (inline->XML), /einvoice/validate (422 mit BT-Fehlliste), /einvoice/render-for (Contract-Resolver einvoice_data() — Andockpunkt Verkaufsmodul, 404 no_data_source ohne Beitrag)

L4 KI-Steuerung:
- POST /documents/suggest: Natuerliche Sprache -> Block-Komposition via zentralem llm_complete (Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungueltige KI-Bloecke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles-Icon, Prompt-Textarea, Bloecke werden angehaengt), i18n de/en

TDD: Rot 25 failed -> Gruen 25/25 (Validierung, XML-Struktur/Escaping/Summen, Contract-Mocks, API 200/422/403/404, Suggest Mock-LLM/Fence/502). tsc exit 0, Build OK, ruff clean. Doku: api-documentation.md, plugin-development-guide.md (einvoice_data-Contract), PROGRESS.md
This commit is contained in:
Agent Zero
2026-08-29 18:05:55 +02:00
parent b311ab7aa1
commit 559bba69a4
11 changed files with 1363 additions and 1 deletions
+18
View File
@@ -58,6 +58,24 @@
- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul) - 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) - 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) ✅ ## 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. **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.
@@ -10,12 +10,14 @@ from __future__ import annotations
import io import io
import uuid as uuid_mod 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 fastapi.responses import StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.db import get_db, set_tenant_context from app.core.db import get_db, set_tenant_context
from app.core.storage import get_storage_backend 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 ( from app.plugins.builtins.report_generator.document_renderer import (
collect_block_asset_ids, collect_block_asset_ids,
collect_placeholder_defaults,
load_assets_data_urls, load_assets_data_urls,
merge_placeholder_defaults, merge_placeholder_defaults,
render_document_html, 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 ( from app.plugins.builtins.report_generator.models import (
DocumentAsset, DocumentAsset,
Letterhead, Letterhead,
@@ -42,6 +51,11 @@ from app.plugins.builtins.report_generator.schemas import (
DocumentPreviewRequest, DocumentPreviewRequest,
DocumentPreviewResponse, DocumentPreviewResponse,
DocumentRenderRequest, DocumentRenderRequest,
DocumentSuggestRequest,
DocumentSuggestResponse,
EInvoiceRenderForRequest,
EInvoiceRenderRequest,
EInvoiceValidationResponse,
LetterheadCreate, LetterheadCreate,
LetterheadResponse, LetterheadResponse,
LetterheadUpdate, LetterheadUpdate,
@@ -708,3 +722,234 @@ async def render_print_template(
media_type="application/pdf", media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, 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": "<block-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()
@@ -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
@@ -162,3 +162,78 @@ class DocumentAssetResponse(BaseModel):
size_bytes: int size_bytes: int
data_url: str | None = None data_url: str | None = None
created_at: datetime | 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 = ""
+4
View File
@@ -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-blocks` | Block-Registry (Built-in + Modul-Beiträge, Phase L1). |
| GET | `/api/v1/reports/document-placeholders` | Platzhalter-Registry pro Entity-Type (Modul-Beiträge). | | 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/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) ### entity-links (Entity Linking)
+20
View File
@@ -2690,3 +2690,23 @@ class MyContract:
- `document_placeholders`-Beispiele dienen als Preview-Fallbacks (StrictUndefined-vermeidend) - `document_placeholders`-Beispiele dienen als Preview-Fallbacks (StrictUndefined-vermeidend)
- Built-in-Bloecke duerfen nicht ueberschrieben werden (Contributions mit existierendem Typ werden ignoriert) - 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) - 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`.
+22
View File
@@ -294,3 +294,25 @@ export async function renderPrintTemplate(input: RenderInput): Promise<Blob> {
export function useRenderPrintTemplate() { export function useRenderPrintTemplate() {
return useMutation({ mutationFn: renderPrintTemplate }); 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<SuggestResult, Error, SuggestInput>({
mutationFn: (input: SuggestInput) =>
apiPost<SuggestResult>('/reports/documents/suggest', {
prompt: input.prompt,
entity_type: input.entityType ?? null,
}),
});
}
@@ -7,6 +7,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -15,6 +16,7 @@ import {
useCreatePrintTemplate, useCreatePrintTemplate,
useUpdatePrintTemplate, useUpdatePrintTemplate,
useDocumentPlaceholders, useDocumentPlaceholders,
useSuggestDocumentBlocks,
type DocBlock, type DocBlock,
type Letterhead, type Letterhead,
type PrintTemplate, type PrintTemplate,
@@ -36,6 +38,27 @@ export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTem
const [letterheadId, setLetterheadId] = useState<string>(template?.letterhead_id ?? ''); const [letterheadId, setLetterheadId] = useState<string>(template?.letterhead_id ?? '');
const [entityType, setEntityType] = useState<string>(template?.entity_type ?? 'contact'); const [entityType, setEntityType] = useState<string>(template?.entity_type ?? 'contact');
const [blocks, setBlocks] = useState<DocBlock[]>(template?.blocks ?? []); const [blocks, setBlocks] = useState<DocBlock[]>(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 createMutation = useCreatePrintTemplate();
const updateMutation = useUpdatePrintTemplate(); const updateMutation = useUpdatePrintTemplate();
@@ -140,6 +163,39 @@ export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTem
</p> </p>
</div> </div>
{/* AI suggestion (L4) */}
<div className="border border-primary-200 rounded-lg p-3 bg-primary-50/50" data-testid="ai-suggest-panel">
<label className="text-xs font-semibold text-primary-700 uppercase flex items-center gap-1" htmlFor="ai-prompt">
<Sparkles className="w-3.5 h-3.5" aria-hidden="true" />
{t('documents.ai.title', 'KI-Vorschlag')}
</label>
<p className="text-xs text-secondary-500 mt-1">
{t('documents.ai.hint', 'Beschreibe die Vorlage — die KI schlägt Blöcke vor und fügt sie hinzu.')}
</p>
<div className="flex gap-2 mt-2">
<textarea
id="ai-prompt"
className="flex-1 rounded-md border border-secondary-300 px-3 py-2 text-sm min-h-[60px] focus:outline-none focus:ring-2 focus:ring-primary-500"
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
placeholder={t('documents.ai.placeholder', 'z.B. Angebotsvorlage mit Anrede, Firmenname, Leistungsliste und Grußformel')}
data-testid="ai-prompt-input"
/>
<Button
size="sm"
onClick={handleSuggest}
isLoading={suggest.isPending}
icon={<Sparkles className="w-4 h-4" aria-hidden="true" />}
data-testid="ai-suggest-button"
>
{t('documents.ai.suggest', 'Vorschlagen')}
</Button>
</div>
{aiNotes && (
<p className="text-xs text-secondary-600 mt-2" data-testid="ai-suggest-notes">{aiNotes}</p>
)}
</div>
{/* Block editor (content blocks + letterhead frame preview) */} {/* Block editor (content blocks + letterhead frame preview) */}
<BlockEditor <BlockEditor
blocks={blocks} blocks={blocks}
+9
View File
@@ -1559,6 +1559,15 @@
"generated": "Dokument wurde erstellt", "generated": "Dokument wurde erstellt",
"generateFailed": "Dokument konnte nicht erstellt werden", "generateFailed": "Dokument konnte nicht erstellt werden",
"noTemplates": "Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen." "noTemplates": "Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen."
},
"ai": {
"title": "KI-Vorschlag",
"hint": "Beschreibe die Vorlage — die KI schlägt Blöcke vor und fügt sie hinzu.",
"placeholder": "z.B. Angebotsvorlage mit Anrede, Firmenname, Leistungsliste und Grußformel",
"suggest": "Vorschlagen",
"suggested": "{{count}} Blöcke übernommen",
"noBlocks": "KI hat keine gültigen Blöcke vorgeschlagen",
"failed": "KI-Vorschlag fehlgeschlagen"
} }
} }
} }
+9
View File
@@ -1559,6 +1559,15 @@
"generated": "Document created", "generated": "Document created",
"generateFailed": "Document could not be created", "generateFailed": "Document could not be created",
"noTemplates": "No print templates for this data type. Create templates under Settings → Documents." "noTemplates": "No print templates for this data type. Create templates under Settings → Documents."
},
"ai": {
"title": "AI Suggestion",
"hint": "Describe the template — the AI suggests blocks and adds them.",
"placeholder": "e.g. quote template with salutation, company name, service list and closing",
"suggest": "Suggest",
"suggested": "{{count}} blocks added",
"noBlocks": "AI did not suggest valid blocks",
"failed": "AI suggestion failed"
} }
} }
} }
+565
View File
@@ -0,0 +1,565 @@
"""E-Invoice (EN16931 / XRechnung CII) format layer + AI block suggestions — Phase L4/L5.
L5 (Format-Layer): inline Rechnungsdaten valides CII-XML. Das
Verkaufsmodul kommt später und dockt über den Contract-Hook
``einvoice_data()`` an (POST /einvoice/render-for).
L4: KI-Vorschlag für Block-Kompositionen. llm_complete wird in allen
Tests gemockt niemals echte LLM-Calls (Budget, Deterministik).
"""
from __future__ import annotations
import json
import uuid as uuid_mod
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
import xml.etree.ElementTree as ET
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from app.core.db import close_engine, reset_engine_for_testing
from app.core.permission_registry import init_permission_registry
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import (
ORIGIN_HEADER,
login_client,
seed_tenant_and_users,
)
DOC_BASE = "/api/v1/reports"
@pytest_asyncio.fixture
async def docs_app(engine: AsyncEngine, redis_client):
"""App with permissions + report_generator installed & activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
init_permission_registry(active_plugin_names={"permissions", "report_generator"})
container = get_container()
await container.initialize()
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(ReportGeneratorPlugin())
reset_plugin_service_for_testing(registry)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "report_generator")
await registry.activate(session, "report_generator")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def docs_client(docs_app) -> 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 <GmbH>")
xml = render_einvoice_xml(payload)
assert "A&amp;B &lt;GmbH&gt;" in xml
# raw unescaped angle bracket must not appear in the name element
assert "<GmbH>" 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