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

+ {/* AI suggestion (L4) */} +
+ +

+ {t('documents.ai.hint', 'Beschreibe die Vorlage — die KI schlägt Blöcke vor und fügt sie hinzu.')} +

+
+