diff --git a/.env.example b/.env.example index afd6786..77728fe 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,8 @@ SMTP_PASSWORD= SMTP_FROM_EMAIL= SMTP_FROM_NAME=Funktechnik Schubert SMTP_USE_TLS=true + +# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings. +LEXWARE_ENABLED=false +LEXWARE_API_BASE_URL=https://api.lexware.io +LEXWARE_API_KEY= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 58f6fea..a35aa71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,6 +166,48 @@ Hermes Athena ist die einzige API-Oberflaeche fuer den Browser. +## Lexware Office Integration + +Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation. + +Verantwortlichkeiten: + +- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege. +- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR. +- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt. + +Komponenten: + +- Hermes speichert Lexware-Konfiguration in `system_settings`. +- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`. +- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`. +- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`. +- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden. + +Datenfluss: + +```text +Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office +Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL +``` + +Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert. + +Neue zentrale Endpunkte: + +- `GET /lexware/settings` +- `PUT /lexware/settings` +- `POST /lexware/test-connection` +- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice` + +RBAC: + +- `lexware.read` +- `lexware.manage` +- `lexware.export` + +Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen. + ## Dashboard Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`. diff --git a/README-DEV.md b/README-DEV.md index 3e515cd..ebb89d2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -42,10 +42,15 @@ SMTP_PASSWORD= SMTP_FROM_EMAIL= SMTP_FROM_NAME=Funktechnik Schubert SMTP_USE_TLS=true +LEXWARE_ENABLED=false +LEXWARE_API_BASE_URL=https://api.lexware.io +LEXWARE_API_KEY= ``` `PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. +`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben. + Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. ## Docker Netzwerk @@ -179,6 +184,32 @@ Statuslink-Konzept: - Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`. - Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen. +## Lexware Office Foundation + +Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet. + +Rollenverteilung: + +- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten. +- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR. + +Konfiguration: + +- Adminbereich: `/settings -> Lexware Office` +- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection` +- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection` +- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY` + +Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf. + +Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden: + +```text +POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice +``` + +v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag. + Benachrichtigungen: - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. diff --git a/ROADMAP.md b/ROADMAP.md index e92efb2..20bd205 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,13 +176,35 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv - CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures -## v0.8.9 - Kundenportal, geplant +## v0.8.9 - Lexware Office Integration Foundation + +- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert +- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten +- Lexware-Konfiguration unter `/settings -> Lexware Office` +- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel +- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY` +- API-Key wird nicht an Athena zurueckgegeben +- Server-seitiger Verbindungstest gegen `GET /v1/profile` +- Neue Tabelle `lexware_sync_records` +- Optionale Lexware-Rechnungsreferenzen an `repair_estimates` +- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs +- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe +- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export` + +## v0.9.0 - Lexware Rechnungserstellung, geplant + +- Echte Rechnungserstellung in Lexware nach final geprueftem API-Mapping +- Kontaktabgleich und Kontaktanlage in Lexware produktionsreif ausbauen +- Exportstatus und Fehlerbehebung im Olympus UI erweitern +- Optionaler Download/Link zur Lexware-Rechnung + +## v0.9.1 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden - Keine Vermischung mit internen Olympus-Benutzern -## v0.9.0 - Tickets, geplant +## v0.9.2 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell @@ -190,10 +212,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - RBAC-Permissions fuer Tickets - Audit Logs fuer Ticketaktionen -## v0.10.0 - Integrationen Paperless/Lexoffice, geplant +## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant - Paperless-ngx Connector fuer Wissensdokumente -- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten +- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen - Sichere Connector-Konfiguration ohne Browser-Secrets - Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 6f7a6d0..cfaeb3b 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -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= diff --git a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py new file mode 100644 index 0000000..bf98008 --- /dev/null +++ b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py @@ -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") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index d81a4ba..eb8ed14 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -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."): diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py new file mode 100644 index 0000000..fc255f9 --- /dev/null +++ b/backend/hermes/app/api/lexware.py @@ -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) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 9590c60..23c8c71 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -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", diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index dcc29f4..260eb13 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -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(): diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 3b7c410..ffb31e2 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -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__) diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py new file mode 100644 index 0000000..5f07528 --- /dev/null +++ b/backend/hermes/app/models/lexware.py @@ -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) diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 696a060..b98603b 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -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()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 78e0346..9974a8b 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -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", diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py new file mode 100644 index 0000000..7723042 --- /dev/null +++ b/backend/hermes/app/schemas/lexware.py @@ -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 diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 1473304..404c0a5 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -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 diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 787cd0b..9ae8a41 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -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) diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py new file mode 100644 index 0000000..90c70c2 --- /dev/null +++ b/backend/hermes/app/services/lexware_service.py @@ -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 {} diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index a2215df..660b1e5 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5ad8a33..94bf31f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ services: SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert} SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + 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 diff --git a/frontend/athena/app/api/lexware/settings/route.ts b/frontend/athena/app/api/lexware/settings/route.ts new file mode 100644 index 0000000..3eb117a --- /dev/null +++ b/frontend/athena/app/api/lexware/settings/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/lexware/settings"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/settings"); +} diff --git a/frontend/athena/app/api/lexware/test-connection/route.ts b/frontend/athena/app/api/lexware/test-connection/route.ts new file mode 100644 index 0000000..521df64 --- /dev/null +++ b/frontend/athena/app/api/lexware/test-connection/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/test-connection"); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts new file mode 100644 index 0000000..9e3f9b7 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index b251eb4..9c4cec3 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -176,6 +176,7 @@ export default function RepairDetailPage({ params }: Params) { const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete"); const canSendEstimates = hasPermission(currentUser, "repair_estimates.send"); const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke"); + const canLexwareExport = hasPermission(currentUser, "lexware.export"); async function createPublicLink() { if (!repair || !canManagePublicLink) return; @@ -444,6 +445,7 @@ export default function RepairDetailPage({ params }: Params) { canDelete={canDeleteEstimates} canSend={canSendEstimates} canRevoke={canRevokeEstimates} + canLexwareExport={canLexwareExport} /> diff --git a/frontend/athena/app/settings/page.tsx b/frontend/athena/app/settings/page.tsx index 70b3494..f5e0059 100644 --- a/frontend/athena/app/settings/page.tsx +++ b/frontend/athena/app/settings/page.tsx @@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; -import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-react"; +import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react"; import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; +import type { + LexwareSettings, + LexwareSettingsPayload, + LexwareTestConnectionResponse, +} from "@/types/lexware"; import type { PublicLinksSettings, SettingsSource, @@ -16,7 +21,7 @@ import type { SmtpTestResponse, } from "@/types/system-settings"; -type SettingsTab = "smtp" | "public-links"; +type SettingsTab = "smtp" | "public-links" | "lexware"; const sourceLabels: Record = { database: "Admin-Konfiguration", @@ -46,6 +51,18 @@ function emptySmtpSettings(): SmtpSettings { }; } +function emptyLexwareSettings(): LexwareSettings { + return { + enabled: false, + api_base_url: "https://api.lexware.io", + api_key_is_set: false, + organization_name: "", + default_tax_rate: "19.00", + default_payment_terms_days: 14, + source: "missing", + }; +} + export default function SettingsPage() { const { showToast } = useToast(); const [activeTab, setActiveTab] = useState("smtp"); @@ -56,22 +73,28 @@ export default function SettingsPage() { repair_status_base_url: "", source: "missing", }); + const [lexware, setLexware] = useState(emptyLexwareSettings()); + const [newLexwareApiKey, setNewLexwareApiKey] = useState(""); const [loading, setLoading] = useState(true); const [savingSmtp, setSavingSmtp] = useState(false); const [sendingTest, setSendingTest] = useState(false); const [savingPublicLinks, setSavingPublicLinks] = useState(false); + const [savingLexware, setSavingLexware] = useState(false); + const [testingLexware, setTestingLexware] = useState(false); const [error, setError] = useState(""); const loadSettings = useCallback(async () => { setError(""); setLoading(true); try { - const [smtpResponse, publicLinksResponse] = await Promise.all([ + const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([ api.get("/system-settings/smtp"), api.get("/system-settings/public-links"), + api.get("/lexware/settings"), ]); setSmtp(smtpResponse.data); setPublicLinks(publicLinksResponse.data); + setLexware(lexwareResponse.data); } catch (err) { setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden.")); } finally { @@ -158,6 +181,56 @@ export default function SettingsPage() { } } + async function saveLexware() { + setSavingLexware(true); + try { + const payload: LexwareSettingsPayload = { + enabled: lexware.enabled, + api_base_url: lexware.api_base_url, + organization_name: lexware.organization_name, + default_tax_rate: lexware.default_tax_rate, + default_payment_terms_days: lexware.default_payment_terms_days, + }; + + if (newLexwareApiKey) { + payload.api_key = newLexwareApiKey; + } + + const response = await api.put("/lexware/settings", payload); + setLexware(response.data); + setNewLexwareApiKey(""); + showToast({ type: "success", title: "Lexware-Konfiguration gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "Lexware konnte nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Lexware-Einstellungen."), + }); + } finally { + setSavingLexware(false); + } + } + + async function testLexwareConnection() { + setTestingLexware(true); + try { + const response = await api.post("/lexware/test-connection"); + showToast({ + type: response.data.success ? "success" : "error", + title: response.data.success ? "Lexware-Verbindung erfolgreich" : "Lexware-Verbindung fehlgeschlagen", + description: response.data.message, + }); + } catch (err) { + showToast({ + type: "error", + title: "Lexware-Verbindung fehlgeschlagen", + description: getErrorMessage(err, "Die Verbindung konnte nicht geprüft werden."), + }); + } finally { + setTestingLexware(false); + } + } + if (loading) { return
Einstellungen werden geladen...
; } @@ -171,7 +244,7 @@ export default function SettingsPage() {

Einstellungen

-

SMTP-Versand und öffentliche Statuslinks verwalten

+

SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten

setActiveTab("smtp")}> @@ -180,6 +253,9 @@ export default function SettingsPage() { setActiveTab("public-links")}> Öffentliche Links + setActiveTab("lexware")}> + Lexware Office +
@@ -287,7 +363,7 @@ export default function SettingsPage() { - ) : ( + ) : activeTab === "public-links" ? (
@@ -319,6 +395,86 @@ export default function SettingsPage() {

+ ) : ( +
+
+
+
+ +

Lexware Office

+
+

+ Quelle: {sourceLabels[lexware.source]} + {lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""} +

+
+
+ + +
+
+ +
+ + + + setLexware((current) => ({ ...current, organization_name: event.target.value }))} /> + + + + setLexware((current) => ({ ...current, api_base_url: event.target.value }))} /> + + + + setNewLexwareApiKey(event.target.value)} + /> + + + + setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))} + /> + + + + setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))} + /> + +
+ +
+ +

