feat(lexware): add integration foundation
This commit is contained in:
parent
8d37eb29b3
commit
ffffb68898
28 changed files with 1150 additions and 11 deletions
|
|
@ -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."):
|
||||
|
|
|
|||
69
backend/hermes/app/api/lexware.py
Normal file
69
backend/hermes/app/api/lexware.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
23
backend/hermes/app/models/lexware.py
Normal file
23
backend/hermes/app/models/lexware.py
Normal 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)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
81
backend/hermes/app/schemas/lexware.py
Normal file
81
backend/hermes/app/schemas/lexware.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
361
backend/hermes/app/services/lexware_service.py
Normal file
361
backend/hermes/app/services/lexware_service.py
Normal 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 {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue