diff --git a/.env.example b/.env.example
index afd6786..155c54e 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,7 @@ 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
@@ -44,3 +45,8 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
+
+# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
+LEXWARE_ENABLED=false
+LEXWARE_API_BASE_URL=https://api.lexware.io
+LEXWARE_API_KEY=
diff --git a/.gitignore b/.gitignore
index b36a9d7..5bd760f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,8 @@ out/
# Runtime storage
# ===========================
storage/
+/backups/
+*.dump
# ===========================
# Coverage
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8134159..3bced00 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -166,6 +166,58 @@ 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`.
@@ -221,7 +273,7 @@ Tabellen:
## Lager / Ersatzteile
-Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet.
+Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen.
Datenfluss:
@@ -238,6 +290,9 @@ Kernregeln:
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
+- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft.
+- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird.
+- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert.
Tabellen:
@@ -271,7 +326,16 @@ RBAC-Permissions:
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
-Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
+Reservierungslogik:
+
+- Entwurf: keine Reservierung.
+- Senden: verknuepfte Lagerartikel werden reserviert.
+- Freigabe: Reservierung bleibt bestehen.
+- Ablehnung: Reservierung wird freigegeben.
+- Storno: Reservierung wird freigegeben.
+- Loeschung eines gesendeten KV: Reservierung wird freigegeben.
+
+Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
Hermes-Endpunkte:
@@ -996,9 +1060,29 @@ 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` 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.
+`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
-`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
+`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`.
### Knowledge-RBAC
diff --git a/README-DEV.md b/README-DEV.md
index 70c613f..d309d74 100644
--- a/README-DEV.md
+++ b/README-DEV.md
@@ -42,10 +42,15 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
+LEXWARE_ENABLED=false
+LEXWARE_API_BASE_URL=https://api.lexware.io
+LEXWARE_API_KEY=
```
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
+`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
+
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
## Docker Netzwerk
@@ -129,6 +134,46 @@ 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:
@@ -179,6 +224,41 @@ 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`.
@@ -228,6 +308,7 @@ SECRET_KEY=local-check uv run alembic upgrade head
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
- `/api/inventory/items`
+- `/api/inventory/items/search`
- `/api/inventory/items/[id]`
- `/api/inventory/items/[id]/stock/adjust`
- `/api/inventory/items/[id]/stock/reserve`
@@ -251,10 +332,19 @@ Bestandslogik:
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`.
+KV-Integration ab v0.8.8:
+
+- Lagerartikel koennen im KV-Dialog gesucht und als Position uebernommen werden.
+- Hermes uebernimmt Verkaufspreis, Einheit, Artikelname, SKU und Hersteller serverseitig.
+- `repair_estimate_items` speichert Snapshot-Felder, damit spaetere Lageraenderungen alte KVs nicht veraendern.
+- Nur gesendete KVs reservieren Bestand.
+- Ablehnung, Storno und Loeschung eines gesendeten KV geben Reservierungen frei.
+- Freigegebene KVs behalten die Reservierung.
+- Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, aber noch nicht aktiv.
+
Folgefeatures:
-- Integration in Kostenvoranschlaege folgt in v0.8.8.
-- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv.
+- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv.
Vorbereitete Website-/Portal-Routen fuer spaeter:
diff --git a/ROADMAP.md b/ROADMAP.md
index 3b10ef2..7c0efb0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -162,20 +162,63 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
-## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant
+## v0.8.8 - Lagerintegration in Kostenvoranschläge
- Lagerartikel als KV-Position ueber Suche auswaehlen
- Verkaufspreis aus `selling_price_cents` uebernehmen
-- Reservierungen aus KV-/Reparaturprozess vorbereiten
-- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten
+- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen
+- Reservierung beim Senden eines KV
+- Freigabe behaelt Reservierung
+- Ablehnung, Storno und Loeschung geben Reservierungen frei
+- Bestandsbewegungen mit `reference_type=repair_estimate`
+- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand
+- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung
+- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv
+- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures
-## v0.8.9 - Kundenportal, geplant
+## v0.8.9 - Lexware Office Integration Foundation
+
+- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert
+- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten
+- Lexware-Konfiguration unter `/settings -> Lexware Office`
+- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel
+- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
+- API-Key wird nicht an Athena zurueckgegeben
+- Server-seitiger Verbindungstest gegen `GET /v1/profile`
+- Neue Tabelle `lexware_sync_records`
+- Optionale Lexware-Rechnungsreferenzen an `repair_estimates`
+- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
+- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
+- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
+- 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
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
-## v0.9.0 - Tickets, geplant
+## v0.9.3 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
@@ -183,10 +226,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen
-## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
+## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
- Paperless-ngx Connector fuer Wissensdokumente
-- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
+- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
- Sichere Connector-Konfiguration ohne Browser-Secrets
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example
index 6f7a6d0..cfaeb3b 100644
--- a/backend/hermes/.env.example
+++ b/backend/hermes/.env.example
@@ -31,3 +31,8 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
+
+# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
+LEXWARE_ENABLED=false
+LEXWARE_API_BASE_URL=https://api.lexware.io
+LEXWARE_API_KEY=
diff --git a/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py
new file mode 100644
index 0000000..58c82c7
--- /dev/null
+++ b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py
@@ -0,0 +1,67 @@
+"""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
new file mode 100644
index 0000000..bf98008
--- /dev/null
+++ b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py
@@ -0,0 +1,113 @@
+"""add lexware foundation
+
+Revision ID: c9d4e5f6a7b8
+Revises: a7c3e9d4b821
+Create Date: 2026-07-05 16:30:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "c9d4e5f6a7b8"
+down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+LEXWARE_PERMISSIONS = [
+ ("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
+ ("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
+ ("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
+]
+
+
+def upgrade() -> None:
+ op.create_table(
+ "lexware_sync_records",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("entity_type", sa.String(length=80), nullable=False),
+ sa.Column("entity_id", sa.Integer(), nullable=False),
+ sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
+ sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
+ sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
+ sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
+ sa.Column("payload_summary", sa.Text(), nullable=True),
+ sa.Column("error_message", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
+ op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
+ op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
+ op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
+ op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
+ op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
+
+ op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
+ op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
+ op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
+ op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
+
+ for name, display_name, description, module in LEXWARE_PERMISSIONS:
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO permissions (name, display_name, description, module)
+ VALUES (:name, :display_name, :description, :module)
+ ON CONFLICT (name) DO UPDATE SET
+ display_name = excluded.display_name,
+ description = excluded.description,
+ module = excluded.module
+ """
+ ).bindparams(name=name, display_name=display_name, description=description, module=module)
+ )
+
+ role_permissions = {
+ "administrator": [item[0] for item in LEXWARE_PERMISSIONS],
+ "management": [item[0] for item in LEXWARE_PERMISSIONS],
+ "support": ["lexware.read"],
+ }
+ for role_name, permission_names in role_permissions.items():
+ for permission_name in permission_names:
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO role_permissions (role_id, permission_id)
+ SELECT roles.id, permissions.id
+ FROM roles, permissions
+ WHERE roles.name = :role_name
+ AND permissions.name = :permission_name
+ ON CONFLICT DO NOTHING
+ """
+ ).bindparams(role_name=role_name, permission_name=permission_name)
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ sa.text(
+ """
+ DELETE FROM role_permissions
+ WHERE permission_id IN (
+ SELECT id FROM permissions WHERE module = 'lexware'
+ )
+ """
+ )
+ )
+ op.execute("DELETE FROM permissions WHERE module = 'lexware'")
+ op.drop_column("repair_estimates", "lexware_synced_at")
+ op.drop_column("repair_estimates", "lexware_invoice_status")
+ op.drop_column("repair_estimates", "lexware_invoice_number")
+ op.drop_column("repair_estimates", "lexware_invoice_id")
+ op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
+ op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
+ op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
+ op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
+ op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
+ op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
+ op.drop_table("lexware_sync_records")
diff --git a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py
new file mode 100644
index 0000000..0c7936b
--- /dev/null
+++ b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py
@@ -0,0 +1,53 @@
+"""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/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py
new file mode 100644
index 0000000..27dac33
--- /dev/null
+++ b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py
@@ -0,0 +1,44 @@
+"""extend repair estimate items inventory
+
+Revision ID: f4a9c2d7e118
+Revises: ef8a2d5c9017
+Create Date: 2026-07-05 13:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "f4a9c2d7e118"
+down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
+ op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
+ op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
+ op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
+ op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
+ op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
+ op.create_foreign_key(
+ "fk_repair_estimate_items_inventory_item_id",
+ "repair_estimate_items",
+ "inventory_items",
+ ["inventory_item_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
+ op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
+ op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
+ op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
+ op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
+ op.drop_column("repair_estimate_items", "inventory_snapshot_name")
+ op.drop_column("repair_estimate_items", "inventory_item_id")
diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py
index 3912a67..5704b10 100644
--- a/backend/hermes/app/api/audit.py
+++ b/backend/hermes/app/api/audit.py
@@ -58,8 +58,16 @@ 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
new file mode 100644
index 0000000..75e131e
--- /dev/null
+++ b/backend/hermes/app/api/backups.py
@@ -0,0 +1,150 @@
+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 c3201b4..02d2752 100644
--- a/backend/hermes/app/api/dashboard.py
+++ b/backend/hermes/app/api/dashboard.py
@@ -4,10 +4,12 @@ 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
@@ -15,6 +17,7 @@ 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__)
@@ -82,6 +85,35 @@ 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:
@@ -89,6 +121,9 @@ def get_dashboard_summary(
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
+ MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)),
+ MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)),
+ MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)),
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
]
@@ -129,6 +164,27 @@ 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/inventory.py b/backend/hermes/app/api/inventory.py
index b09f534..8fd3466 100644
--- a/backend/hermes/app/api/inventory.py
+++ b/backend/hermes/app/api/inventory.py
@@ -81,6 +81,18 @@ def list_items(
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
+@router.get("/items/search", response_model=list[InventoryItemResponse])
+def search_items(
+ q: str | None = None,
+ category: int | None = None,
+ manufacturer: str | None = None,
+ limit: int = Query(default=20, ge=1, le=50),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
+
+
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(
payload: InventoryItemCreate,
diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py
new file mode 100644
index 0000000..03b20c0
--- /dev/null
+++ b/backend/hermes/app/api/lexware.py
@@ -0,0 +1,89 @@
+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 56ab0fb..260754c 100644
--- a/backend/hermes/app/api/repair_estimates.py
+++ b/backend/hermes/app/api/repair_estimates.py
@@ -2,7 +2,7 @@ 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.core.rbac import require_any_permission, require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
@@ -117,13 +117,26 @@ def cancel_estimate(
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
- current_user: User = Depends(require_permission("repair_estimates.update")),
+ current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
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 9590c60..2a3d97f 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.1.0"
+ app_version: str = "0.9.1"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
smtp_from_email: str | None = None
smtp_from_name: str = "Funktechnik Schubert"
smtp_use_tls: bool = True
+ lexware_enabled: bool = False
+ lexware_api_base_url: str = "https://api.lexware.io"
+ lexware_api_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py
index dcc29f4..c860b24 100644
--- a/backend/hermes/app/db/database.py
+++ b/backend/hermes/app/db/database.py
@@ -25,9 +25,11 @@ 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 3b7c410..04c84ca 100644
--- a/backend/hermes/app/main.py
+++ b/backend/hermes/app/main.py
@@ -11,10 +11,12 @@ 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
@@ -32,12 +34,13 @@ configure_logging()
app = FastAPI(
title="Hermes API",
- version="0.1.0",
+ version="0.9.1",
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)
@@ -48,6 +51,7 @@ app.include_router(repairs_router)
app.include_router(repair_estimates_router)
app.include_router(dashboard_router)
app.include_router(system_settings_router)
+app.include_router(lexware_router)
logger = logging.getLogger(__name__)
diff --git a/backend/hermes/app/models/inventory.py b/backend/hermes/app/models/inventory.py
index 9428b9e..ecfdfc9 100644
--- a/backend/hermes/app/models/inventory.py
+++ b/backend/hermes/app/models/inventory.py
@@ -103,7 +103,11 @@ 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", lazy="joined")
+ actor = relationship(
+ "User",
+ foreign_keys="InventoryStockMovement.actor_user_id",
+ lazy="joined",
+ )
@property
def actor_username(self) -> str:
diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py
new file mode 100644
index 0000000..febb874
--- /dev/null
+++ b/backend/hermes/app/models/lexware.py
@@ -0,0 +1,27 @@
+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 53ba9e2..6b32497 100644
--- a/backend/hermes/app/models/repair.py
+++ b/backend/hermes/app/models/repair.py
@@ -56,7 +56,11 @@ 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", lazy="joined")
+ actor = relationship(
+ "User",
+ foreign_keys="RepairStatusHistory.actor_user_id",
+ lazy="joined",
+ )
@property
def actor_username(self) -> str:
@@ -105,7 +109,11 @@ 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", lazy="joined")
+ uploaded_by = relationship(
+ "User",
+ foreign_keys="RepairDocument.uploaded_by_user_id",
+ 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 81ec866..9cdda96 100644
--- a/backend/hermes/app/models/repair_estimate.py
+++ b/backend/hermes/app/models/repair_estimate.py
@@ -27,12 +27,29 @@ 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", 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",
+ )
items: Mapped[list["RepairEstimateItem"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
@@ -52,6 +69,11 @@ class RepairEstimateItem(Base):
id: Mapped[int] = mapped_column(primary_key=True)
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
+ inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True)
+ inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
+ inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="")
+ inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True)
+ inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True)
position: Mapped[int] = mapped_column(Integer)
item_type: Mapped[str] = mapped_column(String(40), index=True)
title: Mapped[str] = mapped_column(String(255))
@@ -64,6 +86,7 @@ class RepairEstimateItem(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
+ inventory_item = relationship("InventoryItem", lazy="joined")
class RepairEstimateEvent(Base):
@@ -78,4 +101,8 @@ 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", lazy="joined")
+ actor = relationship(
+ "User",
+ foreign_keys="RepairEstimateEvent.actor_user_id",
+ lazy="joined",
+ )
diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py
index 1191da6..6661bff 100644
--- a/backend/hermes/app/rbac/defaults.py
+++ b/backend/hermes/app/rbac/defaults.py
@@ -90,6 +90,7 @@ 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"),
@@ -98,6 +99,14 @@ 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 = {
@@ -119,6 +128,7 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
+ "repair_estimates.revoke",
"inventory.read",
"inventory.create",
"inventory.update",
@@ -126,6 +136,12 @@ 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",
@@ -175,6 +191,7 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
+ "lexware.read",
},
"warehouse": {
"dashboard.read",
diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py
index ff2fe8e..19c2db2 100644
--- a/backend/hermes/app/repositories/inventory_repository.py
+++ b/backend/hermes/app/repositories/inventory_repository.py
@@ -69,6 +69,32 @@ class InventoryRepository:
)
return items, total
+ @staticmethod
+ def search_items(
+ db: Session,
+ *,
+ q: str | None = None,
+ category_id: int | None = None,
+ manufacturer: str | None = None,
+ limit: int = 20,
+ ) -> list[InventoryItem]:
+ query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
+ if q:
+ pattern = f"%{q.strip()}%"
+ query = query.where(or_(
+ InventoryItem.sku.ilike(pattern),
+ InventoryItem.name.ilike(pattern),
+ InventoryItem.manufacturer.ilike(pattern),
+ InventoryItem.manufacturer_part_number.ilike(pattern),
+ InventoryItem.supplier_part_number.ilike(pattern),
+ InventoryItem.compatible_devices.ilike(pattern),
+ ))
+ if category_id is not None:
+ query = query.where(InventoryItem.category_id == category_id)
+ if manufacturer:
+ query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
+ return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
+
@staticmethod
def get_item(db: Session, item_id: int) -> InventoryItem | None:
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
@@ -176,6 +202,22 @@ class InventoryRepository:
.where(InventoryItem.purchase_price_cents.is_not(None))
) or 0
+ @staticmethod
+ def count_reserved_items(db: Session) -> int:
+ return db.scalar(
+ select(func.count(InventoryItem.id))
+ .where(InventoryItem.is_active.is_(True))
+ .where(InventoryItem.quantity_reserved > 0)
+ ) or 0
+
+ @staticmethod
+ def reserved_stock_value_cents(db: Session) -> int:
+ return db.scalar(
+ select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
+ .where(InventoryItem.is_active.is_(True))
+ .where(InventoryItem.selling_price_cents.is_not(None))
+ ) or 0
+
@staticmethod
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
return list(
diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py
index 7519a94..db20a7c 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"]))
+ .where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
.limit(1)
)
@@ -123,3 +123,14 @@ 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
new file mode 100644
index 0000000..661d1e9
--- /dev/null
+++ b/backend/hermes/app/schemas/backup.py
@@ -0,0 +1,57 @@
+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
new file mode 100644
index 0000000..45cd103
--- /dev/null
+++ b/backend/hermes/app/schemas/lexware.py
@@ -0,0 +1,96 @@
+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 3410a04..b1b68e5 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"]
+EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
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"]
+EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
def normalize_text(value: object) -> str:
@@ -18,7 +18,9 @@ def normalize_text(value: object) -> str:
class RepairEstimateItemPayload(BaseModel):
item_type: EstimateItemType = "other"
- title: str = Field(min_length=1, max_length=255)
+ inventory_item_id: int | None = None
+ inventory_price_overridden: bool = False
+ title: str = Field(default="", max_length=255)
description: str | None = None
quantity: Decimal = Field(gt=Decimal("0"))
unit: str = Field(default="Stk.", max_length=40)
@@ -38,6 +40,12 @@ class RepairEstimateItemPayload(BaseModel):
return value.strip().replace(",", ".")
return value
+ @model_validator(mode="after")
+ def validate_inventory_or_title(self):
+ if self.inventory_item_id is None and not self.title.strip():
+ raise ValueError("Titel ist erforderlich")
+ return self
+
class RepairEstimatePayload(BaseModel):
title: str = Field(min_length=1, max_length=255)
@@ -85,6 +93,11 @@ class RepairEstimateUpdate(RepairEstimatePayload):
class RepairEstimateItemResponse(BaseModel):
id: int
estimate_id: int
+ inventory_item_id: int | None
+ inventory_snapshot_name: str
+ inventory_snapshot_sku: str
+ inventory_snapshot_manufacturer: str | None
+ inventory_snapshot_part_number: str | None
position: int
item_type: EstimateItemType
title: str
@@ -129,6 +142,14 @@ 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
@@ -139,6 +160,10 @@ class RepairEstimateResponse(BaseModel):
class PublicEstimateItemResponse(BaseModel):
item_type: EstimateItemType
+ inventory_snapshot_name: str
+ inventory_snapshot_sku: str
+ inventory_snapshot_manufacturer: str | None
+ inventory_snapshot_part_number: str | None
title: str
description: str | None
quantity: Decimal
@@ -150,6 +175,7 @@ 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 d46ef66..14110e0 100644
--- a/backend/hermes/app/services/audit_service.py
+++ b/backend/hermes/app/services/audit_service.py
@@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
-SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
+SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@@ -179,6 +179,7 @@ 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",
@@ -188,6 +189,11 @@ def action_title(action: str) -> str:
"inventory.stock.reserve": "Bestand reserviert",
"inventory.stock.release": "Reservierung aufgehoben",
"inventory.stock.consume": "Bestand verbraucht",
+ "inventory.estimate.reserve": "KV reserviert Lagerbestand",
+ "inventory.estimate.release": "KV-Reservierung aufgehoben",
+ "inventory.estimate.consume": "Reservierung verbraucht",
+ "inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben",
+ "inventory.stock.low": "Lagerbestand knapp",
"inventory.categories.create": "Lagerkategorie erstellt",
"inventory.categories.update": "Lagerkategorie geändert",
"inventory.categories.delete": "Lagerkategorie gelöscht",
@@ -201,6 +207,21 @@ 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
new file mode 100644
index 0000000..b84ffe2
--- /dev/null
+++ b/backend/hermes/app/services/backup_service.py
@@ -0,0 +1,373 @@
+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 4960557..49afd72 100644
--- a/backend/hermes/app/services/inventory_service.py
+++ b/backend/hermes/app/services/inventory_service.py
@@ -1,5 +1,6 @@
import re
import unicodedata
+from decimal import Decimal
from typing import TypeVar
from fastapi import HTTPException, status
@@ -8,6 +9,7 @@ from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
+from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.schemas.inventory import (
@@ -194,6 +196,89 @@ class InventoryService:
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
+ @staticmethod
+ def reserve_for_estimate(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
+ item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
+ if item is None or not item.is_active:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
+ quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
+ if quantity > item.quantity_available:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
+ )
+ item.quantity_reserved += quantity
+ InventoryService._recalculate_available(item)
+ InventoryRepository.create_movement(
+ db,
+ item_id=item.id,
+ movement_type="reservation",
+ quantity=quantity,
+ reason="estimate_reserved",
+ reference_type="repair_estimate",
+ reference_id=estimate.id,
+ note=f"Kostenvoranschlag {estimate.estimate_number}",
+ actor_user_id=actor_user_id,
+ )
+
+ @staticmethod
+ def release_estimate_reservation(
+ db: Session,
+ estimate: RepairEstimate,
+ *,
+ actor_user_id: int | None,
+ reason: str = "estimate_released",
+ ) -> None:
+ for estimate_item in estimate.items:
+ if estimate_item.inventory_item_id is None:
+ continue
+ item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
+ if item is None:
+ continue
+ quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
+ item.quantity_reserved = max(0, item.quantity_reserved - quantity)
+ InventoryService._recalculate_available(item)
+ InventoryRepository.create_movement(
+ db,
+ item_id=item.id,
+ movement_type="release",
+ quantity=quantity,
+ reason=reason,
+ reference_type="repair_estimate",
+ reference_id=estimate.id,
+ note=f"Kostenvoranschlag {estimate.estimate_number}",
+ actor_user_id=actor_user_id,
+ )
+
+ @staticmethod
+ def consume_reserved_stock(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
+ item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
+ if item is None:
+ continue
+ quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
+ if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
+ item.quantity_reserved -= quantity
+ item.quantity_on_hand -= quantity
+ InventoryService._recalculate_available(item)
+ InventoryRepository.create_movement(
+ db,
+ item_id=item.id,
+ movement_type="consumption",
+ quantity=quantity,
+ reason="estimate_consumed",
+ reference_type="repair_estimate",
+ reference_id=estimate.id,
+ note=f"Kostenvoranschlag {estimate.estimate_number}",
+ actor_user_id=actor_user_id,
+ )
+
@staticmethod
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
@@ -260,6 +345,18 @@ class InventoryService:
if payload.quantity <= 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
+ @staticmethod
+ def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
+ if quantity != quantity.to_integral_value():
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
+ )
+ normalized = int(quantity)
+ if normalized <= 0:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
+ return normalized
+
@staticmethod
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
InventoryService._recalculate_available(item)
diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py
new file mode 100644
index 0000000..61ffe38
--- /dev/null
+++ b/backend/hermes/app/services/lexware_service.py
@@ -0,0 +1,518 @@
+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 e933587..f096b84 100644
--- a/backend/hermes/app/services/repair_estimate_service.py
+++ b/backend/hermes/app/services/repair_estimate_service.py
@@ -10,6 +10,7 @@ from starlette.requests import Request
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
from app.models.user import User
+from app.repositories.inventory_repository import InventoryRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairStatusUpdate
@@ -24,6 +25,7 @@ from app.schemas.repair_estimate import (
calculate_tax,
)
from app.services.audit_service import write_audit_log
+from app.services.inventory_service import InventoryService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.system_settings_service import SystemSettingsService
@@ -53,9 +55,21 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
"approved_at": estimate.approved_at,
"declined_at": estimate.declined_at,
"created_by_user_id": estimate.created_by_user_id,
+ "inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None],
}
+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:
@@ -66,7 +80,8 @@ class RepairEstimateService:
status="draft",
created_by_user_id=actor.id,
)
- RepairEstimateService._apply_payload(estimate, payload)
+ price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
+ RepairEstimateService._apply_payload(db, estimate, payload)
try:
db.add(estimate)
db.flush()
@@ -92,6 +107,7 @@ class RepairEstimateService:
after_data=_audit_estimate_data(estimate),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
+ RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request)
return estimate
@staticmethod
@@ -99,7 +115,24 @@ class RepairEstimateService:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
before_data = _audit_estimate_data(estimate)
- RepairEstimateService._apply_payload(estimate, payload)
+ price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
+ was_sent = estimate.status == "sent"
+ if was_sent:
+ InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
+ RepairEstimateService._apply_payload(db, estimate, payload)
+ if was_sent:
+ InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
+ write_audit_log(
+ db,
+ action="inventory.estimate.reserve",
+ entity_type="repair_estimates",
+ entity_id=estimate.id,
+ entity_label=_estimate_label(estimate),
+ actor=actor,
+ request=request,
+ metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
+ )
+ RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
@@ -116,14 +149,27 @@ class RepairEstimateService:
after_data=_audit_estimate_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
+ RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request)
return updated
@staticmethod
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
- if estimate.status not in {"draft", "cancelled"}:
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden")
+ if estimate.status not in {"draft", "sent", "cancelled"}:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
before_data = _audit_estimate_data(estimate)
label = _estimate_label(estimate)
+ if estimate.status == "sent":
+ InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
+ write_audit_log(
+ db,
+ action="inventory.estimate.release",
+ entity_type="repair_estimates",
+ entity_id=estimate.id,
+ entity_label=label,
+ actor=actor,
+ request=request,
+ metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number},
+ )
RepairEstimateRepository.delete(db, estimate)
write_audit_log(
db,
@@ -145,6 +191,10 @@ class RepairEstimateService:
if not repair.customer_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
+ was_sent = estimate.status == "sent"
+ if not was_sent:
+ InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
+
link = RepairPublicLinkService.create_with_audit(
db,
repair,
@@ -155,6 +205,18 @@ class RepairEstimateService:
public_status_url = link.public_status_path
estimate = RepairEstimateRepository.mark_sent(db, estimate)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
+ if not was_sent:
+ write_audit_log(
+ db,
+ action="inventory.estimate.reserve",
+ entity_type="repair_estimates",
+ entity_id=estimate.id,
+ entity_label=_estimate_label(estimate),
+ actor=actor,
+ request=request,
+ metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
+ )
+ RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured:
@@ -198,8 +260,11 @@ class RepairEstimateService:
@staticmethod
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
- if estimate.status in {"approved", "declined", "cancelled"}:
+ if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
+ was_sent = estimate.status == "sent"
+ if was_sent:
+ InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
estimate.status = "cancelled"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
@@ -214,8 +279,111 @@ class RepairEstimateService:
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
+ if was_sent:
+ write_audit_log(
+ db,
+ action="inventory.estimate.release",
+ entity_type="repair_estimates",
+ entity_id=estimate.id,
+ entity_label=_estimate_label(estimate),
+ actor=actor,
+ request=request,
+ metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number},
+ )
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:
@@ -223,6 +391,7 @@ 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,
@@ -233,6 +402,10 @@ class RepairEstimateService:
items=[
PublicEstimateItemResponse(
item_type=item.item_type,
+ inventory_snapshot_name=item.inventory_snapshot_name,
+ inventory_snapshot_sku=item.inventory_snapshot_sku,
+ inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer,
+ inventory_snapshot_part_number=item.inventory_snapshot_part_number,
title=item.title,
description=item.description,
quantity=item.quantity,
@@ -272,6 +445,7 @@ class RepairEstimateService:
repair_status = "waiting_for_customer"
note = "Kunde hat den Kostenvoranschlag abgelehnt"
audit_action = "repair_estimates.decline"
+ InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
else:
event_type = "question"
repair_status = "waiting_for_customer"
@@ -291,17 +465,27 @@ class RepairEstimateService:
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
)
+ if decision == "decline":
+ write_audit_log(
+ db,
+ action="inventory.estimate.release",
+ entity_type="repair_estimates",
+ entity_id=estimate.id,
+ entity_label=_estimate_label(estimate),
+ request=request,
+ metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
+ )
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
- def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
+ def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
estimate.title = payload.title
estimate.customer_message = payload.customer_message
estimate.internal_note = payload.internal_note
estimate.tax_rate_percent = payload.tax_rate_percent
estimate.currency = payload.currency
estimate.valid_until = payload.valid_until
- items, subtotal = RepairEstimateService._build_items(payload.items)
+ items, subtotal = RepairEstimateService._build_items(db, payload.items)
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
estimate.subtotal_cents = subtotal
estimate.tax_cents = tax_cents
@@ -309,26 +493,121 @@ class RepairEstimateService:
estimate.items = items
@staticmethod
- def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]:
+ def _build_items(
+ db: Session,
+ payload_items: list[RepairEstimateItemPayload],
+ ) -> tuple[list[RepairEstimateItem], int]:
items: list[RepairEstimateItem] = []
subtotal = 0
for index, payload in enumerate(payload_items, start=1):
- total = calculate_item_total(payload.quantity, payload.unit_price_cents)
+ item_type = payload.item_type
+ title = payload.title
+ description = payload.description
+ unit = payload.unit
+ unit_price_cents = payload.unit_price_cents
+ inventory_item_id = payload.inventory_item_id
+ inventory_snapshot_name = ""
+ inventory_snapshot_sku = ""
+ inventory_snapshot_manufacturer = None
+ inventory_snapshot_part_number = None
+ if inventory_item_id is not None:
+ inventory_item = InventoryRepository.get_item(db, inventory_item_id)
+ if inventory_item is None or not inventory_item.is_active:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden")
+ item_type = "part"
+ title = inventory_item.name
+ unit = inventory_item.unit
+ inventory_snapshot_name = inventory_item.name
+ inventory_snapshot_sku = inventory_item.sku
+ inventory_snapshot_manufacturer = inventory_item.manufacturer
+ inventory_snapshot_part_number = inventory_item.manufacturer_part_number
+ if not payload.inventory_price_overridden:
+ unit_price_cents = inventory_item.selling_price_cents or 0
+ total = calculate_item_total(payload.quantity, unit_price_cents)
subtotal += total
items.append(
RepairEstimateItem(
+ inventory_item_id=inventory_item_id,
+ inventory_snapshot_name=inventory_snapshot_name,
+ inventory_snapshot_sku=inventory_snapshot_sku,
+ inventory_snapshot_manufacturer=inventory_snapshot_manufacturer,
+ inventory_snapshot_part_number=inventory_snapshot_part_number,
position=index,
- item_type=payload.item_type,
- title=payload.title,
- description=payload.description,
+ item_type=item_type,
+ title=title,
+ description=description,
quantity=payload.quantity,
- unit=payload.unit,
- unit_price_cents=payload.unit_price_cents,
+ unit=unit,
+ unit_price_cents=unit_price_cents,
total_cents=total,
)
)
return items, subtotal
+ @staticmethod
+ def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]:
+ overrides: list[dict] = []
+ for payload in payload_items:
+ if payload.inventory_item_id is None or not payload.inventory_price_overridden:
+ continue
+ inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id)
+ if inventory_item is None:
+ continue
+ default_price = inventory_item.selling_price_cents or 0
+ if payload.unit_price_cents != default_price:
+ overrides.append({
+ "inventory_item_id": inventory_item.id,
+ "label": f"{inventory_item.sku} · {inventory_item.name}",
+ "default_price_cents": default_price,
+ "override_price_cents": payload.unit_price_cents,
+ })
+ return overrides
+
+ @staticmethod
+ def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None:
+ for override in overrides:
+ write_audit_log(
+ db,
+ action="inventory.estimate.price_override",
+ entity_type="inventory_items",
+ entity_id=override["inventory_item_id"],
+ entity_label=override["label"],
+ actor=actor,
+ request=request,
+ metadata={
+ "estimate_id": estimate.id,
+ "estimate_number": estimate.estimate_number,
+ "default_price_cents": override["default_price_cents"],
+ "override_price_cents": override["override_price_cents"],
+ },
+ )
+
+ @staticmethod
+ def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
+ seen_item_ids: set[int] = set()
+ for estimate_item in estimate.items:
+ if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids:
+ continue
+ seen_item_ids.add(estimate_item.inventory_item_id)
+ inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
+ if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level:
+ continue
+ write_audit_log(
+ db,
+ action="inventory.stock.low",
+ entity_type="inventory_items",
+ entity_id=inventory_item.id,
+ entity_label=f"{inventory_item.sku} · {inventory_item.name}",
+ actor=actor,
+ request=request,
+ metadata={
+ "estimate_id": estimate.id,
+ "estimate_number": estimate.estimate_number,
+ "quantity_available": inventory_item.quantity_available,
+ "reorder_level": inventory_item.reorder_level,
+ },
+ )
+
@staticmethod
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
@@ -354,4 +633,30 @@ class RepairEstimateService:
Kostenvoranschlag: {escape(estimate.estimate_number)}
Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}
Kostenvoranschlag ansehen
+