+ Olympus bereitet Werkstatt- und KV-Daten vor. Lexware Office bleibt führend für Buchhaltung, + Rechnungen, Steuer und DATEV/EÜR. Der API-Key wird nicht angezeigt und nicht an den Browser zurückgegeben. +

+
+
)} ); diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 0a7650d..210e415 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, Undo2, XCircle } from "lucide-react"; +import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { useToast } from "@/components/common/ToastProvider"; @@ -21,6 +21,7 @@ import type { InventoryCategory, InventoryItem, } from "@/types/inventory"; +import type { LexwareInvoicePreparation } from "@/types/lexware"; import type { RepairEstimate, RepairEstimateItemType, @@ -164,6 +165,7 @@ type Props = { canDelete: boolean; canSend: boolean; canRevoke: boolean; + canLexwareExport: boolean; }; export default function RepairEstimatesSection({ @@ -175,6 +177,7 @@ export default function RepairEstimatesSection({ canDelete, canSend, canRevoke, + canLexwareExport, }: Props) { const { showToast } = useToast(); const [estimates, setEstimates] = useState([]); @@ -188,6 +191,8 @@ export default function RepairEstimatesSection({ const [deleteTarget, setDeleteTarget] = useState(null); const [cancelTarget, setCancelTarget] = useState(null); const [revokeTarget, setRevokeTarget] = useState(null); + const [lexwareResult, setLexwareResult] = useState(null); + const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -407,6 +412,24 @@ export default function RepairEstimatesSection({ } } + async function prepareLexwareInvoice(estimate: RepairEstimate) { + setPendingId(estimate.id); + try { + const response = await api.post(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`); + setLexwareResult(response.data); + setLexwareDialogOpen(true); + showToast({ + type: response.data.ready_for_export ? "success" : "error", + title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen", + description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.", + }); + } catch (err) { + showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) }); + } finally { + setPendingId(null); + } + } + async function deleteEstimate() { if (!deleteTarget) return; setPendingId(deleteTarget.id); @@ -496,6 +519,7 @@ export default function RepairEstimatesSection({ {canSend && ["draft", "sent"].includes(estimate.status) && } {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canRevoke && estimate.status === "approved" && } + {canLexwareExport && estimate.status === "approved" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && } @@ -629,6 +653,66 @@ export default function RepairEstimatesSection({ + + + + Lexware-Rechnung vorbereiten + Die Rechnung wird noch nicht automatisch in Lexware erstellt. + + {lexwareResult && ( +
+
+

{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}

+

Sync-Record #{lexwareResult.sync_record_id}

+
+ + {lexwareResult.warnings.length > 0 && ( +
+

Warnungen

+
    + {lexwareResult.warnings.map((warning) =>
  • {warning}
  • )} +
+
+ )} + +
+ + + + +
+ +
+
+ Position + Steuer + Summe +
+ {lexwareResult.line_item_mapping.map((item, index) => ( +
+
+

{item.title}

+

{item.quantity} {item.unit} × {item.unit_price} €

+
+ {item.tax_rate} % + {item.total} € +
+ ))} +
+ +
+ + + +
+
+ )} + + + +
+
+ ; +} + +export interface LexwareLineItemMapping { + title: string; + description: string | null; + quantity: string; + unit: string; + unit_price: string; + tax_rate: string; + total: string; +} + +export interface LexwareInvoicePreparation { + ready_for_export: boolean; + payload_summary: Record; + customer_mapping: LexwareCustomerMapping; + line_item_mapping: LexwareLineItemMapping[]; + tax_mapping: Record; + warnings: string[]; + sync_record_id: number; +} diff --git a/frontend/athena/types/repair.ts b/frontend/athena/types/repair.ts index 336a5ee..1b67875 100644 --- a/frontend/athena/types/repair.ts +++ b/frontend/athena/types/repair.ts @@ -216,6 +216,10 @@ export interface RepairEstimate { approved_at: string | null; declined_at: string | null; customer_response_message: string | null; + lexware_invoice_id: string | null; + lexware_invoice_number: string | null; + lexware_invoice_status: string | null; + lexware_synced_at: string | null; created_by_user_id: number | null; created_at: string; updated_at: string;