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
@@ -10,12 +10,14 @@ from __future__ import annotations
import io
import uuid as uuid_mod
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete
from app.core.audit import log_audit
from app.core.db import get_db, set_tenant_context
from app.core.storage import get_storage_backend
@@ -27,10 +29,17 @@ from app.plugins.builtins.report_generator.document_blocks import (
)
from app.plugins.builtins.report_generator.document_renderer import (
collect_block_asset_ids,
collect_placeholder_defaults,
load_assets_data_urls,
merge_placeholder_defaults,
render_document_html,
)
from app.plugins.builtins.report_generator.einvoice import (
EInvoiceValidationError,
render_einvoice_xml,
resolve_einvoice_data,
validate_einvoice_data,
)
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
@@ -42,6 +51,11 @@ from app.plugins.builtins.report_generator.schemas import (
DocumentPreviewRequest,
DocumentPreviewResponse,
DocumentRenderRequest,
DocumentSuggestRequest,
DocumentSuggestResponse,
EInvoiceRenderForRequest,
EInvoiceRenderRequest,
EInvoiceValidationResponse,
LetterheadCreate,
LetterheadResponse,
LetterheadUpdate,
@@ -708,3 +722,234 @@ async def render_print_template(
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ────────────────────
@router.post("/einvoice/render")
async def render_einvoice(
body: EInvoiceRenderRequest,
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render inline invoice data to EN16931/XRechnung CII XML.
Pure format endpoint - the sales module later uses render-for with
its einvoice_data() contract hook.
"""
data = body.model_dump(exclude_none=True)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
xml = render_einvoice_xml(data)
filename = f"{data['invoice_number'].replace(' ', '_')}.xml"
return Response(
content=xml,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/einvoice/validate")
async def validate_einvoice(
body: EInvoiceRenderRequest,
current_user: dict = Depends(require_permission("reports:read")),
):
"""Validate invoice data without rendering (missing BT/BG terms -> 422)."""
data = body.model_dump(exclude_none=True)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
return EInvoiceValidationResponse(valid=True).model_dump()
@router.post("/einvoice/render-for")
async def render_einvoice_for_entity(
body: EInvoiceRenderForRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render an e-invoice for an entity via the einvoice_data() contract.
Docking point for the future sales module: it contributes
einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint
handles validation + XML generation. No contribution -> 404.
"""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
entity_id = _parse_uuid(body.entity_id, "entity_id")
data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id)
if data is None:
raise HTTPException(
404,
detail={
"detail": (
f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - "
"Modul nicht aktiv oder Entitaet unbekannt"
),
"code": "no_data_source",
},
)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
xml = render_einvoice_xml(data)
invoice_number = str(data.get("invoice_number") or "einvoice")
filename = f"{invoice_number.replace(' ', '_')}.xml"
return Response(
content=xml,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
_SUGGEST_HEADER = (
"Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n"
"Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n"
"Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n"
'{"blocks": [{"id": "b1", "type": "<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
data_url: str | None = None
created_at: datetime | None = None
# ─── E-Invoice (EN16931/XRechnung, Phase L5) ────────────────────────────────
class EInvoiceAddress(BaseModel):
"""Postal address (BT-50..53 seller, BT-65..68 buyer)."""
street: str | None = None
postal_code: str | None = None
city: str | None = None
country: str | None = None
class EInvoiceLineItem(BaseModel):
"""Invoice line (BG-25). ``line_net_amount`` wins over qty*price."""
name: str = ""
quantity: float = 1.0
unit: str = "HUR"
unit_net_price: float = 0.0
vat_rate: float = 0.0
line_net_amount: float | None = None
class EInvoiceRenderRequest(BaseModel):
"""Inline invoice data — field-level semantics validated by
``einvoice.validate_einvoice_data`` (BT/BG business terms, 422)."""
invoice_number: str = ""
issue_date: str = ""
type_code: str = "380"
currency: str = "EUR"
due_date: str | None = None
delivery_date: str | None = None
buyer_name: str = ""
buyer_reference: str | None = None
buyer_address: EInvoiceAddress | None = None
seller_name: str = ""
seller_vat_id: str | None = None
seller_tax_id: str | None = None
seller_address: EInvoiceAddress | None = None
payment_means_code: str | None = None
payment_terms_text: str | None = None
note: str | None = None
line_items: list[EInvoiceLineItem] = Field(default_factory=list)
profile: str = Field("en16931", pattern="^(en16931|xrechnung)$")
class EInvoiceRenderForRequest(BaseModel):
"""Render an e-invoice for an entity via the ``einvoice_data()``
contract hook (future sales module docking point)."""
entity_type: str = Field(..., min_length=1, max_length=100)
entity_id: str = Field(..., min_length=1)
class EInvoiceValidationResponse(BaseModel):
valid: bool
missing: list[str] = Field(default_factory=list)
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
class DocumentSuggestRequest(BaseModel):
"""Natural-language block composition request for the template editor."""
prompt: str = Field(..., min_length=1, max_length=2000)
entity_type: str | None = Field(None, max_length=100)
class DocumentSuggestResponse(BaseModel):
blocks: list[dict]
notes: str = ""