feat(lexware): add integration foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 13:10:27 +02:00
parent 8d37eb29b3
commit ffffb68898
28 changed files with 1150 additions and 11 deletions

View file

@ -31,3 +31,8 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=

View file

@ -0,0 +1,113 @@
"""add lexware foundation
Revision ID: c9d4e5f6a7b8
Revises: a7c3e9d4b821
Create Date: 2026-07-05 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c9d4e5f6a7b8"
down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEXWARE_PERMISSIONS = [
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
]
def upgrade() -> None:
op.create_table(
"lexware_sync_records",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity_type", sa.String(length=80), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=False),
sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
sa.Column("payload_summary", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
for name, display_name, description, module in LEXWARE_PERMISSIONS:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (:name, :display_name, :description, :module)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
role_permissions = {
"administrator": [item[0] for item in LEXWARE_PERMISSIONS],
"management": [item[0] for item in LEXWARE_PERMISSIONS],
"support": ["lexware.read"],
}
for role_name, permission_names in role_permissions.items():
for permission_name in permission_names:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'lexware'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'lexware'")
op.drop_column("repair_estimates", "lexware_synced_at")
op.drop_column("repair_estimates", "lexware_invoice_status")
op.drop_column("repair_estimates", "lexware_invoice_number")
op.drop_column("repair_estimates", "lexware_invoice_id")
op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
op.drop_table("lexware_sync_records")

View file

@ -62,6 +62,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "repair_estimates.read" in permissions
if action.startswith("inventory."):
return "inventory.read" in permissions
if action.startswith("lexware."):
return "lexware.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):

View file

@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.lexware import LexwareInvoicePreparationResponse, LexwareSettingsResponse, LexwareSettingsUpdate, LexwareTestConnectionResponse
from app.services.lexware_service import LexwareService
router = APIRouter(tags=["Lexware"])
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
repair = RepairRepository.get_by_id(db, repair_id)
if repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
return repair
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
if estimate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
return estimate
@router.get("/lexware/settings", response_model=LexwareSettingsResponse)
def get_lexware_settings(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.read")),
):
return LexwareService.get_settings(db)
@router.put("/lexware/settings", response_model=LexwareSettingsResponse)
def update_lexware_settings(
payload: LexwareSettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.manage")),
):
return LexwareService.update_settings(db, payload, actor=current_user, request=request)
@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse)
def test_lexware_connection(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.manage")),
):
return LexwareService.test_connection(db, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse)
def prepare_lexware_invoice(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.export")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)

View file

@ -32,6 +32,9 @@ class Settings(BaseSettings):
smtp_from_email: str | None = None
smtp_from_name: str = "Funktechnik Schubert"
smtp_use_tls: bool = True
lexware_enabled: bool = False
lexware_api_base_url: str = "https://api.lexware.io"
lexware_api_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -28,6 +28,7 @@ import app.models.user
import app.models.repair
import app.models.repair_estimate
import app.models.system_setting
import app.models.lexware
def get_db():

View file

@ -15,6 +15,7 @@ from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.inventory import router as inventory_router
from app.api.knowledge import router as knowledge_router
from app.api.lexware import router as lexware_router
from app.api.permissions import router as permissions_router
from app.api.repairs import router as repairs_router
from app.api.repair_estimates import router as repair_estimates_router
@ -48,6 +49,7 @@ app.include_router(repairs_router)
app.include_router(repair_estimates_router)
app.include_router(dashboard_router)
app.include_router(system_settings_router)
app.include_router(lexware_router)
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class LexwareSyncRecord(Base):
__tablename__ = "lexware_sync_records"
id: Mapped[int] = mapped_column(primary_key=True)
entity_type: Mapped[str] = mapped_column(String(80), index=True)
entity_id: Mapped[int] = mapped_column(Integer, index=True)
lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True)
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -27,6 +27,10 @@ class RepairEstimate(Base):
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
lexware_invoice_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View file

@ -99,6 +99,9 @@ STANDARD_PERMISSIONS = [
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
]
ROLE_PERMISSION_NAMES = {
@ -128,6 +131,9 @@ ROLE_PERMISSION_NAMES = {
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
"lexware.read",
"lexware.manage",
"lexware.export",
},
"sales": {
"dashboard.read",
@ -177,6 +183,7 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
"lexware.read",
},
"warehouse": {
"dashboard.read",

View file

@ -0,0 +1,81 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.schemas.system_setting import SettingsSource, normalize_text
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
LexwareSyncDirection = Literal["push", "pull"]
class LexwareSettingsResponse(BaseModel):
enabled: bool = False
api_base_url: str = "https://api.lexware.io"
api_key_is_set: bool = False
organization_name: str = ""
default_tax_rate: Decimal = Decimal("19.00")
default_payment_terms_days: int = 14
source: SettingsSource
class LexwareSettingsUpdate(BaseModel):
enabled: bool = False
api_base_url: str = Field(default="https://api.lexware.io", max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
organization_name: str = Field(default="", max_length=255)
default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100"))
default_payment_terms_days: int = Field(default=14, ge=0, le=365)
@field_validator("api_base_url", "api_key", "organization_name", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str:
return normalize_text(value)
@field_validator("api_base_url")
@classmethod
def normalize_base_url(cls, value: str) -> str:
return (value or "https://api.lexware.io").rstrip("/")
@model_validator(mode="after")
def validate_enabled_configuration(self):
if self.enabled and not self.api_base_url:
raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist")
return self
class LexwareTestConnectionResponse(BaseModel):
success: bool
message: str
source: SettingsSource
api_base_url: str
organization_name: str = ""
class LexwareCustomerMapping(BaseModel):
name: str
email: str
phone: str
search_strategy: str
create_payload: dict
class LexwareLineItemMapping(BaseModel):
title: str
description: str | None
quantity: Decimal
unit: str
unit_price: Decimal
tax_rate: Decimal
total: Decimal
class LexwareInvoicePreparationResponse(BaseModel):
ready_for_export: bool
payload_summary: dict
customer_mapping: LexwareCustomerMapping
line_item_mapping: list[LexwareLineItemMapping]
tax_mapping: dict
warnings: list[str]
sync_record_id: int

View file

@ -142,6 +142,10 @@ class RepairEstimateResponse(BaseModel):
approved_at: datetime | None
declined_at: datetime | None
customer_response_message: str | None
lexware_invoice_id: str | None
lexware_invoice_number: str | None
lexware_invoice_status: str | None
lexware_synced_at: datetime | None
created_by_user_id: int | None
created_at: datetime
updated_at: datetime

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@ -207,6 +207,11 @@ def action_title(action: str) -> str:
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
"lexware.settings.update": "Lexware Einstellungen geändert",
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
}
return labels.get(action, action)

View file

@ -0,0 +1,361 @@
import json
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest
from urllib.request import urlopen
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.lexware import LexwareSyncRecord
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.lexware import (
LexwareCustomerMapping,
LexwareInvoicePreparationResponse,
LexwareLineItemMapping,
LexwareSettingsResponse,
LexwareSettingsUpdate,
LexwareTestConnectionResponse,
)
from app.schemas.system_setting import SettingsSource
from app.services.audit_service import write_audit_log
from app.services.system_settings_service import parse_bool
LEXWARE_KEYS = (
"lexware.enabled",
"lexware.api_base_url",
"lexware.api_key",
"lexware.organization_name",
"lexware.default_tax_rate",
"lexware.default_payment_terms_days",
)
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
DEFAULT_API_BASE_URL = "https://api.lexware.io"
@dataclass(frozen=True)
class LexwareRuntimeConfig:
enabled: bool
api_base_url: str
api_key: str
organization_name: str
default_tax_rate: Decimal
default_payment_terms_days: int
source: SettingsSource
@property
def api_key_is_set(self) -> bool:
return bool(self.api_key)
@property
def is_configured(self) -> bool:
return self.enabled and bool(self.api_base_url and self.api_key)
def _decimal(value: object, *, default: Decimal) -> Decimal:
try:
return Decimal(str(value or "").replace(",", "."))
except Exception:
return default
def _int(value: object, *, default: int) -> int:
try:
parsed = int(str(value or "").strip())
except ValueError:
return default
return parsed if 0 <= parsed <= 365 else default
def _euros(cents: int) -> Decimal:
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _safe_error_message(error: Exception) -> str:
if isinstance(error, HTTPError):
if error.code in {401, 403}:
return "Lexware hat den API-Key abgelehnt."
if error.code == 404:
return "Lexware-Endpunkt wurde nicht gefunden."
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
if isinstance(error, URLError):
return "Lexware ist momentan nicht erreichbar."
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
class LexwareService:
@staticmethod
def get_settings(db: Session) -> LexwareSettingsResponse:
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
@staticmethod
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
db_api_key = values.get("lexware.api_key") or ""
db_has_config = db_enabled or db_base_url or db_api_key
if db_has_config:
return LexwareRuntimeConfig(
enabled=db_enabled,
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
api_key=db_api_key,
organization_name=values.get("lexware.organization_name", ""),
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
source="database",
)
if settings.lexware_enabled or settings.lexware_api_key:
return LexwareRuntimeConfig(
enabled=settings.lexware_enabled,
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
api_key=settings.lexware_api_key or "",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="environment",
)
return LexwareRuntimeConfig(
enabled=False,
api_base_url=DEFAULT_API_BASE_URL,
api_key="",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="missing",
)
@staticmethod
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
updates = {
"lexware.enabled": "true" if payload.enabled else "false",
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
"lexware.api_key": api_key,
"lexware.organization_name": payload.organization_name,
"lexware.default_tax_rate": str(payload.default_tax_rate),
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
}
for key, value in updates.items():
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
db.commit()
write_audit_log(
db,
action="lexware.settings.update",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={
"enabled": payload.enabled,
"api_base_url": payload.api_base_url,
"organization_name": payload.organization_name,
"default_tax_rate": str(payload.default_tax_rate),
"default_payment_terms_days": payload.default_payment_terms_days,
"api_key_changed": bool(payload.api_key),
},
)
return LexwareService.get_settings(db)
@staticmethod
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
config = LexwareService.get_runtime_config(db)
if not config.is_configured:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": "lexware_not_configured", "source": config.source},
)
return LexwareTestConnectionResponse(
success=False,
message="Lexware ist nicht vollständig konfiguriert.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
try:
profile = LexwareService._get_profile(config)
except Exception as exc:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
)
return LexwareTestConnectionResponse(
success=False,
message=_safe_error_message(exc),
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
write_audit_log(
db,
action="lexware.connection.test_success",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
)
return LexwareTestConnectionResponse(
success=True,
message="Lexware-Verbindung erfolgreich geprüft.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=organization_name,
)
@staticmethod
def prepare_invoice(
db: Session,
repair: Repair,
estimate: RepairEstimate,
*,
actor: User,
request: Request,
) -> LexwareInvoicePreparationResponse:
config = LexwareService.get_runtime_config(db)
if estimate.status != "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
warnings: list[str] = []
if not config.enabled:
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
if not config.api_key_is_set:
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
if not repair.customer_email:
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
if not estimate.items:
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
customer_payload = {
"roles": {"customer": {}},
"company": {"name": repair.customer_name},
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
}
customer_mapping = LexwareCustomerMapping(
name=repair.customer_name,
email=repair.customer_email,
phone=repair.customer_phone,
search_strategy="email" if repair.customer_email else "name",
create_payload=customer_payload,
)
line_items = [
LexwareLineItemMapping(
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price=_euros(item.unit_price_cents),
tax_rate=estimate.tax_rate_percent,
total=_euros(item.total_cents),
)
for item in estimate.items
]
payload_summary = {
"type": "invoice",
"title": f"Rechnung zu Reparatur {repair.repair_number}",
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
"repair_number": repair.repair_number,
"estimate_number": estimate.estimate_number,
"currency": estimate.currency,
"payment_terms_days": config.default_payment_terms_days,
"subtotal": str(_euros(estimate.subtotal_cents)),
"tax": str(_euros(estimate.tax_cents)),
"total": str(_euros(estimate.total_cents)),
"line_item_count": len(line_items),
}
record = LexwareSyncRecord(
entity_type="repair_estimate",
entity_id=estimate.id,
lexware_resource_type="invoice",
status="pending" if not warnings else "skipped",
direction="push",
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
error_message="; ".join(warnings) if warnings else None,
)
db.add(record)
db.commit()
db.refresh(record)
write_audit_log(
db,
action="lexware.invoice.prepare",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"ready_for_export": not warnings,
"sync_record_id": record.id,
},
)
return LexwareInvoicePreparationResponse(
ready_for_export=not warnings,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_item_mapping=line_items,
tax_mapping={
"source": "repair_estimate",
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
"default_tax_rate": str(config.default_tax_rate),
"tax_amount": str(_euros(estimate.tax_cents)),
},
warnings=warnings,
sync_record_id=record.id,
)
@staticmethod
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
return LexwareSettingsResponse(
enabled=config.enabled,
api_base_url=config.api_base_url,
api_key_is_set=config.api_key_is_set,
organization_name=config.organization_name,
default_tax_rate=config.default_tax_rate,
default_payment_terms_days=config.default_payment_terms_days,
source=config.source,
)
@staticmethod
def _get_profile(config: LexwareRuntimeConfig) -> dict:
request = UrlRequest(
f"{config.api_base_url}/v1/profile",
headers={
"Authorization": f"Bearer {config.api_key}",
"Accept": "application/json",
},
method="GET",
)
with urlopen(request, timeout=15) as response:
body = response.read().decode("utf-8")
if not body:
return {}
data = json.loads(body)
return data if isinstance(data, dict) else {}

View file

@ -22,6 +22,9 @@ services:
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage