diff --git a/.env.example b/.env.example
index 155c54e..afd6786 100644
--- a/.env.example
+++ b/.env.example
@@ -20,7 +20,6 @@ STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
-# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
KNOWLEDGE_STORAGE_PATH=/data/knowledge
@@ -45,8 +44,3 @@ 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/.gitignore b/.gitignore
index 5bd760f..b36a9d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,8 +43,6 @@ out/
# Runtime storage
# ===========================
storage/
-/backups/
-*.dump
# ===========================
# Coverage
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3bced00..58f6fea 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -166,58 +166,6 @@ 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.
-- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
-
-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.
-
-Buchhaltungsworkflow:
-
-- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
-- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
-- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
-- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
-
-Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
-
-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`.
@@ -1060,29 +1008,9 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
-`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
+`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
-`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
-
-### Backup-Modul ab v0.9.1
-
-Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
-
-- Hermes-Service: `backend/hermes/app/services/backup_service.py`
-- Hermes-API: `backend/hermes/app/api/backups.py`
-- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
-- Athena-Seite: `frontend/athena/app/backups/page.tsx`
-- Athena-BFF: `/api/backups/...`
-
-Sicherheitsregeln:
-
-- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
-- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
-- Hermes anonymisiert den Datenbank-Host im Manifest.
-- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
-- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
-
-Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
+`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
### Knowledge-RBAC
diff --git a/README-DEV.md b/README-DEV.md
index d309d74..3e515cd 100644
--- a/README-DEV.md
+++ b/README-DEV.md
@@ -42,15 +42,10 @@ 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
@@ -134,46 +129,6 @@ STORAGE_BASE_PATH=/data/storage
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
-## Backup und Restore
-
-Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
-
-Ablage:
-
-- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
-- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
-- Backup-Dateien gehoeren nie ins Git
-
-Inhalt eines Backups:
-
-- `manifest.json`
-- `database.dump`
-- `storage/`
-
-Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
-
-- `GET /api/backups`
-- `POST /api/backups/create`
-- `GET /api/backups/[filename]/download`
-- `POST /api/backups/[filename]/validate`
-- `POST /api/backups/[filename]/restore`
-- `DELETE /api/backups/[filename]/delete`
-
-Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
-
-Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
-
-1. Backup validieren.
-2. Sicherheitsbestaetigung pruefen.
-3. Restore ueber `scripts/restore.sh ` ausfuehren.
-4. Ergebnis und Audit Logs kontrollieren.
-
-Empfehlung fuer den Betrieb:
-
-- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
-- Backup-Dateien vor Offsite-Kopie verschluesseln.
-- Restore nur in Wartungsfenstern ausfuehren.
-
## Knowledge Workflow
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
@@ -224,41 +179,6 @@ 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.
-
-Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
-
-Exportstatus:
-
-- `prepared`
-- `transferred`
-- `booked`
-- `cancelled`
-
Benachrichtigungen:
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 7c0efb0..e92efb2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -176,49 +176,13 @@ 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 - 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`
-- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
-- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
-- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
-
-## 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 - Backup und Restore
-
-- Backup-Modul mit Athena-BFF und Hermes-Service-Layer
-- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/`
-- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups`
-- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore`
-- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus
-- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung
-- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh`
-- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad
-
-## v0.9.2 - Kundenportal, geplant
+## v0.8.9 - Kundenportal, geplant
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
-## v0.9.3 - Tickets, geplant
+## v0.9.0 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
@@ -226,10 +190,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 und Lexware-Ausbau, geplant
+## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
- Paperless-ngx Connector fuer Wissensdokumente
-- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
+- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
- 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 cfaeb3b..6f7a6d0 100644
--- a/backend/hermes/.env.example
+++ b/backend/hermes/.env.example
@@ -31,8 +31,3 @@ 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/a7c3e9d4b821_add_repair_estimate_revoke_permission.py b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py
deleted file mode 100644
index 58c82c7..0000000
--- a/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""add repair estimate revoke permission
-
-Revision ID: a7c3e9d4b821
-Revises: f4a9c2d7e118
-Create Date: 2026-07-05 15:30:00.000000
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-revision: str = "a7c3e9d4b821"
-down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-PERMISSION_NAME = "repair_estimates.revoke"
-
-
-def upgrade() -> None:
- op.execute(
- sa.text(
- """
- INSERT INTO permissions (name, display_name, description, module)
- VALUES (
- :name,
- 'Kostenvoranschläge zurücknehmen',
- 'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
- 'repair_estimates'
- )
- ON CONFLICT (name) DO UPDATE SET
- display_name = excluded.display_name,
- description = excluded.description,
- module = excluded.module
- """
- ).bindparams(name=PERMISSION_NAME)
- )
- for role_name in ("administrator", "management"):
- 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 name = :permission_name
- )
- """
- ).bindparams(permission_name=PERMISSION_NAME)
- )
- op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))
diff --git a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py
deleted file mode 100644
index bf98008..0000000
--- a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""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/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py
deleted file mode 100644
index 0c7936b..0000000
--- a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""add accounting export status
-
-Revision ID: d2e3f4a5b6c7
-Revises: c9d4e5f6a7b8
-Create Date: 2026-07-05 17:20:00.000000
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-revision: str = "d2e3f4a5b6c7"
-down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
- op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
- op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
- op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
- op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
- op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
-
- op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
- op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
- op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
- op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
- op.create_foreign_key(
- "fk_repair_estimates_accounting_transferred_by_user_id",
- "repair_estimates",
- "users",
- ["accounting_transferred_by_user_id"],
- ["id"],
- ondelete="SET NULL",
- )
-
-
-def downgrade() -> None:
- op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
- op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
- op.drop_column("repair_estimates", "accounting_transferred_at")
- op.drop_column("repair_estimates", "accounting_note")
- op.drop_column("repair_estimates", "accounting_export_status")
- op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
- op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
- op.drop_column("lexware_sync_records", "transferred_by_user_id")
- op.drop_column("lexware_sync_records", "transferred_at")
- op.drop_column("lexware_sync_records", "accounting_note")
- op.drop_column("lexware_sync_records", "export_status")
diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py
index 5704b10..3912a67 100644
--- a/backend/hermes/app/api/audit.py
+++ b/backend/hermes/app/api/audit.py
@@ -58,16 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "knowledge.read" in permissions
if action.startswith("repairs."):
return "repairs.read" in permissions
- if action.startswith("repair_estimates."):
- 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("accounting."):
- return "lexware.read" in permissions
- if action.startswith("backups."):
- return "backup.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/backups.py b/backend/hermes/app/api/backups.py
deleted file mode 100644
index 75e131e..0000000
--- a/backend/hermes/app/api/backups.py
+++ /dev/null
@@ -1,150 +0,0 @@
-from fastapi import APIRouter, Depends, status
-from fastapi.responses import FileResponse
-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.user import User
-from app.schemas.api_response import ApiSuccess
-from app.schemas.backup import BackupRestoreRequest
-from app.services.audit_service import write_audit_log
-from app.services.backup_service import BackupService
-
-router = APIRouter(prefix="/backups", tags=["Backups"])
-
-
-@router.get("", response_model=ApiSuccess)
-def list_backups(
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.read")),
-):
- return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
-
-
-@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
-def create_backup(
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.create")),
-):
- backup = BackupService.create_backup(actor=current_user)
- write_audit_log(
- db,
- action="backups.create",
- entity_type="backup",
- entity_label=backup.filename,
- actor=current_user,
- request=request,
- metadata={
- "filename": backup.filename,
- "size_bytes": backup.size_bytes,
- "app_version": backup.app_version,
- },
- )
- return ApiSuccess(data=backup, message="Backup erstellt")
-
-
-@router.get("/{filename}/download")
-def download_backup(
- filename: str,
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.download")),
-):
- path = BackupService.resolve_backup_path(filename)
- write_audit_log(
- db,
- action="backups.download",
- entity_type="backup",
- entity_label=path.name,
- actor=current_user,
- request=request,
- metadata={"filename": path.name, "size_bytes": path.stat().st_size},
- )
- return FileResponse(path=path, media_type="application/zip", filename=path.name)
-
-
-@router.delete("/{filename}", response_model=ApiSuccess)
-def delete_backup(
- filename: str,
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.delete")),
-):
- path = BackupService.resolve_backup_path(filename)
- size_bytes = path.stat().st_size
- BackupService.delete_backup(filename)
- write_audit_log(
- db,
- action="backups.delete",
- entity_type="backup",
- entity_label=path.name,
- actor=current_user,
- request=request,
- metadata={"filename": path.name, "size_bytes": size_bytes},
- )
- return ApiSuccess(message="Backup geloescht")
-
-
-@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
-def validate_backup_restore(
- filename: str,
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.restore")),
-):
- validation = BackupService.validate_backup(filename)
- write_audit_log(
- db,
- action="backups.validate",
- entity_type="backup",
- entity_label=filename,
- actor=current_user,
- request=request,
- metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
- )
- return ApiSuccess(data=validation, message=validation.message)
-
-
-@router.post("/{filename}/restore", response_model=ApiSuccess)
-def restore_backup(
- filename: str,
- payload: BackupRestoreRequest,
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("backup.restore")),
-):
- write_audit_log(
- db,
- action="backups.restore_started",
- entity_type="backup",
- entity_label=filename,
- actor=current_user,
- request=request,
- metadata={"filename": filename},
- )
- try:
- validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
- except Exception:
- write_audit_log(
- db,
- action="backups.restore_failed",
- entity_type="backup",
- entity_label=filename,
- actor=current_user,
- request=request,
- metadata={"filename": filename},
- )
- raise
-
- write_audit_log(
- db,
- action="backups.restore_completed",
- entity_type="backup",
- entity_label=filename,
- actor=current_user,
- request=request,
- metadata={"filename": filename},
- )
- return ApiSuccess(data=validation, message="Restore abgeschlossen")
diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py
index 02d2752..a63f72d 100644
--- a/backend/hermes/app/api/dashboard.py
+++ b/backend/hermes/app/api/dashboard.py
@@ -4,12 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
-from app.core.config import settings
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.rbac import Role
from app.models.audit import AuditLog
-from app.models.lexware import LexwareSyncRecord
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.inventory_repository import InventoryRepository
@@ -17,7 +15,6 @@ from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
-from app.services.backup_service import BackupService
from app.services.system_settings_service import SystemSettingsService
logger = logging.getLogger(__name__)
@@ -85,35 +82,6 @@ def get_dashboard_summary(
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)),
MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
- MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
- ])
-
- if "lexware.read" in permissions:
- repairs.extend([
- MetricCard(
- label="Vorbereitete Rechnungen",
- value=db.scalar(
- select(func.count(LexwareSyncRecord.id))
- .where(LexwareSyncRecord.lexware_resource_type == "invoice")
- .where(LexwareSyncRecord.export_status == "prepared")
- ) or 0,
- ),
- MetricCard(
- label="An Buchhaltung übergeben",
- value=db.scalar(
- select(func.count(LexwareSyncRecord.id))
- .where(LexwareSyncRecord.lexware_resource_type == "invoice")
- .where(LexwareSyncRecord.export_status == "transferred")
- ) or 0,
- ),
- MetricCard(
- label="Noch nicht übertragen",
- value=db.scalar(
- select(func.count(LexwareSyncRecord.id))
- .where(LexwareSyncRecord.lexware_resource_type == "invoice")
- .where(LexwareSyncRecord.export_status == "prepared")
- ) or 0,
- ),
])
if "inventory.read" in permissions:
@@ -164,27 +132,6 @@ def get_dashboard_summary(
),
]
- if "backup.read" in permissions:
- backup_stats = BackupService.get_backup_stats()
- repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
- system_status.extend([
- SystemStatusItem(
- label="Letztes Backup",
- value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
- status="ok" if backup_stats.latest_backup_at else "warning",
- ),
- SystemStatusItem(
- label="Backup-Speicher",
- value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
- status="ok" if backup_stats.total_count else "warning",
- ),
- SystemStatusItem(
- label="Hermes-Version",
- value=settings.app_version,
- status="info",
- ),
- ])
-
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
return DashboardSummary(
diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py
deleted file mode 100644
index 03b20c0..0000000
--- a/backend/hermes/app/api/lexware.py
+++ /dev/null
@@ -1,89 +0,0 @@
-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 (
- AccountingTransferUpdate,
- 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)
-
-
-@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
-def mark_accounting_transferred(
- repair_id: int,
- estimate_id: int,
- payload: AccountingTransferUpdate,
- 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.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py
index 260754c..1ec60f7 100644
--- a/backend/hermes/app/api/repair_estimates.py
+++ b/backend/hermes/app/api/repair_estimates.py
@@ -124,19 +124,6 @@ def cancel_estimate(
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
-@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse)
-def revoke_estimate(
- repair_id: int,
- estimate_id: int,
- request: Request,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("repair_estimates.revoke")),
-):
- repair = get_repair_or_404(db, repair_id)
- estimate = get_estimate_or_404(db, repair_id, estimate_id)
- return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request)
-
-
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
def list_estimate_events(
repair_id: int,
diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py
index 2a3d97f..9590c60 100644
--- a/backend/hermes/app/core/config.py
+++ b/backend/hermes/app/core/config.py
@@ -9,7 +9,7 @@ class Settings(BaseSettings):
secret_key: str
app_name: str = "Hermes API"
- app_version: str = "0.9.1"
+ app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
@@ -32,9 +32,6 @@ 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 c860b24..dcc29f4 100644
--- a/backend/hermes/app/db/database.py
+++ b/backend/hermes/app/db/database.py
@@ -25,11 +25,9 @@ import app.models.customer
import app.models.knowledge
import app.models.audit
import app.models.user
-import app.models.inventory
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 04c84ca..3b7c410 100644
--- a/backend/hermes/app/main.py
+++ b/backend/hermes/app/main.py
@@ -11,12 +11,10 @@ from starlette.requests import Request
from app.api.auth import router as auth_router
from app.api.audit import router as audit_router
-from app.api.backups import router as backups_router
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
@@ -34,13 +32,12 @@ configure_logging()
app = FastAPI(
title="Hermes API",
- version="0.9.1",
+ version="0.1.0",
description="Backend von Olympus",
)
app.include_router(auth_router)
app.include_router(audit_router)
-app.include_router(backups_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
@@ -51,7 +48,6 @@ 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/inventory.py b/backend/hermes/app/models/inventory.py
index ecfdfc9..9428b9e 100644
--- a/backend/hermes/app/models/inventory.py
+++ b/backend/hermes/app/models/inventory.py
@@ -103,11 +103,7 @@ class InventoryStockMovement(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
item: Mapped[InventoryItem] = relationship(back_populates="movements")
- actor = relationship(
- "User",
- foreign_keys="InventoryStockMovement.actor_user_id",
- lazy="joined",
- )
+ actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py
deleted file mode 100644
index febb874..0000000
--- a/backend/hermes/app/models/lexware.py
+++ /dev/null
@@ -1,27 +0,0 @@
-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)
- export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
- accounting_note: Mapped[str | None] = mapped_column(Text, nullable=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)
- transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
- transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py
index 6b32497..53ba9e2 100644
--- a/backend/hermes/app/models/repair.py
+++ b/backend/hermes/app/models/repair.py
@@ -56,11 +56,7 @@ class RepairStatusHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
- actor = relationship(
- "User",
- foreign_keys="RepairStatusHistory.actor_user_id",
- lazy="joined",
- )
+ actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
@@ -109,11 +105,7 @@ class RepairDocument(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair: Mapped[Repair] = relationship(back_populates="documents")
- uploaded_by = relationship(
- "User",
- foreign_keys="RepairDocument.uploaded_by_user_id",
- lazy="joined",
- )
+ uploaded_by = relationship("User", lazy="joined")
@property
def uploaded_by_username(self) -> str:
diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py
index 9cdda96..696a060 100644
--- a/backend/hermes/app/models/repair_estimate.py
+++ b/backend/hermes/app/models/repair_estimate.py
@@ -27,29 +27,12 @@ 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)
- accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
- accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
- accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
- accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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())
repair = relationship("Repair", lazy="joined")
- created_by = relationship(
- "User",
- foreign_keys="RepairEstimate.created_by_user_id",
- lazy="joined",
- )
- accounting_transferred_by = relationship(
- "User",
- foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
- lazy="joined",
- )
+ created_by = relationship("User", lazy="joined")
items: Mapped[list["RepairEstimateItem"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
@@ -101,8 +84,4 @@ class RepairEstimateEvent(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
- actor = relationship(
- "User",
- foreign_keys="RepairEstimateEvent.actor_user_id",
- lazy="joined",
- )
+ actor = relationship("User", lazy="joined")
diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py
index 6661bff..1191da6 100644
--- a/backend/hermes/app/rbac/defaults.py
+++ b/backend/hermes/app/rbac/defaults.py
@@ -90,7 +90,6 @@ STANDARD_PERMISSIONS = [
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
- ("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"),
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
@@ -99,14 +98,6 @@ 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"),
- ("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
- ("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
- ("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
- ("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
- ("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
]
ROLE_PERMISSION_NAMES = {
@@ -128,7 +119,6 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
- "repair_estimates.revoke",
"inventory.read",
"inventory.create",
"inventory.update",
@@ -136,12 +126,6 @@ ROLE_PERMISSION_NAMES = {
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
- "lexware.read",
- "lexware.manage",
- "lexware.export",
- "backup.read",
- "backup.create",
- "backup.download",
},
"sales": {
"dashboard.read",
@@ -191,7 +175,6 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
- "lexware.read",
},
"warehouse": {
"dashboard.read",
diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py
index db20a7c..7519a94 100644
--- a/backend/hermes/app/repositories/repair_estimate_repository.py
+++ b/backend/hermes/app/repositories/repair_estimate_repository.py
@@ -33,7 +33,7 @@ class RepairEstimateRepository:
select(RepairEstimate)
.options(selectinload(RepairEstimate.items))
.where(RepairEstimate.repair_id == repair_id)
- .where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
+ .where(RepairEstimate.status.in_(["sent", "approved", "declined"]))
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
.limit(1)
)
@@ -123,14 +123,3 @@ class RepairEstimateRepository:
@staticmethod
def count_declined(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
-
- @staticmethod
- def count_revoked_today(db: Session) -> int:
- today = datetime.now(UTC).date()
- return db.scalar(
- select(func.count(RepairEstimate.id))
- .join(RepairEstimateEvent, RepairEstimateEvent.estimate_id == RepairEstimate.id)
- .where(RepairEstimate.status == "revoked")
- .where(RepairEstimateEvent.event_type == "revoked")
- .where(func.date(RepairEstimateEvent.created_at) == today)
- ) or 0
diff --git a/backend/hermes/app/schemas/backup.py b/backend/hermes/app/schemas/backup.py
deleted file mode 100644
index 661d1e9..0000000
--- a/backend/hermes/app/schemas/backup.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from datetime import datetime
-
-from pydantic import BaseModel, Field
-
-
-class BackupManifest(BaseModel):
- backup_id: str
- created_at: datetime
- app_version: str
- backup_type: str = "full"
- database_url_host_anonymized: str
- database_name: str
- storage_base_path: str
- included_sections: list[str] = Field(default_factory=list)
- file_count: int = 0
- total_size_bytes: int = 0
- checksum_sha256: str
- created_by_user_id: int | None = None
- created_by_username: str = ""
-
-
-class BackupSummary(BaseModel):
- filename: str
- size_bytes: int
- created_at: datetime | None = None
- app_version: str = ""
- backup_type: str = "full"
- database_name: str = ""
- storage_base_path: str = ""
- file_count: int = 0
- total_size_bytes: int = 0
- created_by_user_id: int | None = None
- created_by_username: str = ""
- validation_status: str = "valid"
- validation_message: str = ""
-
-
-class BackupListResponse(BaseModel):
- items: list[BackupSummary] = Field(default_factory=list)
- total_count: int = 0
- total_size_bytes: int = 0
- latest_backup_at: datetime | None = None
-
-
-class BackupValidationResponse(BaseModel):
- filename: str
- valid: bool
- message: str
- issues: list[str] = Field(default_factory=list)
- checksum_valid: bool = False
- restore_supported: bool = False
- requires_cli_restore: bool = True
- manifest: BackupManifest | None = None
-
-
-class BackupRestoreRequest(BaseModel):
- confirm_text: str
diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py
deleted file mode 100644
index 45cd103..0000000
--- a/backend/hermes/app/schemas/lexware.py
+++ /dev/null
@@ -1,96 +0,0 @@
-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"]
-AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
-
-
-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
- export_status: AccountingExportStatus
- payload_summary: dict
- customer_mapping: LexwareCustomerMapping
- line_item_mapping: list[LexwareLineItemMapping]
- tax_mapping: dict
- warnings: list[str]
- sync_record_id: int
- accounting_note: str = ""
- transferred_at: str | None = None
- transferred_by_user_id: int | None = None
-
-
-class AccountingTransferUpdate(BaseModel):
- accounting_note: str | None = Field(default=None, max_length=2000)
-
- @field_validator("accounting_note", mode="before")
- @classmethod
- def normalize_note(cls, value: object) -> str | None:
- text = normalize_text(value)
- return text or None
diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py
index b1b68e5..75b942d 100644
--- a/backend/hermes/app/schemas/repair_estimate.py
+++ b/backend/hermes/app/schemas/repair_estimate.py
@@ -4,10 +4,10 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
-EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
+EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"]
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
EstimateActorType = Literal["user", "customer", "system"]
-EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
+EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"]
def normalize_text(value: object) -> str:
@@ -142,14 +142,6 @@ 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
- accounting_export_status: str | None
- accounting_note: str | None
- accounting_transferred_at: datetime | None
- accounting_transferred_by_user_id: int | None
created_by_user_id: int | None
created_at: datetime
updated_at: datetime
@@ -175,7 +167,6 @@ class PublicEstimateItemResponse(BaseModel):
class PublicEstimateResponse(BaseModel):
estimate_number: str
status: EstimateStatus
- status_label: str
title: str
customer_message: str
subtotal_cents: int
diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py
index 14110e0..d986106 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", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
+SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@@ -179,7 +179,6 @@ def action_title(action: str) -> str:
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
- "repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"inventory.items.create": "Lagerartikel erstellt",
"inventory.items.update": "Lagerartikel geändert",
@@ -207,21 +206,6 @@ 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",
- "accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
- "accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
- "accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
- "backups.create": "Backup erstellt",
- "backups.download": "Backup heruntergeladen",
- "backups.delete": "Backup gelöscht",
- "backups.validate": "Backup validiert",
- "backups.restore_started": "Restore gestartet",
- "backups.restore_failed": "Restore fehlgeschlagen",
- "backups.restore_completed": "Restore abgeschlossen",
}
return labels.get(action, action)
diff --git a/backend/hermes/app/services/backup_service.py b/backend/hermes/app/services/backup_service.py
deleted file mode 100644
index b84ffe2..0000000
--- a/backend/hermes/app/services/backup_service.py
+++ /dev/null
@@ -1,373 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from datetime import UTC, datetime
-import hashlib
-import json
-import os
-from pathlib import Path
-import shutil
-import subprocess
-import tempfile
-import zipfile
-
-from fastapi import HTTPException, status
-from sqlalchemy.engine import make_url
-
-from app.core.config import settings
-from app.models.user import User
-from app.schemas.backup import (
- BackupListResponse,
- BackupManifest,
- BackupSummary,
- BackupValidationResponse,
-)
-
-BACKUP_FILENAME_PREFIX = "olympus-backup-"
-BACKUP_FILENAME_SUFFIX = ".zip"
-DATABASE_DUMP_NAME = "database.dump"
-MANIFEST_NAME = "manifest.json"
-STORAGE_DIR_NAME = "storage"
-BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
-RESTORE_DISABLED_MESSAGE = (
- "Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
- "Bitte Restore ueber CLI-Script ausfuehren."
-)
-
-
-@dataclass(frozen=True)
-class BackupStats:
- total_count: int
- total_size_bytes: int
- latest_backup_at: datetime | None
-
-
-class BackupService:
- @staticmethod
- def get_backup_dir() -> Path:
- backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
- backup_dir.mkdir(parents=True, exist_ok=True)
- return backup_dir
-
- @staticmethod
- def list_backups() -> BackupListResponse:
- items = [
- BackupService._read_summary(path)
- for path in sorted(
- BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
- key=lambda item: item.stat().st_mtime,
- reverse=True,
- )
- ]
- latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
- return BackupListResponse(
- items=items,
- total_count=len(items),
- total_size_bytes=sum(item.size_bytes for item in items),
- latest_backup_at=latest_backup_at,
- )
-
- @staticmethod
- def get_backup_stats() -> BackupStats:
- backups = BackupService.list_backups()
- return BackupStats(
- total_count=backups.total_count,
- total_size_bytes=backups.total_size_bytes,
- latest_backup_at=backups.latest_backup_at,
- )
-
- @staticmethod
- def create_backup(*, actor: User) -> BackupSummary:
- backup_dir = BackupService.get_backup_dir()
- timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
- filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
- target_path = backup_dir / filename
-
- with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
- temp_dir = Path(temp_dir_name)
- dump_path = temp_dir / DATABASE_DUMP_NAME
- storage_temp_dir = temp_dir / STORAGE_DIR_NAME
- manifest_path = temp_dir / MANIFEST_NAME
- archive_path = temp_dir / filename
-
- BackupService._run_pg_dump(dump_path)
- file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
- dump_size = dump_path.stat().st_size
- checksum_sha256 = BackupService._calculate_archive_checksum(
- dump_path=dump_path,
- storage_dir=storage_temp_dir,
- )
-
- manifest = BackupManifest(
- backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
- created_at=datetime.now(UTC),
- app_version=settings.app_version,
- backup_type="full",
- database_url_host_anonymized=BackupService._anonymized_database_host(),
- database_name=BackupService._database_name(),
- storage_base_path=settings.storage_base_path,
- included_sections=["database", "storage"],
- file_count=file_count + 1,
- total_size_bytes=total_size_bytes + dump_size,
- checksum_sha256=checksum_sha256,
- created_by_user_id=actor.id,
- created_by_username=actor.username,
- )
- manifest_path.write_text(
- json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
- encoding="utf-8",
- )
-
- BackupService._write_archive(
- archive_path=archive_path,
- manifest_path=manifest_path,
- dump_path=dump_path,
- storage_dir=storage_temp_dir,
- )
- shutil.move(str(archive_path), target_path)
-
- return BackupService._read_summary(target_path)
-
- @staticmethod
- def validate_backup(filename: str) -> BackupValidationResponse:
- path = BackupService.resolve_backup_path(filename)
- issues: list[str] = []
- manifest: BackupManifest | None = None
- checksum_valid = False
-
- try:
- with zipfile.ZipFile(path) as archive:
- names = set(archive.namelist())
- if MANIFEST_NAME not in names:
- issues.append("manifest.json fehlt")
- if DATABASE_DUMP_NAME not in names:
- issues.append("database.dump fehlt")
- if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
- issues.append("storage/ fehlt")
-
- if MANIFEST_NAME in names:
- try:
- with archive.open(MANIFEST_NAME) as manifest_file:
- manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
- except Exception:
- issues.append("manifest.json ist ungueltig")
-
- if manifest is not None:
- checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
- if not checksum_valid:
- issues.append("Checksumme ist ungueltig")
- except zipfile.BadZipFile:
- issues.append("ZIP-Datei ist ungueltig")
-
- valid = len(issues) == 0
- return BackupValidationResponse(
- filename=path.name,
- valid=valid,
- message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
- issues=issues,
- checksum_valid=checksum_valid,
- restore_supported=False,
- requires_cli_restore=True,
- manifest=manifest,
- )
-
- @staticmethod
- def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
- if confirm_text != BACKUP_CONFIRM_TEXT:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Bestaetigungstext stimmt nicht ueberein",
- )
-
- validation = BackupService.validate_backup(filename)
- if not validation.valid:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
- )
-
- raise HTTPException(
- status_code=status.HTTP_501_NOT_IMPLEMENTED,
- detail=RESTORE_DISABLED_MESSAGE,
- )
-
- @staticmethod
- def delete_backup(filename: str) -> None:
- path = BackupService.resolve_backup_path(filename)
- path.unlink(missing_ok=False)
-
- @staticmethod
- def resolve_backup_path(filename: str) -> Path:
- if not filename.endswith(BACKUP_FILENAME_SUFFIX):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
- if Path(filename).name != filename or ".." in Path(filename).parts:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
-
- path = (BackupService.get_backup_dir() / filename).resolve()
- backup_dir = BackupService.get_backup_dir()
- if backup_dir != path.parent or not path.exists() or not path.is_file():
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
- return path
-
- @staticmethod
- def _run_pg_dump(dump_path: Path) -> None:
- pg_dump_url = BackupService._pg_dump_database_url()
- command = [
- "pg_dump",
- "--format=custom",
- "--no-owner",
- "--no-privileges",
- f"--file={dump_path}",
- f"--dbname={pg_dump_url}",
- ]
-
- try:
- subprocess.run(
- command,
- check=True,
- capture_output=True,
- text=True,
- env=os.environ.copy(),
- )
- except FileNotFoundError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="pg_dump ist im Hermes-Container nicht verfuegbar",
- ) from exc
- except subprocess.CalledProcessError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="PostgreSQL-Dump konnte nicht erstellt werden",
- ) from exc
-
- @staticmethod
- def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
- source_dir = Path(settings.storage_base_path).resolve()
- backup_dir = BackupService.get_backup_dir()
- source_dir.mkdir(parents=True, exist_ok=True)
- target_dir.mkdir(parents=True, exist_ok=True)
- file_count = 0
- total_size_bytes = 0
-
- for source_path in sorted(source_dir.rglob("*")):
- if source_path == backup_dir or backup_dir in source_path.parents:
- continue
- relative_path = source_path.relative_to(source_dir)
- destination_path = target_dir / relative_path
- if source_path.is_dir():
- destination_path.mkdir(parents=True, exist_ok=True)
- continue
- if not source_path.is_file():
- continue
- destination_path.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(source_path, destination_path)
- file_count += 1
- total_size_bytes += source_path.stat().st_size
-
- return file_count, total_size_bytes
-
- @staticmethod
- def _write_archive(
- *,
- archive_path: Path,
- manifest_path: Path,
- dump_path: Path,
- storage_dir: Path,
- ) -> None:
- with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
- archive.writestr(f"{STORAGE_DIR_NAME}/", "")
- archive.write(manifest_path, MANIFEST_NAME)
- archive.write(dump_path, DATABASE_DUMP_NAME)
- for file_path in sorted(storage_dir.rglob("*")):
- if file_path.is_dir():
- continue
- archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
-
- @staticmethod
- def _read_summary(path: Path) -> BackupSummary:
- default_summary = BackupSummary(
- filename=path.name,
- size_bytes=path.stat().st_size,
- validation_status="warning",
- validation_message="Manifest konnte nicht gelesen werden",
- )
- try:
- with zipfile.ZipFile(path) as archive:
- with archive.open(MANIFEST_NAME) as manifest_file:
- manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
- return BackupSummary(
- filename=path.name,
- size_bytes=path.stat().st_size,
- created_at=manifest.created_at,
- app_version=manifest.app_version,
- backup_type=manifest.backup_type,
- database_name=manifest.database_name,
- storage_base_path=manifest.storage_base_path,
- file_count=manifest.file_count,
- total_size_bytes=manifest.total_size_bytes,
- created_by_user_id=manifest.created_by_user_id,
- created_by_username=manifest.created_by_username,
- validation_status="valid",
- validation_message="Backup ist lesbar",
- )
- except Exception:
- return default_summary
-
- @staticmethod
- def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
- digest = hashlib.sha256()
- digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
- BackupService._update_digest_from_file(digest, dump_path)
-
- for file_path in sorted(storage_dir.rglob("*")):
- if file_path.is_dir():
- continue
- digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
- BackupService._update_digest_from_file(digest, file_path)
-
- return digest.hexdigest()
-
- @staticmethod
- def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
- digest = hashlib.sha256()
- if DATABASE_DUMP_NAME not in archive.namelist():
- return False
-
- digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
- with archive.open(DATABASE_DUMP_NAME) as dump_file:
- BackupService._update_digest_from_stream(digest, dump_file)
-
- for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
- digest.update(name.encode("utf-8"))
- with archive.open(name) as storage_file:
- BackupService._update_digest_from_stream(digest, storage_file)
-
- return digest.hexdigest() == expected_checksum
-
- @staticmethod
- def _anonymized_database_host() -> str:
- parsed = make_url(settings.database_url)
- host = parsed.host or "unknown"
- digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
- return f"sha256:{digest}"
-
- @staticmethod
- def _database_name() -> str:
- parsed = make_url(settings.database_url)
- return parsed.database or "unknown"
-
- @staticmethod
- def _pg_dump_database_url() -> str:
- parsed = make_url(settings.database_url)
- normalized = parsed.set(drivername="postgresql")
- return normalized.render_as_string(hide_password=False)
-
- @staticmethod
- def _update_digest_from_file(digest, file_path: Path) -> None:
- with file_path.open("rb") as file_handle:
- BackupService._update_digest_from_stream(digest, file_handle)
-
- @staticmethod
- def _update_digest_from_stream(digest, stream) -> None:
- for chunk in iter(lambda: stream.read(1024 * 1024), b""):
- digest.update(chunk)
diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py
index 49afd72..01b082c 100644
--- a/backend/hermes/app/services/inventory_service.py
+++ b/backend/hermes/app/services/inventory_service.py
@@ -225,13 +225,7 @@ class InventoryService:
)
@staticmethod
- def release_estimate_reservation(
- db: Session,
- estimate: RepairEstimate,
- *,
- actor_user_id: int | None,
- reason: str = "estimate_released",
- ) -> None:
+ def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
for estimate_item in estimate.items:
if estimate_item.inventory_item_id is None:
continue
@@ -246,7 +240,7 @@ class InventoryService:
item_id=item.id,
movement_type="release",
quantity=quantity,
- reason=reason,
+ reason="estimate_released",
reference_type="repair_estimate",
reference_id=estimate.id,
note=f"Kostenvoranschlag {estimate.estimate_number}",
diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py
deleted file mode 100644
index 61ffe38..0000000
--- a/backend/hermes/app/services/lexware_service.py
+++ /dev/null
@@ -1,518 +0,0 @@
-import json
-from dataclasses import dataclass
-from datetime import UTC, datetime
-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 (
- AccountingTransferUpdate,
- 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 = LexwareService._invoice_payload_summary(config, repair, estimate, 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",
- export_status="prepared",
- accounting_note=estimate.accounting_note,
- payload_summary=json.dumps(payload_summary, ensure_ascii=True),
- error_message="; ".join(warnings) if warnings else None,
- )
- estimate.accounting_export_status = "prepared"
- db.add(record)
- db.commit()
- db.refresh(record)
- db.refresh(estimate)
- write_audit_log(
- db,
- action="accounting.invoice.handoff",
- 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,
- "export_status": record.export_status,
- },
- )
- return LexwareService._invoice_preparation_response(
- record=record,
- payload_summary=payload_summary,
- customer_mapping=customer_mapping,
- line_items=line_items,
- estimate=estimate,
- config=config,
- warnings=warnings,
- )
-
- @staticmethod
- def mark_transferred(
- db: Session,
- repair: Repair,
- estimate: RepairEstimate,
- payload: AccountingTransferUpdate,
- *,
- actor: User,
- request: Request,
- ) -> LexwareInvoicePreparationResponse:
- record = LexwareService._latest_invoice_record(db, estimate.id)
- if record is None:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
- if record.export_status not in {"prepared", "transferred"}:
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
-
- note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
- now = datetime.now(UTC)
- record.export_status = "transferred"
- record.status = "success"
- record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
- record.transferred_at = now
- record.transferred_by_user_id = actor.id
- record.synced_at = now
- estimate.accounting_export_status = "transferred"
- estimate.accounting_note = record.accounting_note
- estimate.accounting_transferred_at = now
- estimate.accounting_transferred_by_user_id = actor.id
- db.commit()
- db.refresh(record)
- db.refresh(estimate)
-
- write_audit_log(
- db,
- action="accounting.invoice.mark_transferred",
- 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,
- "sync_record_id": record.id,
- "export_status": record.export_status,
- "transferred_at": record.transferred_at,
- },
- )
- if note_changed:
- write_audit_log(
- db,
- action="accounting.invoice.note_update",
- 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, "sync_record_id": record.id},
- )
-
- config = LexwareService.get_runtime_config(db)
- customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
- warnings: list[str] = []
- payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
- return LexwareService._invoice_preparation_response(
- record=record,
- payload_summary=payload_summary,
- customer_mapping=customer_mapping,
- line_items=line_items,
- estimate=estimate,
- config=config,
- warnings=warnings,
- )
-
- @staticmethod
- def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
- 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
- ]
- return customer_mapping, line_items
-
- @staticmethod
- def _invoice_payload_summary(
- config: LexwareRuntimeConfig,
- repair: Repair,
- estimate: RepairEstimate,
- line_items: list[LexwareLineItemMapping],
- ) -> dict:
- return {
- "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),
- }
-
- @staticmethod
- def _invoice_preparation_response(
- *,
- record: LexwareSyncRecord,
- payload_summary: dict,
- customer_mapping: LexwareCustomerMapping,
- line_items: list[LexwareLineItemMapping],
- estimate: RepairEstimate,
- config: LexwareRuntimeConfig,
- warnings: list[str],
- ) -> LexwareInvoicePreparationResponse:
- return LexwareInvoicePreparationResponse(
- ready_for_export=not warnings,
- export_status=record.export_status,
- 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,
- accounting_note=record.accounting_note or "",
- transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
- transferred_by_user_id=record.transferred_by_user_id,
- )
-
- @staticmethod
- def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
- from sqlalchemy import select
-
- return db.scalar(
- select(LexwareSyncRecord)
- .where(LexwareSyncRecord.entity_type == "repair_estimate")
- .where(LexwareSyncRecord.entity_id == estimate_id)
- .where(LexwareSyncRecord.lexware_resource_type == "invoice")
- .order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
- .limit(1)
- )
-
- @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/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py
index f096b84..b9b4ee8 100644
--- a/backend/hermes/app/services/repair_estimate_service.py
+++ b/backend/hermes/app/services/repair_estimate_service.py
@@ -59,17 +59,6 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
}
-ESTIMATE_STATUS_LABELS = {
- "draft": "Entwurf",
- "sent": "Wartet auf Freigabe",
- "approved": "Freigegeben",
- "declined": "Abgelehnt",
- "expired": "Abgelaufen",
- "cancelled": "Storniert",
- "revoked": "Kostenvoranschlag wird überarbeitet",
-}
-
-
class RepairEstimateService:
@staticmethod
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
@@ -260,7 +249,7 @@ class RepairEstimateService:
@staticmethod
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
- if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
+ if estimate.status in {"approved", "declined", "cancelled"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
was_sent = estimate.status == "sent"
if was_sent:
@@ -292,98 +281,6 @@ class RepairEstimateService:
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
- @staticmethod
- def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
- if estimate.status != "approved":
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
-
- before_data = _audit_estimate_data(estimate)
- InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
- estimate.status = "revoked"
- RepairEstimateRepository.add_event(
- db,
- estimate_id=estimate.id,
- event_type="revoked",
- actor_type="user",
- actor_user_id=actor.id,
- note="Freigabe zurückgenommen",
- commit=False,
- )
- db.commit()
- db.refresh(estimate)
-
- if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
- RepairRepository.update_status(
- db,
- repair,
- RepairStatusUpdate(
- status="waiting_for_customer",
- note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
- ),
- actor_user_id=actor.id,
- )
-
- link = RepairPublicLinkService.create_with_audit(
- db,
- repair,
- actor=actor,
- request=request,
- audit_action="repairs.public_link.regenerate",
- )
- subject = "Kostenvoranschlag wurde zurückgenommen"
- smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
- mail_sent = False
- if smtp_config.is_configured and repair.customer_email:
- try:
- SystemSettingsService.send_email(
- smtp_config,
- recipient=repair.customer_email,
- subject=subject,
- text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
- html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
- )
- mail_sent = True
- except Exception:
- mail_sent = False
-
- RepairRepository.create_notification_event(
- db,
- repair_id=repair.id,
- event_type="repair_estimate_revoked_mail",
- channel="email",
- recipient=repair.customer_email,
- subject=subject,
- template="repair_estimate_revoked",
- status="sent" if mail_sent else "failed",
- success=mail_sent,
- error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
- sent_at=datetime.now(UTC) if mail_sent else None,
- )
- updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
- write_audit_log(
- db,
- action="repair_estimates.revoke",
- entity_type="repair_estimates",
- entity_id=updated.id,
- entity_label=_estimate_label(updated),
- actor=actor,
- request=request,
- before_data=before_data,
- after_data=_audit_estimate_data(updated),
- metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
- )
- write_audit_log(
- db,
- action="inventory.estimate.release",
- entity_type="repair_estimates",
- entity_id=updated.id,
- entity_label=_estimate_label(updated),
- actor=actor,
- request=request,
- metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
- )
- return updated
-
@staticmethod
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
if estimate is None:
@@ -391,7 +288,6 @@ class RepairEstimateService:
return PublicEstimateResponse(
estimate_number=estimate.estimate_number,
status=estimate.status,
- status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
title=estimate.title,
customer_message=estimate.customer_message,
subtotal_cents=estimate.subtotal_cents,
@@ -633,30 +529,4 @@ class RepairEstimateService:
Kostenvoranschlag: {escape(estimate.estimate_number)}
Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}
Kostenvoranschlag ansehen
-