feat(L4-L5): KI-Steuerung + XRechnung-Format-Layer (EN16931/CII)
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
+18
@@ -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 = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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&B <GmbH>" 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
|
||||||
Reference in New Issue
Block a user