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:
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user