feat(lexware): add integration foundation
This commit is contained in:
parent
8d37eb29b3
commit
ffffb68898
28 changed files with 1150 additions and 11 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
30
ROADMAP.md
30
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
frontend/athena/app/api/lexware/settings/route.ts
Normal file
18
frontend/athena/app/api/lexware/settings/route.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
14
frontend/athena/app/api/lexware/test-connection/route.ts
Normal file
14
frontend/athena/app/api/lexware/test-connection/route.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SettingsSource, string> = {
|
||||
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<SettingsTab>("smtp");
|
||||
|
|
@ -56,22 +73,28 @@ export default function SettingsPage() {
|
|||
repair_status_base_url: "",
|
||||
source: "missing",
|
||||
});
|
||||
const [lexware, setLexware] = useState<LexwareSettings>(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<SmtpSettings>("/system-settings/smtp"),
|
||||
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
||||
api.get<LexwareSettings>("/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<LexwareSettings>("/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<LexwareTestConnectionResponse>("/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
||||
}
|
||||
|
|
@ -171,7 +244,7 @@ export default function SettingsPage() {
|
|||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">SMTP-Versand und öffentliche Statuslinks verwalten</p>
|
||||
<p className="mt-1 text-sm text-slate-500">SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten</p>
|
||||
</div>
|
||||
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
||||
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
||||
|
|
@ -180,6 +253,9 @@ export default function SettingsPage() {
|
|||
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
||||
Öffentliche Links
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
|
||||
Lexware Office
|
||||
</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -287,7 +363,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
) : activeTab === "public-links" ? (
|
||||
<section className="rounded-lg border bg-white p-6">
|
||||
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
|
|
@ -319,6 +395,86 @@ export default function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-lg border bg-white p-6">
|
||||
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ReceiptText className="h-5 w-5 text-slate-500" />
|
||||
<h2 className="text-lg font-semibold text-slate-950">Lexware Office</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Quelle: {sourceLabels[lexware.source]}
|
||||
{lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => void testLexwareConnection()} disabled={testingLexware}>
|
||||
<ShieldCheck />
|
||||
{testingLexware ? "Prüft..." : "Verbindung testen"}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void saveLexware()} disabled={savingLexware}>
|
||||
<Save />
|
||||
{savingLexware ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
checked={lexware.enabled}
|
||||
onChange={(event) => setLexware((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
Lexware-Integration aktivieren
|
||||
</label>
|
||||
|
||||
<Field label="Organisation">
|
||||
<Input value={lexware.organization_name} onChange={(event) => setLexware((current) => ({ ...current, organization_name: event.target.value }))} />
|
||||
</Field>
|
||||
|
||||
<Field label="API Base URL">
|
||||
<Input value={lexware.api_base_url} onChange={(event) => setLexware((current) => ({ ...current, api_base_url: event.target.value }))} />
|
||||
</Field>
|
||||
|
||||
<Field label={lexware.api_key_is_set ? "Neuen API-Key setzen" : "API-Key"}>
|
||||
<Input
|
||||
type="password"
|
||||
value={newLexwareApiKey}
|
||||
placeholder={lexware.api_key_is_set ? "Leer lassen, um aktuellen API-Key zu behalten" : ""}
|
||||
onChange={(event) => setNewLexwareApiKey(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Standard MwSt. %">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={lexware.default_tax_rate}
|
||||
onChange={(event) => setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Zahlungsziel Tage">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={lexware.default_payment_terms_days}
|
||||
onChange={(event) => setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-start gap-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<RepairEstimate[]>([]);
|
||||
|
|
@ -188,6 +191,8 @@ export default function RepairEstimatesSection({
|
|||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
||||
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
|
||||
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
|
||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||
|
|
@ -407,6 +412,24 @@ export default function RepairEstimatesSection({
|
|||
}
|
||||
}
|
||||
|
||||
async function prepareLexwareInvoice(estimate: RepairEstimate) {
|
||||
setPendingId(estimate.id);
|
||||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(`/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) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
||||
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
||||
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
|
||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />Lexware-Rechnung vorbereiten</Button>}
|
||||
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -629,6 +653,66 @@ export default function RepairEstimatesSection({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lexware-Rechnung vorbereiten</DialogTitle>
|
||||
<DialogDescription>Die Rechnung wird noch nicht automatisch in Lexware erstellt.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{lexwareResult && (
|
||||
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
|
||||
<div className={`rounded-lg border p-4 ${lexwareResult.ready_for_export ? "bg-emerald-50 text-emerald-950" : "bg-amber-50 text-amber-950"}`}>
|
||||
<p className="font-semibold">{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}</p>
|
||||
<p className="mt-1">Sync-Record #{lexwareResult.sync_record_id}</p>
|
||||
</div>
|
||||
|
||||
{lexwareResult.warnings.length > 0 && (
|
||||
<div className="rounded-lg border bg-amber-50 p-4 text-amber-950">
|
||||
<p className="font-semibold">Warnungen</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{lexwareResult.warnings.map((warning) => <li key={warning}>{warning}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-2">
|
||||
<Meta label="Kunde" value={lexwareResult.customer_mapping.name} />
|
||||
<Meta label="E-Mail" value={lexwareResult.customer_mapping.email || "-"} />
|
||||
<Meta label="Telefon" value={lexwareResult.customer_mapping.phone || "-"} />
|
||||
<Meta label="Kontaktabgleich" value={lexwareResult.customer_mapping.search_strategy} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
|
||||
<span>Position</span>
|
||||
<span>Steuer</span>
|
||||
<span>Summe</span>
|
||||
</div>
|
||||
{lexwareResult.line_item_mapping.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className="grid grid-cols-[1fr_auto_auto] gap-3 border-b p-3 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{item.title}</p>
|
||||
<p className="text-xs text-slate-500">{item.quantity} {item.unit} × {item.unit_price} €</p>
|
||||
</div>
|
||||
<span>{item.tax_rate} %</span>
|
||||
<span className="font-medium text-slate-950">{item.total} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
|
||||
<Meta label="Netto" value={`${String(lexwareResult.payload_summary.subtotal ?? "-")} €`} />
|
||||
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")} €`} />
|
||||
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")} €`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setLexwareDialogOpen(false)}>Schließen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(cancelTarget)}
|
||||
title="Kostenvoranschlag stornieren?"
|
||||
|
|
|
|||
56
frontend/athena/types/lexware.ts
Normal file
56
frontend/athena/types/lexware.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { SettingsSource } from "@/types/system-settings";
|
||||
|
||||
export interface LexwareSettings {
|
||||
enabled: boolean;
|
||||
api_base_url: string;
|
||||
api_key_is_set: boolean;
|
||||
organization_name: string;
|
||||
default_tax_rate: string;
|
||||
default_payment_terms_days: number;
|
||||
source: SettingsSource;
|
||||
}
|
||||
|
||||
export interface LexwareSettingsPayload {
|
||||
enabled: boolean;
|
||||
api_base_url: string;
|
||||
api_key?: string;
|
||||
organization_name: string;
|
||||
default_tax_rate: string;
|
||||
default_payment_terms_days: number;
|
||||
}
|
||||
|
||||
export interface LexwareTestConnectionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
source: SettingsSource;
|
||||
api_base_url: string;
|
||||
organization_name: string;
|
||||
}
|
||||
|
||||
export interface LexwareCustomerMapping {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
search_strategy: string;
|
||||
create_payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
customer_mapping: LexwareCustomerMapping;
|
||||
line_item_mapping: LexwareLineItemMapping[];
|
||||
tax_mapping: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
sync_record_id: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue