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 fdac253..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`.
@@ -219,6 +271,72 @@ Tabellen:
- `repair_public_access_tokens`
- `repair_notification_events`
+## Lager / Ersatzteile
+
+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:
+
+```text
+Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL
+```
+
+Kernregeln:
+
+- Browser greifen nie direkt auf Hermes zu.
+- Artikelnummern/SKUs sind eindeutig.
+- Leere SKU bei Erstellung wird automatisch im Format `ET--000001` vergeben.
+- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben.
+- `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:
+
+- `inventory_items`
+- `inventory_categories`
+- `inventory_locations`
+- `inventory_suppliers`
+- `inventory_stock_movements`
+
+Bestandsbewegungen:
+
+- `initial`
+- `purchase`
+- `adjustment`
+- `reservation`
+- `release`
+- `consumption`
+- `return`
+- `correction`
+
+RBAC-Permissions:
+
+- `inventory.read`
+- `inventory.create`
+- `inventory.update`
+- `inventory.delete`
+- `inventory.stock.adjust`
+- `inventory.stock.reserve`
+- `inventory.stock.consume`
+- `inventory.manage.masterdata`
+
+Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
+
+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:
- `GET /repairs`
@@ -942,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 896ad69..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`.
@@ -214,6 +294,58 @@ Kostenvoranschlaege:
- Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit.
- PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures.
+## Lager / Ersatzteile
+
+Das Lagermodul ist ab v0.8.7 aktiv.
+
+Migration lokal ausfuehren:
+
+```bash
+cd backend/hermes
+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`
+- `/api/inventory/items/[id]/stock/release`
+- `/api/inventory/items/[id]/stock/consume`
+- `/api/inventory/items/[id]/movements`
+- `/api/inventory/categories`
+- `/api/inventory/categories/[id]`
+- `/api/inventory/locations`
+- `/api/inventory/locations/[id]`
+- `/api/inventory/suppliers`
+- `/api/inventory/suppliers/[id]`
+
+Hermes-Endpunkte liegen unter `/inventory/...` und sind mit `inventory.*` Permissions geschuetzt.
+
+Bestandslogik:
+
+- SKU optional; bei leerer SKU erzeugt Hermes `ET--000001`.
+- `quantity_available = quantity_on_hand - quantity_reserved`.
+- Bestandsanpassungen, Reservierungen, Freigaben und Verbrauch erzeugen `inventory_stock_movements`.
+- 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:
+
+- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv.
+
Vorbereitete Website-/Portal-Routen fuer spaeter:
- `/status/`
diff --git a/ROADMAP.md b/ROADMAP.md
index 8248ce1..7c0efb0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -147,13 +147,78 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version
- Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen
-## v0.8.7 - Kundenportal, geplant
+## v0.8.7 - Lager / Ersatzteile
+
+- Ersatzteil- und Lagerartikelverwaltung
+- Kategorien, Lagerorte und Lieferanten
+- SKU-Format `ET--000001`
+- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit
+- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch
+- Preise intern in cents, UI-Eingabe in Euro
+- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten
+- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen
+- Audit Logs und Activity Feed fuer Lageraktionen
+- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]`
+- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
+- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
+
+## v0.8.8 - Lagerintegration in Kostenvoranschläge
+
+- Lagerartikel als KV-Position ueber Suche auswaehlen
+- Verkaufspreis aus `selling_price_cents` uebernehmen
+- 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 - 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
@@ -161,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/env.py b/backend/hermes/alembic/env.py
index 474acef..3217c11 100644
--- a/backend/hermes/alembic/env.py
+++ b/backend/hermes/alembic/env.py
@@ -14,6 +14,7 @@ import app.models.audit
import app.models.rbac
import app.models.repair
import app.models.repair_estimate
+import app.models.inventory
import app.models.system_setting
config = context.config
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/ef8a2d5c9017_create_inventory.py b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
new file mode 100644
index 0000000..e422b5a
--- /dev/null
+++ b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
@@ -0,0 +1,214 @@
+"""create inventory
+
+Revision ID: ef8a2d5c9017
+Revises: b4e8f1c7a205
+Create Date: 2026-07-05 12:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "ef8a2d5c9017"
+down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+INVENTORY_PERMISSIONS = [
+ ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
+ ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
+ ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
+ ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
+ ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
+ ("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"),
+]
+
+
+def upgrade() -> None:
+ op.create_table(
+ "inventory_categories",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=160), nullable=False),
+ sa.Column("slug", sa.String(length=180), nullable=False),
+ sa.Column("description", 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.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ sa.UniqueConstraint("slug"),
+ )
+ op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
+ op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
+
+ op.create_table(
+ "inventory_locations",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=160), nullable=False),
+ sa.Column("description", 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.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+ op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
+
+ op.create_table(
+ "inventory_suppliers",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=180), nullable=False),
+ sa.Column("contact_name", sa.String(length=180), nullable=True),
+ sa.Column("email", sa.String(length=255), nullable=True),
+ sa.Column("phone", sa.String(length=80), nullable=True),
+ sa.Column("website", sa.String(length=255), nullable=True),
+ sa.Column("customer_number", sa.String(length=120), nullable=True),
+ sa.Column("notes", 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.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+ op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
+
+ op.create_table(
+ "inventory_items",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("sku", sa.String(length=40), nullable=False),
+ sa.Column("name", sa.String(length=255), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("category_id", sa.Integer(), nullable=True),
+ sa.Column("manufacturer", sa.String(length=180), nullable=True),
+ sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
+ sa.Column("supplier_id", sa.Integer(), nullable=True),
+ sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
+ sa.Column("compatible_devices", sa.Text(), nullable=True),
+ sa.Column("location_id", sa.Integer(), nullable=True),
+ sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
+ sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
+ sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
+ sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
+ sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
+ sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
+ sa.Column("selling_price_cents", sa.Integer(), nullable=True),
+ sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
+ sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
+ 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.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
+ )
+ op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
+ op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
+ op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
+ op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
+ op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
+ op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
+ op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
+ op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
+ op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
+ op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
+
+ op.create_table(
+ "inventory_stock_movements",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("item_id", sa.Integer(), nullable=False),
+ sa.Column("movement_type", sa.String(length=40), nullable=False),
+ sa.Column("quantity", sa.Integer(), nullable=False),
+ sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
+ sa.Column("reference_type", sa.String(length=80), nullable=True),
+ sa.Column("reference_id", sa.Integer(), nullable=True),
+ sa.Column("note", sa.Text(), nullable=True),
+ sa.Column("actor_user_id", sa.Integer(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
+ op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
+ op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
+ op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
+ op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
+
+ for name, display_name, description, module in INVENTORY_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 INVENTORY_PERMISSIONS],
+ "management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
+ "technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
+ "support": ["inventory.read"],
+ "warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
+ }
+ 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 = 'inventory'
+ )
+ """
+ )
+ )
+ op.execute("DELETE FROM permissions WHERE module = 'inventory'")
+ op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
+ op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
+ op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
+ op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
+ op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
+ op.drop_table("inventory_stock_movements")
+ op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
+ op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
+ op.drop_table("inventory_items")
+ op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
+ op.drop_table("inventory_suppliers")
+ op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
+ op.drop_table("inventory_locations")
+ op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
+ op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
+ op.drop_table("inventory_categories")
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/__pycache__/main.cpython-314.pyc b/backend/hermes/app/__pycache__/main.cpython-314.pyc
index 1be2f4b..fe05fec 100644
Binary files a/backend/hermes/app/__pycache__/main.cpython-314.pyc and b/backend/hermes/app/__pycache__/main.cpython-314.pyc differ
diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py
index efa3d8e..5704b10 100644
--- a/backend/hermes/app/api/audit.py
+++ b/backend/hermes/app/api/audit.py
@@ -58,6 +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 2ef835a..02d2752 100644
--- a/backend/hermes/app/api/dashboard.py
+++ b/backend/hermes/app/api/dashboard.py
@@ -4,16 +4,20 @@ 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
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__)
@@ -35,6 +39,7 @@ def get_dashboard_summary(
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
+ inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
if "customers.read" in permissions:
@@ -80,8 +85,48 @@ 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:
+ inventory = [
+ 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))),
+ ]
+
if "system_settings.manage" in permissions:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
public_links = SystemSettingsService.get_public_links_settings(db)
@@ -119,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(
@@ -127,6 +193,7 @@ def get_dashboard_summary(
users=users,
roles=roles,
repairs=repairs,
+ inventory=inventory,
system_status=system_status,
activities=EmptyWidget(
title="Letzte Aktivitäten",
diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py
new file mode 100644
index 0000000..8fd3466
--- /dev/null
+++ b/backend/hermes/app/api/inventory.py
@@ -0,0 +1,319 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, 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.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
+from app.models.user import User
+from app.repositories.inventory_repository import InventoryRepository
+from app.schemas.inventory import (
+ InventoryCategoryPayload,
+ InventoryCategoryResponse,
+ InventoryItemCreate,
+ InventoryItemListResponse,
+ InventoryItemResponse,
+ InventoryItemUpdate,
+ InventoryLocationPayload,
+ InventoryLocationResponse,
+ InventoryStockAction,
+ InventoryStockMovementResponse,
+ InventorySupplierPayload,
+ InventorySupplierResponse,
+)
+from app.services.inventory_service import InventoryService
+
+router = APIRouter(prefix="/inventory", tags=["Inventory"])
+
+
+def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
+ item = InventoryRepository.get_item(db, item_id)
+ if item is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
+ return item
+
+
+def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
+ category = InventoryRepository.get_category(db, category_id)
+ if category is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
+ return category
+
+
+def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
+ location = InventoryRepository.get_location(db, location_id)
+ if location is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
+ return location
+
+
+def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
+ supplier = InventoryRepository.get_supplier(db, supplier_id)
+ if supplier is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
+ return supplier
+
+
+@router.get("/items", response_model=InventoryItemListResponse)
+def list_items(
+ q: str | None = None,
+ category_id: int | None = None,
+ supplier_id: int | None = None,
+ location_id: int | None = None,
+ low_stock: bool | None = None,
+ active: bool | None = True,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ items, total = InventoryRepository.list_items(
+ db,
+ q=q,
+ category_id=category_id,
+ supplier_id=supplier_id,
+ location_id=location_id,
+ low_stock=low_stock,
+ active=active,
+ limit=limit,
+ offset=offset,
+ )
+ 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,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.create")),
+):
+ return InventoryService.create_item(db, payload, actor=current_user, request=request)
+
+
+@router.get("/items/{item_id}", response_model=InventoryItemResponse)
+def get_item(
+ item_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ return get_item_or_404(db, item_id)
+
+
+@router.put("/items/{item_id}", response_model=InventoryItemResponse)
+def update_item(
+ item_id: int,
+ payload: InventoryItemUpdate,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.update")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
+
+
+@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
+def delete_item(
+ item_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.delete")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.delete_item(db, item, actor=current_user, request=request)
+
+
+@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
+def adjust_stock(
+ item_id: int,
+ payload: InventoryStockAction,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.stock.adjust")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
+
+
+@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
+def reserve_stock(
+ item_id: int,
+ payload: InventoryStockAction,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.stock.reserve")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
+
+
+@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
+def release_stock(
+ item_id: int,
+ payload: InventoryStockAction,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.stock.reserve")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
+
+
+@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
+def consume_stock(
+ item_id: int,
+ payload: InventoryStockAction,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.stock.consume")),
+):
+ item = get_item_or_404(db, item_id)
+ return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
+
+
+@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
+def list_movements(
+ item_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ get_item_or_404(db, item_id)
+ return InventoryRepository.list_movements(db, item_id)
+
+
+@router.get("/categories", response_model=list[InventoryCategoryResponse])
+def list_categories(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ return InventoryRepository.list_categories(db)
+
+
+@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
+def create_category(
+ payload: InventoryCategoryPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ return InventoryService.create_category(db, payload, actor=current_user, request=request)
+
+
+@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
+def update_category(
+ category_id: int,
+ payload: InventoryCategoryPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ category = get_category_or_404(db, category_id)
+ return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
+
+
+@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_category(
+ category_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ category = get_category_or_404(db, category_id)
+ InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
+ return None
+
+
+@router.get("/locations", response_model=list[InventoryLocationResponse])
+def list_locations(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ return InventoryRepository.list_locations(db)
+
+
+@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
+def create_location(
+ payload: InventoryLocationPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ return InventoryService.create_location(db, payload, actor=current_user, request=request)
+
+
+@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
+def update_location(
+ location_id: int,
+ payload: InventoryLocationPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ location = get_location_or_404(db, location_id)
+ return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
+
+
+@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_location(
+ location_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ location = get_location_or_404(db, location_id)
+ InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
+ return None
+
+
+@router.get("/suppliers", response_model=list[InventorySupplierResponse])
+def list_suppliers(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.read")),
+):
+ return InventoryRepository.list_suppliers(db)
+
+
+@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
+def create_supplier(
+ payload: InventorySupplierPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
+
+
+@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
+def update_supplier(
+ supplier_id: int,
+ payload: InventorySupplierPayload,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ supplier = get_supplier_or_404(db, supplier_id)
+ return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
+
+
+@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_supplier(
+ supplier_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_permission("inventory.manage.masterdata")),
+):
+ supplier = get_supplier_or_404(db, supplier_id)
+ InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
+ return None
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/__pycache__/config.cpython-314.pyc b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc
index c828751..c789c9b 100644
Binary files a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc and b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc differ
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/__pycache__/database.cpython-314.pyc b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc
index 79708cf..a25929a 100644
Binary files a/backend/hermes/app/db/__pycache__/database.cpython-314.pyc and b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc differ
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 9443c1e..04c84ca 100644
--- a/backend/hermes/app/main.py
+++ b/backend/hermes/app/main.py
@@ -11,9 +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
@@ -31,21 +34,24 @@ 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)
app.include_router(customers_router)
+app.include_router(inventory_router)
app.include_router(knowledge_router)
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/__pycache__/user.cpython-314.pyc b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc
index 5a9e9b9..d25fb9d 100644
Binary files a/backend/hermes/app/models/__pycache__/user.cpython-314.pyc and b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc differ
diff --git a/backend/hermes/app/models/inventory.py b/backend/hermes/app/models/inventory.py
new file mode 100644
index 0000000..ecfdfc9
--- /dev/null
+++ b/backend/hermes/app/models/inventory.py
@@ -0,0 +1,114 @@
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.database import Base
+
+
+class InventoryCategory(Base):
+ __tablename__ = "inventory_categories"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
+ slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
+ description: 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())
+
+ items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
+
+
+class InventoryLocation(Base):
+ __tablename__ = "inventory_locations"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
+ description: 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())
+
+ items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
+
+
+class InventorySupplier(Base):
+ __tablename__ = "inventory_suppliers"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
+ contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
+ website: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
+ notes: 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())
+
+ items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
+
+
+class InventoryItem(Base):
+ __tablename__ = "inventory_items"
+ __table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
+ name: Mapped[str] = mapped_column(String(255), index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
+ manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
+ manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
+ supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
+ supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
+ compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
+ location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
+ quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
+ reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
+ purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
+ tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="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())
+
+ category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
+ supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
+ location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
+ movements: Mapped[list["InventoryStockMovement"]] = relationship(
+ back_populates="item",
+ cascade="all, delete-orphan",
+ order_by="InventoryStockMovement.created_at.desc()",
+ )
+
+
+class InventoryStockMovement(Base):
+ __tablename__ = "inventory_stock_movements"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
+ movement_type: Mapped[str] = mapped_column(String(40), index=True)
+ quantity: Mapped[int] = mapped_column(Integer)
+ reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
+ reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
+ reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ actor_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())
+
+ item: Mapped[InventoryItem] = relationship(back_populates="movements")
+ actor = relationship(
+ "User",
+ foreign_keys="InventoryStockMovement.actor_user_id",
+ lazy="joined",
+ )
+
+ @property
+ def actor_username(self) -> str:
+ return self.actor.username if self.actor is not None else ""
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 12490a9..6661bff 100644
--- a/backend/hermes/app/rbac/defaults.py
+++ b/backend/hermes/app/rbac/defaults.py
@@ -90,6 +90,23 @@ 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"),
+ ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
+ ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
+ ("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 = {
@@ -111,6 +128,20 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
+ "repair_estimates.revoke",
+ "inventory.read",
+ "inventory.create",
+ "inventory.update",
+ "inventory.stock.adjust",
+ "inventory.stock.reserve",
+ "inventory.stock.consume",
+ "inventory.manage.masterdata",
+ "lexware.read",
+ "lexware.manage",
+ "lexware.export",
+ "backup.read",
+ "backup.create",
+ "backup.download",
},
"sales": {
"dashboard.read",
@@ -138,6 +169,9 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
+ "inventory.read",
+ "inventory.stock.reserve",
+ "inventory.stock.consume",
},
"support": {
"dashboard.read",
@@ -156,9 +190,18 @@ ROLE_PERMISSION_NAMES = {
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.send",
+ "inventory.read",
+ "lexware.read",
},
"warehouse": {
"dashboard.read",
+ "inventory.read",
+ "inventory.create",
+ "inventory.update",
+ "inventory.stock.adjust",
+ "inventory.stock.reserve",
+ "inventory.stock.consume",
+ "inventory.manage.masterdata",
},
"guest": {
"dashboard.read",
diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py
new file mode 100644
index 0000000..19c2db2
--- /dev/null
+++ b/backend/hermes/app/repositories/inventory_repository.py
@@ -0,0 +1,229 @@
+from datetime import UTC, datetime
+
+from sqlalchemy import Select, func, or_, select
+from sqlalchemy.orm import Session, selectinload
+
+from app.models.inventory import (
+ InventoryCategory,
+ InventoryItem,
+ InventoryLocation,
+ InventoryStockMovement,
+ InventorySupplier,
+)
+
+
+class InventoryRepository:
+ @staticmethod
+ def item_query() -> Select[tuple[InventoryItem]]:
+ return select(InventoryItem).options(
+ selectinload(InventoryItem.category),
+ selectinload(InventoryItem.supplier),
+ selectinload(InventoryItem.location),
+ )
+
+ @staticmethod
+ def list_items(
+ db: Session,
+ *,
+ q: str | None = None,
+ category_id: int | None = None,
+ supplier_id: int | None = None,
+ location_id: int | None = None,
+ low_stock: bool | None = None,
+ active: bool | None = True,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> tuple[list[InventoryItem], int]:
+ query = InventoryRepository.item_query()
+ 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),
+ ))
+ if category_id is not None:
+ query = query.where(InventoryItem.category_id == category_id)
+ if supplier_id is not None:
+ query = query.where(InventoryItem.supplier_id == supplier_id)
+ if location_id is not None:
+ query = query.where(InventoryItem.location_id == location_id)
+ if low_stock is not None:
+ if low_stock:
+ query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
+ else:
+ query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
+ if active is not None:
+ query = query.where(InventoryItem.is_active == active)
+
+ total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
+ items = list(
+ db.scalars(
+ query
+ .order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
+ .offset(offset)
+ .limit(limit)
+ )
+ )
+ 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))
+
+ @staticmethod
+ def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
+ return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
+
+ @staticmethod
+ def get_next_sku(db: Session, year: int | None = None) -> str:
+ current_year = year or datetime.now(UTC).year
+ prefix = f"ET-{current_year}-"
+ latest = db.scalar(
+ select(InventoryItem.sku)
+ .where(InventoryItem.sku.like(f"{prefix}%"))
+ .order_by(InventoryItem.sku.desc())
+ .limit(1)
+ )
+ next_number = 1
+ if latest:
+ next_number = int(latest.split("-")[-1]) + 1
+ return f"{prefix}{next_number:06d}"
+
+ @staticmethod
+ def list_categories(db: Session) -> list[InventoryCategory]:
+ return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
+
+ @staticmethod
+ def get_category(db: Session, category_id: int) -> InventoryCategory | None:
+ return db.get(InventoryCategory, category_id)
+
+ @staticmethod
+ def list_locations(db: Session) -> list[InventoryLocation]:
+ return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
+
+ @staticmethod
+ def get_location(db: Session, location_id: int) -> InventoryLocation | None:
+ return db.get(InventoryLocation, location_id)
+
+ @staticmethod
+ def list_suppliers(db: Session) -> list[InventorySupplier]:
+ return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
+
+ @staticmethod
+ def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
+ return db.get(InventorySupplier, supplier_id)
+
+ @staticmethod
+ def has_movements(db: Session, item_id: int) -> bool:
+ return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
+
+ @staticmethod
+ def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
+ return list(
+ db.scalars(
+ select(InventoryStockMovement)
+ .where(InventoryStockMovement.item_id == item_id)
+ .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
+ )
+ )
+
+ @staticmethod
+ def create_movement(
+ db: Session,
+ *,
+ item_id: int,
+ movement_type: str,
+ quantity: int,
+ reason: str,
+ reference_type: str | None,
+ reference_id: int | None,
+ note: str | None,
+ actor_user_id: int | None,
+ ) -> InventoryStockMovement:
+ movement = InventoryStockMovement(
+ item_id=item_id,
+ movement_type=movement_type,
+ quantity=quantity,
+ reason=reason,
+ reference_type=reference_type,
+ reference_id=reference_id,
+ note=note,
+ actor_user_id=actor_user_id,
+ )
+ db.add(movement)
+ return movement
+
+ @staticmethod
+ def count_active_items(db: Session) -> int:
+ return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
+
+ @staticmethod
+ def count_low_stock_items(db: Session) -> int:
+ return db.scalar(
+ select(func.count(InventoryItem.id))
+ .where(InventoryItem.is_active.is_(True))
+ .where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
+ ) or 0
+
+ @staticmethod
+ def total_stock_value_cents(db: Session) -> int:
+ return db.scalar(
+ select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
+ .where(InventoryItem.is_active.is_(True))
+ .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(
+ db.scalars(
+ select(InventoryStockMovement)
+ .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
+ .limit(limit)
+ )
+ )
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/dashboard.py b/backend/hermes/app/schemas/dashboard.py
index 047f18e..8eae677 100644
--- a/backend/hermes/app/schemas/dashboard.py
+++ b/backend/hermes/app/schemas/dashboard.py
@@ -25,6 +25,7 @@ class DashboardSummary(BaseModel):
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
+ inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
activities: EmptyWidget
tasks: EmptyWidget
diff --git a/backend/hermes/app/schemas/inventory.py b/backend/hermes/app/schemas/inventory.py
new file mode 100644
index 0000000..093fb55
--- /dev/null
+++ b/backend/hermes/app/schemas/inventory.py
@@ -0,0 +1,231 @@
+from datetime import datetime
+from decimal import Decimal
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+InventoryMovementType = Literal[
+ "initial",
+ "purchase",
+ "adjustment",
+ "reservation",
+ "release",
+ "consumption",
+ "return",
+ "correction",
+]
+
+
+def normalize_text(value: object) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+
+def normalize_optional_text(value: object) -> str | None:
+ text = normalize_text(value)
+ return text or None
+
+
+class InventoryCategoryPayload(BaseModel):
+ name: str = Field(min_length=1, max_length=160)
+ description: str | None = None
+
+ @field_validator("name", "description", mode="before")
+ @classmethod
+ def normalize_strings(cls, value: object) -> str | None:
+ if value is None:
+ return None
+ return normalize_text(value)
+
+
+class InventoryLocationPayload(BaseModel):
+ name: str = Field(min_length=1, max_length=160)
+ description: str | None = None
+
+ @field_validator("name", "description", mode="before")
+ @classmethod
+ def normalize_strings(cls, value: object) -> str | None:
+ if value is None:
+ return None
+ return normalize_text(value)
+
+
+class InventorySupplierPayload(BaseModel):
+ name: str = Field(min_length=1, max_length=180)
+ contact_name: str | None = None
+ email: str | None = None
+ phone: str | None = None
+ website: str | None = None
+ customer_number: str | None = None
+ notes: str | None = None
+
+ @field_validator("*", mode="before")
+ @classmethod
+ def normalize_strings(cls, value: object) -> str | None:
+ if value is None:
+ return None
+ return normalize_optional_text(value)
+
+
+class InventoryItemPayload(BaseModel):
+ sku: str | None = Field(default=None, max_length=40)
+ name: str = Field(min_length=1, max_length=255)
+ description: str | None = None
+ category_id: int | None = None
+ manufacturer: str | None = None
+ manufacturer_part_number: str | None = None
+ supplier_id: int | None = None
+ supplier_part_number: str | None = None
+ compatible_devices: str | None = None
+ location_id: int | None = None
+ quantity_on_hand: int = Field(default=0, ge=0)
+ quantity_reserved: int = Field(default=0, ge=0)
+ reorder_level: int = Field(default=0, ge=0)
+ unit: str = Field(default="Stk.", max_length=40)
+ purchase_price_cents: int | None = Field(default=None, ge=0)
+ selling_price_cents: int | None = Field(default=None, ge=0)
+ currency: str = Field(default="EUR", max_length=3)
+ tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
+ notes: str | None = None
+ is_active: bool = True
+
+ @field_validator(
+ "sku",
+ "name",
+ "description",
+ "manufacturer",
+ "manufacturer_part_number",
+ "supplier_part_number",
+ "compatible_devices",
+ "unit",
+ "currency",
+ "notes",
+ mode="before",
+ )
+ @classmethod
+ def normalize_strings(cls, value: object) -> str | None:
+ if value is None:
+ return None
+ return normalize_text(value)
+
+ @field_validator("currency")
+ @classmethod
+ def normalize_currency(cls, value: str) -> str:
+ return value.upper() or "EUR"
+
+ @field_validator("tax_rate_percent", mode="before")
+ @classmethod
+ def normalize_tax_rate(cls, value: object) -> object:
+ if isinstance(value, str):
+ return value.strip().replace(",", ".")
+ return value
+
+ @model_validator(mode="after")
+ def validate_reserved_quantity(self):
+ if self.quantity_reserved > self.quantity_on_hand:
+ raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
+ return self
+
+
+class InventoryItemCreate(InventoryItemPayload):
+ pass
+
+
+class InventoryItemUpdate(InventoryItemPayload):
+ pass
+
+
+class InventoryStockAction(BaseModel):
+ quantity: int = Field(ge=0)
+ reason: str = Field(min_length=1, max_length=255)
+ note: str | None = None
+ reference_type: str | None = Field(default=None, max_length=80)
+ reference_id: int | None = None
+
+ @field_validator("reason", "note", "reference_type", mode="before")
+ @classmethod
+ def normalize_strings(cls, value: object) -> str | None:
+ if value is None:
+ return None
+ return normalize_text(value)
+
+
+class InventoryCategoryResponse(InventoryCategoryPayload):
+ id: int
+ slug: str
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class InventoryLocationResponse(InventoryLocationPayload):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class InventorySupplierResponse(InventorySupplierPayload):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class InventoryItemResponse(BaseModel):
+ id: int
+ sku: str
+ name: str
+ description: str | None
+ category_id: int | None
+ manufacturer: str | None
+ manufacturer_part_number: str | None
+ supplier_id: int | None
+ supplier_part_number: str | None
+ compatible_devices: str | None
+ location_id: int | None
+ quantity_on_hand: int
+ quantity_reserved: int
+ quantity_available: int
+ reorder_level: int
+ unit: str
+ purchase_price_cents: int | None
+ selling_price_cents: int | None
+ currency: str
+ tax_rate_percent: Decimal
+ notes: str | None
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
+ category: InventoryCategoryResponse | None
+ supplier: InventorySupplierResponse | None
+ location: InventoryLocationResponse | None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class InventoryItemListResponse(BaseModel):
+ items: list[InventoryItemResponse]
+ total: int
+ limit: int
+ offset: int
+
+
+class InventoryStockMovementResponse(BaseModel):
+ id: int
+ item_id: int
+ movement_type: str
+ quantity: int
+ reason: str
+ reference_type: str | None
+ reference_id: int | None
+ note: str | None
+ actor_user_id: int | None
+ actor_username: str
+ created_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
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 f2c6f44..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)
@@ -31,6 +33,19 @@ class RepairEstimateItemPayload(BaseModel):
return None
return normalize_text(value)
+ @field_validator("quantity", mode="before")
+ @classmethod
+ def normalize_quantity(cls, value: object) -> object:
+ if isinstance(value, str):
+ 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)
@@ -53,6 +68,13 @@ class RepairEstimatePayload(BaseModel):
def normalize_currency(cls, value: str) -> str:
return value.upper() or "EUR"
+ @field_validator("tax_rate_percent", mode="before")
+ @classmethod
+ def normalize_tax_rate(cls, value: object) -> object:
+ if isinstance(value, str):
+ return value.strip().replace(",", ".")
+ return value
+
@model_validator(mode="after")
def validate_items(self):
if not self.items:
@@ -71,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
@@ -115,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
@@ -125,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
@@ -136,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 204e17c..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,11 +179,49 @@ 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",
+ "inventory.items.deactivate": "Lagerartikel deaktiviert",
+ "inventory.items.delete": "Lagerartikel gelöscht",
+ "inventory.stock.adjust": "Bestand angepasst",
+ "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",
+ "inventory.locations.create": "Lagerort erstellt",
+ "inventory.locations.update": "Lagerort geändert",
+ "inventory.locations.delete": "Lagerort gelöscht",
+ "inventory.suppliers.create": "Lieferant erstellt",
+ "inventory.suppliers.update": "Lieferant geändert",
+ "inventory.suppliers.delete": "Lieferant gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"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
new file mode 100644
index 0000000..49afd72
--- /dev/null
+++ b/backend/hermes/app/services/inventory_service.py
@@ -0,0 +1,415 @@
+import re
+import unicodedata
+from decimal import Decimal
+from typing import TypeVar
+
+from fastapi import HTTPException, status
+from sqlalchemy.exc import IntegrityError
+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 (
+ InventoryCategoryPayload,
+ InventoryItemCreate,
+ InventoryItemUpdate,
+ InventoryLocationPayload,
+ InventoryStockAction,
+ InventorySupplierPayload,
+)
+from app.services.audit_service import write_audit_log
+
+MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
+
+
+def _slugify(value: str) -> str:
+ normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
+ return slug or "eintrag"
+
+
+def _item_label(item: InventoryItem) -> str:
+ return f"{item.sku} · {item.name}"
+
+
+def _audit_item_data(item: InventoryItem) -> dict:
+ return {
+ "id": item.id,
+ "sku": item.sku,
+ "name": item.name,
+ "category_id": item.category_id,
+ "supplier_id": item.supplier_id,
+ "location_id": item.location_id,
+ "quantity_on_hand": item.quantity_on_hand,
+ "quantity_reserved": item.quantity_reserved,
+ "quantity_available": item.quantity_available,
+ "reorder_level": item.reorder_level,
+ "selling_price_cents": item.selling_price_cents,
+ "currency": item.currency,
+ "is_active": item.is_active,
+ }
+
+
+class InventoryService:
+ @staticmethod
+ def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
+ sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
+ InventoryService._validate_sku_available(db, sku)
+ item = InventoryItem(sku=sku)
+ InventoryService._apply_item_payload(item, payload)
+ try:
+ db.add(item)
+ db.flush()
+ if item.quantity_on_hand > 0:
+ InventoryRepository.create_movement(
+ db,
+ item_id=item.id,
+ movement_type="initial",
+ quantity=item.quantity_on_hand,
+ reason="Anfangsbestand",
+ reference_type=None,
+ reference_id=None,
+ note=None,
+ actor_user_id=actor.id,
+ )
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
+ db.refresh(item)
+ item = InventoryRepository.get_item(db, item.id) or item
+ write_audit_log(
+ db,
+ action="inventory.items.create",
+ entity_type="inventory_items",
+ entity_id=item.id,
+ entity_label=_item_label(item),
+ actor=actor,
+ request=request,
+ after_data=_audit_item_data(item),
+ )
+ return item
+
+ @staticmethod
+ def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
+ before_data = _audit_item_data(item)
+ sku = (payload.sku or "").upper() or item.sku
+ InventoryService._validate_sku_available(db, sku, item_id=item.id)
+ item.sku = sku
+ InventoryService._apply_item_payload(item, payload)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
+ db.refresh(item)
+ updated = InventoryRepository.get_item(db, item.id) or item
+ write_audit_log(
+ db,
+ action="inventory.items.update",
+ entity_type="inventory_items",
+ entity_id=updated.id,
+ entity_label=_item_label(updated),
+ actor=actor,
+ request=request,
+ before_data=before_data,
+ after_data=_audit_item_data(updated),
+ )
+ return updated
+
+ @staticmethod
+ def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
+ before_data = _audit_item_data(item)
+ label = _item_label(item)
+ if InventoryRepository.has_movements(db, item.id):
+ item.is_active = False
+ InventoryService._recalculate_available(item)
+ db.commit()
+ db.refresh(item)
+ write_audit_log(
+ db,
+ action="inventory.items.deactivate",
+ entity_type="inventory_items",
+ entity_id=item.id,
+ entity_label=label,
+ actor=actor,
+ request=request,
+ before_data=before_data,
+ after_data=_audit_item_data(item),
+ )
+ return InventoryRepository.get_item(db, item.id) or item
+
+ db.delete(item)
+ db.commit()
+ write_audit_log(
+ db,
+ action="inventory.items.delete",
+ entity_type="inventory_items",
+ entity_id=item.id,
+ entity_label=label,
+ actor=actor,
+ request=request,
+ before_data=before_data,
+ )
+ return None
+
+ @staticmethod
+ def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
+ before_data = _audit_item_data(item)
+ item.quantity_on_hand = payload.quantity
+ if item.quantity_reserved > item.quantity_on_hand:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
+ InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
+ return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
+
+ @staticmethod
+ def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
+ InventoryService._validate_positive_stock_quantity(payload)
+ if payload.quantity > item.quantity_available:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
+ before_data = _audit_item_data(item)
+ item.quantity_reserved += payload.quantity
+ InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
+ return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
+
+ @staticmethod
+ def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
+ InventoryService._validate_positive_stock_quantity(payload)
+ if payload.quantity > item.quantity_reserved:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
+ before_data = _audit_item_data(item)
+ item.quantity_reserved -= payload.quantity
+ InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
+ return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
+
+ @staticmethod
+ def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
+ InventoryService._validate_positive_stock_quantity(payload)
+ if payload.quantity > item.quantity_on_hand:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
+ before_data = _audit_item_data(item)
+ item.quantity_on_hand -= payload.quantity
+ item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
+ 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)
+ return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
+
+ @staticmethod
+ def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
+ before_data = {"id": category.id, "name": category.name, "slug": category.slug}
+ category.name = payload.name
+ category.slug = _slugify(payload.name)
+ category.description = payload.description
+ return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
+
+ @staticmethod
+ def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
+ location = InventoryLocation(name=payload.name, description=payload.description)
+ return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
+
+ @staticmethod
+ def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
+ before_data = {"id": location.id, "name": location.name}
+ location.name = payload.name
+ location.description = payload.description
+ return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
+
+ @staticmethod
+ def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
+ supplier = InventorySupplier(**payload.model_dump())
+ return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
+
+ @staticmethod
+ def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
+ before_data = {"id": supplier.id, "name": supplier.name}
+ for key, value in payload.model_dump().items():
+ setattr(supplier, key, value)
+ return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
+
+ @staticmethod
+ def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
+ label = getattr(entity, "name", "")
+ before_data = {"id": entity.id, "name": label}
+ db.delete(entity)
+ db.commit()
+ write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
+
+ @staticmethod
+ def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
+ for key, value in payload.model_dump(exclude={"sku"}).items():
+ setattr(item, key, value)
+ InventoryService._recalculate_available(item)
+
+ @staticmethod
+ def _recalculate_available(item: InventoryItem) -> None:
+ item.quantity_available = item.quantity_on_hand - item.quantity_reserved
+
+ @staticmethod
+ def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
+ existing = InventoryRepository.get_item_by_sku(db, sku)
+ if existing is not None and existing.id != item_id:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
+
+ @staticmethod
+ def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
+ 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)
+ InventoryRepository.create_movement(
+ db,
+ item_id=item.id,
+ movement_type=movement_type,
+ quantity=payload.quantity,
+ reason=payload.reason,
+ reference_type=payload.reference_type,
+ reference_id=payload.reference_id,
+ note=payload.note,
+ actor_user_id=actor_user_id,
+ )
+
+ @staticmethod
+ def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
+ InventoryService._recalculate_available(item)
+ db.commit()
+ db.refresh(item)
+ updated = InventoryRepository.get_item(db, item.id) or item
+ write_audit_log(
+ db,
+ action=action,
+ entity_type="inventory_items",
+ entity_id=updated.id,
+ entity_label=_item_label(updated),
+ actor=actor,
+ request=request,
+ before_data=before_data,
+ after_data=_audit_item_data(updated),
+ )
+ return updated
+
+ @staticmethod
+ def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
+ try:
+ db.add(entity)
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
+ db.refresh(entity)
+ label = getattr(entity, "name", "")
+ write_audit_log(
+ db,
+ action=action,
+ entity_type=entity.__tablename__,
+ entity_id=entity.id,
+ entity_label=label,
+ actor=actor,
+ request=request,
+ before_data=before_data,
+ after_data={"id": entity.id, "name": label},
+ )
+ return entity
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
+