Compare commits

..

No commits in common. "main" and "v0.8.1" have entirely different histories.
main ... v0.8.1

129 changed files with 117 additions and 11858 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -20,7 +20,6 @@ STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
KNOWLEDGE_STORAGE_PATH=/data/knowledge
@ -29,24 +28,3 @@ KNOWLEDGE_MAX_UPLOAD_MB=50
# Server-zu-Server Token fuer spaetere Website-Reparaturannahme.
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
OLYMPUS_REPAIR_INTAKE_TOKEN=
# Env-Fallback fuer oeffentliche Reparaturstatuslinks.
# Bevorzugt wird die Admin-Konfiguration unter /settings.
# Beispiel: https://test.funktechnik-schubert.de/status
PUBLIC_REPAIR_STATUS_BASE_URL=
# Env-Fallback fuer SMTP. Bevorzugt wird die Admin-Konfiguration unter /settings.
# Fuer Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
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=

7
.gitignore vendored
View file

@ -39,13 +39,6 @@ out/
# ===========================
*.log
# ===========================
# Runtime storage
# ===========================
storage/
/backups/
*.dump
# ===========================
# Coverage
# ===========================

View file

@ -166,58 +166,6 @@ Hermes
Athena ist die einzige API-Oberflaeche fuer den Browser.
## Lexware Office Integration
Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation.
Verantwortlichkeiten:
- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege.
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt.
Komponenten:
- Hermes speichert Lexware-Konfiguration in `system_settings`.
- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`.
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
Datenfluss:
```text
Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office
Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL
```
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
Buchhaltungsworkflow:
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
Neue zentrale Endpunkte:
- `GET /lexware/settings`
- `PUT /lexware/settings`
- `POST /lexware/test-connection`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice`
RBAC:
- `lexware.read`
- `lexware.manage`
- `lexware.export`
Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen.
## Dashboard
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
@ -271,72 +219,6 @@ 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-<jahr>-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`
@ -389,7 +271,7 @@ Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die
### Reparatur-Benachrichtigungen
`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert.
`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen.
Vorbereitete Ereignisse:
@ -406,158 +288,6 @@ Vorbereitete Ereignisse:
Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`.
Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt.
Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant.
Hermes-Endpunkt fuer manuellen Neuversand:
- `POST /repairs/{id}/send-status-mail`
Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf.
### System Settings
`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten:
- `smtp.host`
- `smtp.port`
- `smtp.username`
- `smtp.password`
- `smtp.from_email`
- `smtp.from_name`
- `smtp.use_tls`
- `smtp.enabled`
- `public.repair_status_base_url`
Hermes-Endpunkte:
- `GET /system-settings/smtp`
- `PUT /system-settings/smtp`
- `POST /system-settings/smtp/test`
- `GET /system-settings/public-links`
- `PUT /system-settings/public-links`
Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist.
### Reparaturdokumente
Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework.
Datenmodell `repair_documents`:
- `repair_id`
- `title`
- `document_type`
- `original_filename`
- `stored_filename`
- `storage_path`
- `mime_type`
- `size_bytes`
- `checksum_sha256`
- `visibility`
- `note`
- `uploaded_by_user_id`
- Zeitstempel
Dokumenttypen:
- `device_photo`
- `fault_photo`
- `measurement`
- `estimate`
- `repair_report`
- `shipping`
- `other`
`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert.
Storage:
- Namespace: `repairs/<repair_id>/documents`
- Erlaubte Uploads: JPG, PNG, WEBP und PDF
- Dateinamen werden normalisiert
- SHA-256 wird gespeichert
- Dateiinhalte werden nicht geloggt
- Storage-Pfade werden nicht in Audit-Metadaten geschrieben
- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC
Hermes-Endpunkte:
- `GET /repairs/{id}/documents`
- `POST /repairs/{id}/documents/upload`
- `GET /repairs/{id}/documents/{document_id}`
- `PUT /repairs/{id}/documents/{document_id}`
- `GET /repairs/{id}/documents/{document_id}/download`
- `DELETE /repairs/{id}/documents/{document_id}`
RBAC:
- Liste, Detail und Download erfordern `repairs.read`.
- Upload, Aenderung und Loeschung erfordern `repairs.update`.
Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz.
### Kostenvoranschläge
Ab v0.8.6 verwaltet Olympus Kostenvoranschlaege direkt im Repair-Modul. Es gibt keinen Kundenlogin und keine neue Website-App. Kundenentscheidungen laufen ueber den bestehenden sicheren Public-Statuslink.
Datenmodell:
- `repair_estimates`
- `repair_estimate_items`
- `repair_estimate_events`
Kostenvoranschlagsnummern folgen `KV<jahr>-000001`. Positionstypen sind `labor`, `part`, `flat_rate`, `shipping` und `other`. Statuswerte sind `draft`, `sent`, `approved`, `declined`, `expired` und `cancelled`.
Hermes berechnet alle Summen serverseitig:
- `subtotal_cents` aus Positionen
- `tax_cents` aus `tax_rate_percent`
- `total_cents` als Summe aus netto und Steuer
Interne Hermes-Endpunkte:
- `GET /repairs/{repair_id}/estimates`
- `POST /repairs/{repair_id}/estimates`
- `GET /repairs/{repair_id}/estimates/{estimate_id}`
- `PUT /repairs/{repair_id}/estimates/{estimate_id}`
- `DELETE /repairs/{repair_id}/estimates/{estimate_id}`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/send`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/cancel`
- `GET /repairs/{repair_id}/estimates/{estimate_id}/events`
RBAC:
- `repair_estimates.read`
- `repair_estimates.create`
- `repair_estimates.update`
- `repair_estimates.delete`
- `repair_estimates.send`
Administrator bekommt alle Rechte. Management und Technik bekommen Lesen/Erstellen/Bearbeiten/Senden. Support bekommt Lesen/Senden.
Public API:
- `GET /public/repairs/status/{token}` liefert bei aktivem gesendetem KV ein Feld `estimate`.
- `POST /public/repairs/status/{token}/estimate/approve`
- `POST /public/repairs/status/{token}/estimate/decline`
- `POST /public/repairs/status/{token}/estimate/question`
Die Public-Antwort enthaelt keine internen Notizen, keine internen Events und keine Tokens. Bei Freigabe setzt Olympus den KV auf `approved` und den Reparaturstatus auf `approved`. Bei Ablehnung oder Rueckfrage bleibt der Prozess bei `waiting_for_customer`.
Der KV-Versand nutzt die bestehende SMTP-Konfiguration aus `system_settings` mit Env-Fallback. Fehler beim Mailversand zerstoeren den KV nicht; der Versandversuch wird als Notification Event dokumentiert.
Nicht enthalten in v0.8.6:
- PDF-Erzeugung
- Lexoffice
- Rechnungserstellung
- Kundenlogin
- Anzeige/Freigabe in der oeffentlichen Website
Die Website kann spaeter, z. B. in `funktechnik-schubert-website` v0.4.3, die bereits vorhandenen Public-API-Daten anzeigen und die Entscheidungsendpunkte aufrufen.
### Website Repair Intake
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
@ -977,8 +707,6 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl
Neue Uploads werden im Namespace `knowledge/documents/<manufacturer_id>` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien.
Reparaturdokumente werden ab v0.8.5 im Namespace `repairs/<repair_id>/documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature.
Konfiguration:
- `STORAGE_PROVIDER`, Default `local`
@ -1060,29 +788,9 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
### Backup-Modul ab v0.9.1
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
- Hermes-API: `backend/hermes/app/api/backups.py`
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
- Athena-BFF: `/api/backups/...`
Sicherheitsregeln:
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
- Hermes anonymisiert den Datenbank-Host im Manifest.
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
### Knowledge-RBAC
@ -1185,12 +893,6 @@ docker-compose.yml
`OLYMPUS_REPAIR_INTAKE_TOKEN`
: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen.
`PUBLIC_REPAIR_STATUS_BASE_URL`
: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, gibt Hermes weiterhin relative Links im Format `/status/<token>` zurueck.
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`
: Env-Fallback fuer automatische Reparaturstatusmails. Bevorzugt wird die Admin-Konfiguration unter `/settings`. `SMTP_PASSWORD` darf nicht geloggt, committed oder an Athena zurueckgegeben werden. Fuer Apple Mail/iCloud: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername vollstaendige Mailadresse, Passwort app-spezifisches Passwort.
### Athena
`HERMES_INTERNAL_URL`

View file

@ -34,23 +34,8 @@ STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
OLYMPUS_REPAIR_INTAKE_TOKEN=
PUBLIC_REPAIR_STATUS_BASE_URL=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=
```
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
## Docker Netzwerk
@ -134,46 +119,6 @@ STORAGE_BASE_PATH=/data/storage
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
## Backup und Restore
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
Ablage:
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
- Backup-Dateien gehoeren nie ins Git
Inhalt eines Backups:
- `manifest.json`
- `database.dump`
- `storage/`
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
- `GET /api/backups`
- `POST /api/backups/create`
- `GET /api/backups/[filename]/download`
- `POST /api/backups/[filename]/validate`
- `POST /api/backups/[filename]/restore`
- `DELETE /api/backups/[filename]/delete`
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
1. Backup validieren.
2. Sicherheitsbestaetigung pruefen.
3. Restore ueber `scripts/restore.sh <backup-zip>` 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:
@ -199,15 +144,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
- `/api/repairs`
- `/api/repairs/[id]`
- `/api/repairs/[id]/documents`
- `/api/repairs/[id]/documents/upload`
- `/api/repairs/[id]/documents/[documentId]`
- `/api/repairs/[id]/documents/[documentId]/download`
- `/api/repairs/[id]/status`
- `/api/repairs/[id]/history`
- `/api/repairs/[id]/public-link`
- `/api/repairs/[id]/notifications`
- `/api/repairs/[id]/send-status-mail`
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
@ -219,132 +159,14 @@ Statuslink-Konzept:
- RBAC-Permission: `repairs.public_link.manage`.
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
- `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`.
- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback.
- 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`.
- `repair_notification_events` dokumentiert jeden Versandversuch.
- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet.
- Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert.
- SMTP wird bevorzugt ueber `/settings` konfiguriert. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` bleiben als Env-Fallback erhalten.
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert.
Reparaturdokumente:
- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden.
- Hermes speichert Uploads ueber `StorageService` unter `repairs/<repair_id>/documents`.
- Erlaubte Dateitypen: JPG, PNG, WEBP und PDF.
- Metadaten liegen in `repair_documents`; Dateiinhalte liegen nie im Git oder in `public`.
- `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien.
- PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature.
Kostenvoranschlaege:
- Ab v0.8.6 koennen KVs direkt auf der Reparaturdetailseite erstellt, bearbeitet, gesendet, storniert und geloescht werden.
- Athena nutzt ausschliesslich BFF-Routen unter `/api/repairs/[id]/estimates`.
- Hermes berechnet alle Summen serverseitig. Client-Summen sind nur Vorschau.
- Nummernformat: `KV<jahr>-000001`.
- Der Versand nutzt die bestehende SMTP-Konfiguration aus `/settings` mit Env-Fallback.
- Der Versand erzeugt/erneuert einen sicheren Public-Statuslink und sendet ihn an `customer_email`.
- `GET /public/repairs/status/{token}` liefert aktive/gesendete KV-Daten ohne interne Notizen.
- Public-Entscheidungen laufen ohne Kundenlogin ueber:
- `POST /public/repairs/status/{token}/estimate/approve`
- `POST /public/repairs/status/{token}/estimate/decline`
- `POST /public/repairs/status/{token}/estimate/question`
- 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-<jahr>-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.
- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor.
- Aktuell wird kein SMTP-Versand aus Olympus erzwungen.
Vorbereitete Website-/Portal-Routen fuer spaeter:

View file

@ -102,123 +102,13 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort
- Optionaler E-Mail-Versand mit sicherem Statuslink
## v0.8.3 - Automatische Reparaturstatusmails
- Automatischer Statusmail-Versand bei Reparaturstatuswechseln
- Frische sichere Public-Statuslinks fuer Statusmails
- Professionelles HTML-Mailtemplate fuer Funktechnik Schubert
- Notification-Historie mit Erfolg, Fehler und Empfaenger
- Manueller Neuversand ueber `POST /repairs/{id}/send-status-mail`
- Athena-BFF-Route und Button "E-Mail erneut senden"
- Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail
- SMTP-Ausfall blockiert keine Reparaturprozesse
## v0.8.4 - Admin SMTP Settings
- SMTP-Konfiguration im Olympus-Webinterface unter `/settings`
- Testmail-Versand mit Audit-Log
- `system_settings` als zentrale Tabelle fuer Systemkonfiguration
- Env Vars bleiben als Fallback fuer SMTP und Public Repair Status Base URL
- Statusmail-Versand nutzt bevorzugt Admin-Konfiguration
- Passwort wird nicht an Athena zurueckgegeben
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
## v0.8.5 - Reparaturdokumente
- Dokumente und Bilder direkt an Reparaturen verwalten
- Upload ueber Athena-BFF und Hermes StorageService
- Storage-Pfad `repairs/<repair_id>/documents`
- Erlaubte Dateitypen JPG, PNG, WEBP und PDF
- Dokumenttypen fuer Geraetefotos, Fehlerbilder, Messbilder, Kostenvoranschlag, Reparaturbericht, Versandbeleg und Sonstiges
- Sichtbarkeit `internal`/`customer` vorbereitet, aber noch keine oeffentliche Kundenanzeige
- Bildvorschau mit Lightbox, PDF inline/download
- Audit Logs fuer Upload, Aenderung und Loeschung
## v0.8.6 - Kostenvoranschläge und Kundenfreigabe
- Kostenvoranschläge direkt an Reparaturen
- Positionen fuer Arbeitszeit, Ersatzteile, Pauschalen, Versand und Sonstiges
- Serverseitige Netto-, MwSt.- und Gesamtsummen
- KV-Nummern im Format `KV<jahr>-000001`
- Versand per bestehendem Public-Statuslink und SMTP-Systemsettings
- Public API liefert Estimate-Daten am Statuslink
- Public Approve/Decline/Question ohne Kundenlogin
- Kundenentscheidung aktualisiert Reparaturstatus und KV-Historie
- 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 - Lager / Ersatzteile
- Ersatzteil- und Lagerartikelverwaltung
- Kategorien, Lagerorte und Lieferanten
- SKU-Format `ET-<jahr>-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
## v0.8.3 - Kundenportal, geplant
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
## v0.9.3 - Tickets, geplant
## v0.9.0 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
@ -226,10 +116,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
- Paperless-ngx Connector fuer Wissensdokumente
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
- Sichere Connector-Konfiguration ohne Browser-Secrets
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen

BIN
backend/.DS_Store vendored

Binary file not shown.

View file

@ -19,20 +19,3 @@ STORAGE_MAX_UPLOAD_MB=50
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50
OLYMPUS_REPAIR_INTAKE_TOKEN=
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
PUBLIC_REPAIR_STATUS_BASE_URL=
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
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=

View file

@ -13,9 +13,6 @@ import app.models.knowledge
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

View file

@ -1,58 +0,0 @@
"""extend repair documents
Revision ID: a1c5f9e2d430
Revises: f2b8d4e6a910
Create Date: 2026-07-05 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a1c5f9e2d430"
down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False))
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False)
op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False))
op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False))
op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True))
op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True))
op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.create_foreign_key(
op.f("fk_repair_documents_uploaded_by_user_id_users"),
"repair_documents",
"users",
["uploaded_by_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False)
op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False)
op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents")
op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey")
op.drop_column("repair_documents", "updated_at")
op.drop_column("repair_documents", "uploaded_by_user_id")
op.drop_column("repair_documents", "note")
op.drop_column("repair_documents", "visibility")
op.drop_column("repair_documents", "checksum_sha256")
op.drop_column("repair_documents", "size_bytes")
op.drop_column("repair_documents", "mime_type")
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None)
op.drop_column("repair_documents", "stored_filename")
op.drop_column("repair_documents", "original_filename")

View file

@ -1,67 +0,0 @@
"""add repair estimate revoke permission
Revision ID: a7c3e9d4b821
Revises: f4a9c2d7e118
Create Date: 2026-07-05 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a7c3e9d4b821"
down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PERMISSION_NAME = "repair_estimates.revoke"
def upgrade() -> None:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (
:name,
'Kostenvoranschläge zurücknehmen',
'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
'repair_estimates'
)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=PERMISSION_NAME)
)
for role_name in ("administrator", "management"):
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=PERMISSION_NAME)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE name = :permission_name
)
"""
).bindparams(permission_name=PERMISSION_NAME)
)
op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))

View file

@ -1,160 +0,0 @@
"""create repair estimates
Revision ID: b4e8f1c7a205
Revises: a1c5f9e2d430
Create Date: 2026-07-05 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b4e8f1c7a205"
down_revision: Union[str, Sequence[str], None] = "a1c5f9e2d430"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ESTIMATE_PERMISSIONS = [
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
("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"),
]
def upgrade() -> None:
op.create_table(
"repair_estimates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("estimate_number", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("customer_message", sa.Text(), server_default="", nullable=False),
sa.Column("internal_note", sa.Text(), nullable=True),
sa.Column("subtotal_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
sa.Column("tax_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("customer_response_message", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("estimate_number"),
)
op.create_index(op.f("ix_repair_estimates_created_by_user_id"), "repair_estimates", ["created_by_user_id"], unique=False)
op.create_index(op.f("ix_repair_estimates_estimate_number"), "repair_estimates", ["estimate_number"], unique=True)
op.create_index(op.f("ix_repair_estimates_repair_id"), "repair_estimates", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_estimates_status"), "repair_estimates", ["status"], unique=False)
op.create_table(
"repair_estimate_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("estimate_id", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("item_type", sa.String(length=40), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("quantity", sa.Numeric(10, 2), server_default="1.00", nullable=False),
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
sa.Column("unit_price_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("total_cents", sa.Integer(), server_default="0", 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(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_estimate_items_estimate_id"), "repair_estimate_items", ["estimate_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_items_item_type"), "repair_estimate_items", ["item_type"], unique=False)
op.create_table(
"repair_estimate_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("estimate_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=40), nullable=False),
sa.Column("actor_type", sa.String(length=40), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), 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(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_estimate_events_actor_type"), "repair_estimate_events", ["actor_type"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_actor_user_id"), "repair_estimate_events", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_estimate_id"), "repair_estimate_events", ["estimate_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_event_type"), "repair_estimate_events", ["event_type"], unique=False)
for name, display_name, description, module in ESTIMATE_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 ESTIMATE_PERMISSIONS],
"management": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
"technician": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
"support": ["repair_estimates.read", "repair_estimates.send"],
}
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 = 'repair_estimates'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'repair_estimates'")
op.drop_index(op.f("ix_repair_estimate_events_event_type"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_estimate_id"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_actor_user_id"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_actor_type"), table_name="repair_estimate_events")
op.drop_table("repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_items_item_type"), table_name="repair_estimate_items")
op.drop_index(op.f("ix_repair_estimate_items_estimate_id"), table_name="repair_estimate_items")
op.drop_table("repair_estimate_items")
op.drop_index(op.f("ix_repair_estimates_status"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_repair_id"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_estimate_number"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_created_by_user_id"), table_name="repair_estimates")
op.drop_table("repair_estimates")

View file

@ -1,113 +0,0 @@
"""add lexware foundation
Revision ID: c9d4e5f6a7b8
Revises: a7c3e9d4b821
Create Date: 2026-07-05 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c9d4e5f6a7b8"
down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEXWARE_PERMISSIONS = [
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
]
def upgrade() -> None:
op.create_table(
"lexware_sync_records",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity_type", sa.String(length=80), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=False),
sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
sa.Column("payload_summary", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
for name, display_name, description, module in LEXWARE_PERMISSIONS:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (:name, :display_name, :description, :module)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
role_permissions = {
"administrator": [item[0] for item in LEXWARE_PERMISSIONS],
"management": [item[0] for item in LEXWARE_PERMISSIONS],
"support": ["lexware.read"],
}
for role_name, permission_names in role_permissions.items():
for permission_name in permission_names:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'lexware'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'lexware'")
op.drop_column("repair_estimates", "lexware_synced_at")
op.drop_column("repair_estimates", "lexware_invoice_status")
op.drop_column("repair_estimates", "lexware_invoice_number")
op.drop_column("repair_estimates", "lexware_invoice_id")
op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
op.drop_table("lexware_sync_records")

View file

@ -1,53 +0,0 @@
"""add accounting export status
Revision ID: d2e3f4a5b6c7
Revises: c9d4e5f6a7b8
Create Date: 2026-07-05 17:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d2e3f4a5b6c7"
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_repair_estimates_accounting_transferred_by_user_id",
"repair_estimates",
"users",
["accounting_transferred_by_user_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
op.drop_column("repair_estimates", "accounting_transferred_at")
op.drop_column("repair_estimates", "accounting_note")
op.drop_column("repair_estimates", "accounting_export_status")
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
op.drop_column("lexware_sync_records", "transferred_by_user_id")
op.drop_column("lexware_sync_records", "transferred_at")
op.drop_column("lexware_sync_records", "accounting_note")
op.drop_column("lexware_sync_records", "export_status")

View file

@ -1,29 +0,0 @@
"""extend repair notifications
Revision ID: e9a7c3d5b812
Revises: d4f6a2b8c901
Create Date: 2026-07-04 13:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "e9a7c3d5b812"
down_revision: Union[str, Sequence[str], None] = "d4f6a2b8c901"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("repair_notification_events", sa.Column("template", sa.Text(), server_default="", nullable=False))
op.add_column("repair_notification_events", sa.Column("success", sa.Boolean(), server_default="false", nullable=False))
op.create_index(op.f("ix_repair_notification_events_success"), "repair_notification_events", ["success"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_repair_notification_events_success"), table_name="repair_notification_events")
op.drop_column("repair_notification_events", "success")
op.drop_column("repair_notification_events", "template")

View file

@ -1,214 +0,0 @@
"""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")

View file

@ -1,69 +0,0 @@
"""create system settings
Revision ID: f2b8d4e6a910
Revises: e9a7c3d5b812
Create Date: 2026-07-04 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "f2b8d4e6a910"
down_revision: Union[str, Sequence[str], None] = "e9a7c3d5b812"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(length=160), nullable=False),
sa.Column("value", sa.Text(), server_default="", nullable=False),
sa.Column("is_secret", sa.Boolean(), server_default="false", 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.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
op.create_index(op.f("ix_system_settings_key"), "system_settings", ["key"], unique=True)
op.execute(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (
'system_settings.manage',
'Systemeinstellungen verwalten',
'SMTP und öffentliche Systemlinks verwalten',
'system'
)
ON CONFLICT (name) DO NOTHING
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = 'administrator'
AND permissions.name = 'system_settings.manage'
ON CONFLICT DO NOTHING
"""
)
def downgrade() -> None:
op.execute(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE name = 'system_settings.manage'
)
"""
)
op.execute("DELETE FROM permissions WHERE name = 'system_settings.manage'")
op.drop_index(op.f("ix_system_settings_key"), table_name="system_settings")
op.drop_table("system_settings")

View file

@ -1,44 +0,0 @@
"""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")

View file

@ -58,16 +58,6 @@ 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."):

View file

@ -1,150 +0,0 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.schemas.api_response import ApiSuccess
from app.schemas.backup import BackupRestoreRequest
from app.services.audit_service import write_audit_log
from app.services.backup_service import BackupService
router = APIRouter(prefix="/backups", tags=["Backups"])
@router.get("", response_model=ApiSuccess)
def list_backups(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.read")),
):
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
def create_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.create")),
):
backup = BackupService.create_backup(actor=current_user)
write_audit_log(
db,
action="backups.create",
entity_type="backup",
entity_label=backup.filename,
actor=current_user,
request=request,
metadata={
"filename": backup.filename,
"size_bytes": backup.size_bytes,
"app_version": backup.app_version,
},
)
return ApiSuccess(data=backup, message="Backup erstellt")
@router.get("/{filename}/download")
def download_backup(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.download")),
):
path = BackupService.resolve_backup_path(filename)
write_audit_log(
db,
action="backups.download",
entity_type="backup",
entity_label=path.name,
actor=current_user,
request=request,
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
)
return FileResponse(path=path, media_type="application/zip", filename=path.name)
@router.delete("/{filename}", response_model=ApiSuccess)
def delete_backup(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.delete")),
):
path = BackupService.resolve_backup_path(filename)
size_bytes = path.stat().st_size
BackupService.delete_backup(filename)
write_audit_log(
db,
action="backups.delete",
entity_type="backup",
entity_label=path.name,
actor=current_user,
request=request,
metadata={"filename": path.name, "size_bytes": size_bytes},
)
return ApiSuccess(message="Backup geloescht")
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
def validate_backup_restore(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.restore")),
):
validation = BackupService.validate_backup(filename)
write_audit_log(
db,
action="backups.validate",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
)
return ApiSuccess(data=validation, message=validation.message)
@router.post("/{filename}/restore", response_model=ApiSuccess)
def restore_backup(
filename: str,
payload: BackupRestoreRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.restore")),
):
write_audit_log(
db,
action="backups.restore_started",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
try:
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
except Exception:
write_audit_log(
db,
action="backups.restore_failed",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
raise
write_audit_log(
db,
action="backups.restore_completed",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
return ApiSuccess(data=validation, message="Restore abgeschlossen")

View file

@ -4,21 +4,14 @@ 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
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
logger = logging.getLogger(__name__)
@ -39,8 +32,6 @@ 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:
customers = [
@ -73,118 +64,8 @@ def get_dashboard_summary(
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
]
if "repair_estimates.read" in permissions:
repairs.extend([
MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)),
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)
latest_testmail = db.scalar(
select(AuditLog)
.where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"]))
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
.limit(1)
)
latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db)
system_status = [
SystemStatusItem(
label="SMTP-Konfiguration",
value={
"database": "Admin-Konfiguration",
"environment": "Umgebung",
"missing": "Nicht konfiguriert",
}[smtp_config.source],
status="ok" if smtp_config.is_configured else "warning",
),
SystemStatusItem(
label="Public Status Base URL",
value=public_links.repair_status_base_url or "Nicht konfiguriert",
status="ok" if public_links.repair_status_base_url else "warning",
),
SystemStatusItem(
label="Letzte SMTP-Testmail",
value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail",
status="info" if latest_testmail else "warning",
),
SystemStatusItem(
label="Letzte fehlgeschlagene Statusmail",
value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler",
status="warning" if latest_failed_status_mail else "ok",
),
]
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(
@ -193,8 +74,6 @@ def get_dashboard_summary(
users=users,
roles=roles,
repairs=repairs,
inventory=inventory,
system_status=system_status,
activities=EmptyWidget(
title="Letzte Aktivitäten",
message="Noch keine Aktivitäten vorhanden.",

View file

@ -1,319 +0,0 @@
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

View file

@ -1,89 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.lexware import (
AccountingTransferUpdate,
LexwareInvoicePreparationResponse,
LexwareSettingsResponse,
LexwareSettingsUpdate,
LexwareTestConnectionResponse,
)
from app.services.lexware_service import LexwareService
router = APIRouter(tags=["Lexware"])
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
repair = RepairRepository.get_by_id(db, repair_id)
if repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
return repair
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
if estimate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
return estimate
@router.get("/lexware/settings", response_model=LexwareSettingsResponse)
def get_lexware_settings(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.read")),
):
return LexwareService.get_settings(db)
@router.put("/lexware/settings", response_model=LexwareSettingsResponse)
def update_lexware_settings(
payload: LexwareSettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.manage")),
):
return LexwareService.update_settings(db, payload, actor=current_user, request=request)
@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse)
def test_lexware_connection(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.manage")),
):
return LexwareService.test_connection(db, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse)
def prepare_lexware_invoice(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.export")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
def mark_accounting_transferred(
repair_id: int,
estimate_id: int,
payload: AccountingTransferUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.export")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)

View file

@ -1,194 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_any_permission, 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.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateEventResponse,
RepairEstimateResponse,
RepairEstimateUpdate,
)
from app.services.repair_estimate_service import RepairEstimateService
from app.services.repair_public_link_service import RepairPublicLinkService
router = APIRouter(tags=["Repair Estimates"])
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("/repairs/{repair_id}/estimates", response_model=list[RepairEstimateResponse])
def list_estimates(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
get_repair_or_404(db, repair_id)
return RepairEstimateRepository.list_by_repair(db, repair_id)
@router.post("/repairs/{repair_id}/estimates", response_model=RepairEstimateResponse, status_code=status.HTTP_201_CREATED)
def create_estimate(
repair_id: int,
payload: RepairEstimateCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.create")),
):
repair = get_repair_or_404(db, repair_id)
return RepairEstimateService.create(db, repair, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
def get_estimate(
repair_id: int,
estimate_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
get_repair_or_404(db, repair_id)
return get_estimate_or_404(db, repair_id, estimate_id)
@router.put("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
def update_estimate(
repair_id: int,
estimate_id: int,
payload: RepairEstimateUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.update")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.update(db, repair, estimate, payload, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}/estimates/{estimate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.delete")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
RepairEstimateService.delete(db, repair, estimate, actor=current_user, request=request)
return None
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/send", response_model=RepairEstimateResponse)
def send_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.send")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.send(db, repair, estimate, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/cancel", response_model=RepairEstimateResponse)
def cancel_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
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,
estimate_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return estimate.events
def get_public_repair_and_estimate(db: Session, token: str) -> tuple[Repair, RepairEstimate]:
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
repair = public_link.repair
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
if estimate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
RepairRepository.mark_public_link_used(db, public_link)
return repair, estimate
@router.post("/public/repairs/status/{token}/estimate/approve", response_model=PublicEstimateResponse)
def approve_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "approve", payload, request=request)
return RepairEstimateService.public_response(updated)
@router.post("/public/repairs/status/{token}/estimate/decline", response_model=PublicEstimateResponse)
def decline_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "decline", payload, request=request)
return RepairEstimateService.public_response(updated)
@router.post("/public/repairs/status/{token}/estimate/question", response_model=PublicEstimateResponse)
def question_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "question", payload, request=request)
return RepairEstimateService.public_response(updated)

View file

@ -1,5 +1,4 @@
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from typing import cast
@ -12,13 +11,8 @@ from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import (
RepairCreate,
RepairDocumentResponse,
RepairDocumentType,
RepairDocumentUpdate,
RepairDocumentVisibility,
RepairIntakePayload,
RepairIntakeResponse,
RepairNotificationEventResponse,
RepairListResponse,
RepairNotificationOverviewResponse,
RepairPublicLinkCreateResponse,
@ -31,7 +25,6 @@ from app.schemas.repair import (
RepairUpdate,
)
from app.services.repair_notification_service import RepairNotificationService
from app.services.repair_document_service import RepairDocumentService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_service import RepairService
@ -136,117 +129,6 @@ def get_repair_history(
return RepairRepository.get_history(db, repair_id)
@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse])
def list_repair_documents(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
return RepairRepository.list_documents(db, repair_id)
@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_repair_document(
repair_id: int,
request: Request,
file: UploadFile = File(...),
title: str = Form(..., min_length=1, max_length=255),
document_type: RepairDocumentType = Form("other"),
visibility: RepairDocumentVisibility = Form("internal"),
note: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
normalized_title = title.strip()
normalized_note = note.strip() if note else None
if not normalized_title:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich")
return await RepairDocumentService.upload_document(
db,
db_repair,
file=file,
title=normalized_title,
document_type=document_type,
visibility=visibility,
note=normalized_note,
actor=current_user,
request=request,
)
@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def get_repair_document(
repair_id: int,
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def update_repair_document(
repair_id: int,
document_id: int,
payload: RepairDocumentUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/documents/{document_id}/download")
def download_repair_document(
repair_id: int,
document_id: int,
disposition: str = Query(default="inline", pattern="^(inline|attachment)$"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
file_path = RepairDocumentService.open_document_file(document)
return FileResponse(
file_path,
media_type=document.mime_type or "application/octet-stream",
headers={
"Content-Disposition": RepairDocumentService.content_disposition(
document,
mode="attachment" if disposition == "attachment" else "inline",
),
},
)
@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_repair_document(
repair_id: int,
document_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request)
return None
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def get_public_link(
repair_id: int,
@ -289,17 +171,6 @@ def get_repair_notifications(
return RepairNotificationService.overview(db, db_repair)
@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse)
def send_repair_status_mail(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake(
payload: RepairIntakePayload,

View file

@ -1,67 +0,0 @@
from fastapi import APIRouter, Depends
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.system_setting import (
PublicLinksSettingsResponse,
PublicLinksSettingsUpdate,
SmtpSettingsResponse,
SmtpSettingsUpdate,
SmtpTestRequest,
SmtpTestResponse,
)
from app.services.system_settings_service import SystemSettingsService
router = APIRouter(
prefix="/system-settings",
tags=["System Settings"],
)
@router.get("/smtp", response_model=SmtpSettingsResponse)
def get_smtp_settings(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("system_settings.manage")),
):
return SystemSettingsService.get_smtp_settings(db)
@router.put("/smtp", response_model=SmtpSettingsResponse)
def update_smtp_settings(
payload: SmtpSettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("system_settings.manage")),
):
return SystemSettingsService.update_smtp_settings(db, payload, actor=current_user, request=request)
@router.post("/smtp/test", response_model=SmtpTestResponse)
def send_smtp_test_mail(
payload: SmtpTestRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("system_settings.manage")),
):
return SystemSettingsService.send_test_mail(db, payload.recipient, actor=current_user, request=request)
@router.get("/public-links", response_model=PublicLinksSettingsResponse)
def get_public_links_settings(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("system_settings.manage")),
):
return SystemSettingsService.get_public_links_settings(db)
@router.put("/public-links", response_model=PublicLinksSettingsResponse)
def update_public_links_settings(
payload: PublicLinksSettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("system_settings.manage")),
):
return SystemSettingsService.update_public_links_settings(db, payload, actor=current_user, request=request)

View file

@ -9,7 +9,7 @@ class Settings(BaseSettings):
secret_key: str
app_name: str = "Hermes API"
app_version: str = "0.9.1"
app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
@ -24,17 +24,6 @@ class Settings(BaseSettings):
knowledge_storage_path: str = "/data/knowledge"
knowledge_max_upload_mb: int = 50
olympus_repair_intake_token: str | None = None
public_repair_status_base_url: str | None = None
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
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",

View file

@ -25,11 +25,6 @@ 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():

View file

@ -11,17 +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
from app.api.roles import router as roles_router
from app.api.system_settings import router as system_settings_router
from app.api.users import router as users_router
from app.db.database import SessionLocal
from app.db.health import check_database
@ -34,24 +29,19 @@ configure_logging()
app = FastAPI(
title="Hermes API",
version="0.9.1",
version="0.1.0",
description="Backend von Olympus",
)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(backups_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
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__)

View file

@ -1,114 +0,0 @@
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 ""

View file

@ -1,27 +0,0 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class LexwareSyncRecord(Base):
__tablename__ = "lexware_sync_records"
id: Mapped[int] = mapped_column(primary_key=True)
entity_type: Mapped[str] = mapped_column(String(80), index=True)
entity_id: Mapped[int] = mapped_column(Integer, index=True)
lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True)
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)

View file

@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
@ -41,7 +41,6 @@ class Repair(Base):
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
class RepairStatusHistory(Base):
@ -56,11 +55,7 @@ class RepairStatusHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
actor = relationship(
"User",
foreign_keys="RepairStatusHistory.actor_user_id",
lazy="joined",
)
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
@ -94,37 +89,10 @@ class RepairDocument(Base):
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True)
original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
uploaded_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: Mapped[Repair] = relationship(back_populates="documents")
uploaded_by = relationship(
"User",
foreign_keys="RepairDocument.uploaded_by_user_id",
lazy="joined",
)
@property
def uploaded_by_username(self) -> str:
return self.uploaded_by.username if self.uploaded_by is not None else ""
@property
def uploaded_by_display_name(self) -> str:
if self.uploaded_by is None:
return ""
display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip()
return display_name or self.uploaded_by.username
class RepairPublicAccessToken(Base):
@ -152,9 +120,7 @@ class RepairNotificationEvent(Base):
channel: Mapped[str] = mapped_column(String(40), index=True)
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
subject: Mapped[str] = mapped_column(String(255))
template: Mapped[str] = mapped_column(Text, default="", server_default="")
status: Mapped[str] = mapped_column(String(40), index=True)
success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -1,108 +0,0 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class RepairEstimate(Base):
__tablename__ = "repair_estimates"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
estimate_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
title: Mapped[str] = mapped_column(String(255))
customer_message: Mapped[str] = mapped_column(Text, default="", server_default="")
internal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
subtotal_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
tax_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
lexware_invoice_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair = relationship("Repair", lazy="joined")
created_by = relationship(
"User",
foreign_keys="RepairEstimate.created_by_user_id",
lazy="joined",
)
accounting_transferred_by = relationship(
"User",
foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
lazy="joined",
)
items: Mapped[list["RepairEstimateItem"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
order_by="RepairEstimateItem.position",
lazy="selectin",
)
events: Mapped[list["RepairEstimateEvent"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
order_by="RepairEstimateEvent.created_at",
lazy="selectin",
)
class RepairEstimateItem(Base):
__tablename__ = "repair_estimate_items"
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))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=Decimal("1.00"), server_default="1.00")
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
unit_price_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
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())
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
inventory_item = relationship("InventoryItem", lazy="joined")
class RepairEstimateEvent(Base):
__tablename__ = "repair_estimate_events"
id: Mapped[int] = mapped_column(primary_key=True)
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(40), index=True)
actor_type: Mapped[str] = mapped_column(String(40), index=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
actor = relationship(
"User",
foreign_keys="RepairEstimateEvent.actor_user_id",
lazy="joined",
)

View file

@ -1,21 +0,0 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
value: Mapped[str] = mapped_column(Text, default="", server_default="")
is_secret: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
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(),
)

View file

@ -69,7 +69,6 @@ STANDARD_PERMISSIONS = [
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"),
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
@ -85,28 +84,6 @@ STANDARD_PERMISSIONS = [
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
("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 = {
@ -124,24 +101,6 @@ ROLE_PERMISSION_NAMES = {
"repairs.update",
"repairs.status.update",
"repairs.public_link.manage",
"repair_estimates.read",
"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",
@ -165,13 +124,6 @@ ROLE_PERMISSION_NAMES = {
"repairs.read",
"repairs.update",
"repairs.status.update",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"inventory.read",
"inventory.stock.reserve",
"inventory.stock.consume",
},
"support": {
"dashboard.read",
@ -188,20 +140,9 @@ ROLE_PERMISSION_NAMES = {
"repairs.status.update",
"repairs.intake",
"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",

View file

@ -1,229 +0,0 @@
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)
)
)

View file

@ -1,136 +0,0 @@
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair_estimate import RepairEstimate, RepairEstimateEvent, RepairEstimateItem
class RepairEstimateRepository:
@staticmethod
def list_by_repair(db: Session, repair_id: int) -> list[RepairEstimate]:
return list(
db.scalars(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
.where(RepairEstimate.repair_id == repair_id)
.order_by(RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
)
)
@staticmethod
def get(db: Session, *, repair_id: int, estimate_id: int) -> RepairEstimate | None:
return db.scalar(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
.where(RepairEstimate.repair_id == repair_id)
.where(RepairEstimate.id == estimate_id)
)
@staticmethod
def get_latest_public(db: Session, repair_id: int) -> RepairEstimate | None:
return db.scalar(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items))
.where(RepairEstimate.repair_id == repair_id)
.where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
.limit(1)
)
@staticmethod
def get_next_number(db: Session, year: int) -> str:
prefix = f"KV{year}-"
latest = db.scalar(
select(RepairEstimate.estimate_number)
.where(RepairEstimate.estimate_number.like(f"{prefix}%"))
.order_by(RepairEstimate.estimate_number.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def save(db: Session, estimate: RepairEstimate) -> RepairEstimate:
db.add(estimate)
db.commit()
db.refresh(estimate)
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
@staticmethod
def replace_items(db: Session, estimate: RepairEstimate, items: list[RepairEstimateItem]) -> None:
estimate.items.clear()
db.flush()
for item in items:
estimate.items.append(item)
@staticmethod
def delete(db: Session, estimate: RepairEstimate) -> None:
db.delete(estimate)
db.commit()
@staticmethod
def add_event(
db: Session,
*,
estimate_id: int,
event_type: str,
actor_type: str,
actor_user_id: int | None = None,
note: str | None = None,
commit: bool = True,
) -> RepairEstimateEvent:
event = RepairEstimateEvent(
estimate_id=estimate_id,
event_type=event_type,
actor_type=actor_type,
actor_user_id=actor_user_id,
note=note,
)
db.add(event)
if commit:
db.commit()
db.refresh(event)
return event
@staticmethod
def mark_sent(db: Session, estimate: RepairEstimate) -> RepairEstimate:
estimate.status = "sent"
estimate.sent_at = datetime.now(UTC)
db.commit()
db.refresh(estimate)
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
@staticmethod
def count_open(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status.in_(["draft", "sent"]))) or 0
@staticmethod
def count_waiting(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "sent")) or 0
@staticmethod
def count_approved_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairEstimate.id))
.where(RepairEstimate.status == "approved")
.where(func.date(RepairEstimate.approved_at) == today)
) or 0
@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

View file

@ -3,8 +3,8 @@ from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
class RepairRepository:
@ -221,7 +221,6 @@ class RepairRepository:
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(RepairPublicAccessToken.revoked_at.is_(None))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
.limit(1)
@ -235,7 +234,6 @@ class RepairRepository:
.options(selectinload(RepairPublicAccessToken.repair))
.where(RepairPublicAccessToken.token_hash == token_hash)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(RepairPublicAccessToken.revoked_at.is_(None))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.limit(1)
)
@ -301,9 +299,7 @@ class RepairRepository:
channel: str,
recipient: str,
subject: str,
template: str,
status: str,
success: bool,
error_message: str | None = None,
sent_at: datetime | None = None,
) -> RepairNotificationEvent:
@ -313,9 +309,7 @@ class RepairRepository:
channel=channel,
recipient=recipient,
subject=subject,
template=template,
status=status,
success=success,
error_message=error_message,
sent_at=sent_at,
)
@ -324,115 +318,6 @@ class RepairRepository:
db.refresh(event)
return event
@staticmethod
def count_status_mails_sent_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(True))
.where(func.date(RepairNotificationEvent.created_at) == today)
) or 0
@staticmethod
def count_failed_status_mails(db: Session) -> int:
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(False))
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
) or 0
@staticmethod
def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None:
return db.scalar(
select(RepairNotificationEvent)
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(False))
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
.limit(1)
)
@staticmethod
def count_open_repairs_without_customer_email(db: Session) -> int:
return db.scalar(
select(func.count(Repair.id))
.where(~Repair.status.in_(["completed", "cancelled"]))
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
) or 0
@staticmethod
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
return list(
db.scalars(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
)
)
@staticmethod
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
return db.scalar(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.where(RepairDocument.id == document_id)
)
@staticmethod
def create_document(
db: Session,
*,
repair_id: int,
title: str,
document_type: str,
original_filename: str,
stored_filename: str,
storage_path: str,
mime_type: str,
size_bytes: int,
checksum_sha256: str,
visibility: str,
note: str | None,
uploaded_by_user_id: int | None,
) -> RepairDocument:
document = RepairDocument(
repair_id=repair_id,
title=title,
document_type=document_type,
original_filename=original_filename,
stored_filename=stored_filename,
storage_path=storage_path,
mime_type=mime_type,
size_bytes=size_bytes,
checksum_sha256=checksum_sha256,
visibility=visibility,
note=note,
uploaded_by_user_id=uploaded_by_user_id,
)
db.add(document)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
@staticmethod
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
for key, value in payload.model_dump().items():
setattr(document, key, value)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
@staticmethod
def delete_document(db: Session, document: RepairDocument) -> None:
db.delete(document)
db.commit()
@staticmethod
def count_documents(db: Session) -> int:
return db.scalar(select(func.count(RepairDocument.id))) or 0
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()

View file

@ -1,35 +0,0 @@
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.system_setting import SystemSetting
class SystemSettingsRepository:
@staticmethod
def get(db: Session, key: str) -> SystemSetting | None:
return db.scalar(select(SystemSetting).where(SystemSetting.key == key))
@staticmethod
def get_many(db: Session, keys: Iterable[str]) -> dict[str, SystemSetting]:
key_list = list(keys)
if not key_list:
return {}
settings = db.scalars(select(SystemSetting).where(SystemSetting.key.in_(key_list))).all()
return {setting.key: setting for setting in settings}
@staticmethod
def upsert(db: Session, *, key: str, value: str, is_secret: bool = False) -> SystemSetting:
setting = SystemSettingsRepository.get(db, key)
if setting is None:
setting = SystemSetting(key=key, value=value, is_secret=is_secret)
db.add(setting)
db.flush()
return setting
setting.value = value
setting.is_secret = is_secret
db.flush()
return setting

View file

@ -1,57 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, Field
class BackupManifest(BaseModel):
backup_id: str
created_at: datetime
app_version: str
backup_type: str = "full"
database_url_host_anonymized: str
database_name: str
storage_base_path: str
included_sections: list[str] = Field(default_factory=list)
file_count: int = 0
total_size_bytes: int = 0
checksum_sha256: str
created_by_user_id: int | None = None
created_by_username: str = ""
class BackupSummary(BaseModel):
filename: str
size_bytes: int
created_at: datetime | None = None
app_version: str = ""
backup_type: str = "full"
database_name: str = ""
storage_base_path: str = ""
file_count: int = 0
total_size_bytes: int = 0
created_by_user_id: int | None = None
created_by_username: str = ""
validation_status: str = "valid"
validation_message: str = ""
class BackupListResponse(BaseModel):
items: list[BackupSummary] = Field(default_factory=list)
total_count: int = 0
total_size_bytes: int = 0
latest_backup_at: datetime | None = None
class BackupValidationResponse(BaseModel):
filename: str
valid: bool
message: str
issues: list[str] = Field(default_factory=list)
checksum_valid: bool = False
restore_supported: bool = False
requires_cli_restore: bool = True
manifest: BackupManifest | None = None
class BackupRestoreRequest(BaseModel):
confirm_text: str

View file

@ -13,20 +13,12 @@ class EmptyWidget(BaseModel):
message: str
class SystemStatusItem(BaseModel):
label: str
value: str
status: str = "info"
class DashboardSummary(BaseModel):
customers: list[MetricCard] = []
latest_customers: list[CustomerResponse] = []
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
activities: EmptyWidget
tasks: EmptyWidget
tickets: EmptyWidget

View file

@ -1,231 +0,0 @@
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)

View file

@ -1,96 +0,0 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.schemas.system_setting import SettingsSource, normalize_text
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
LexwareSyncDirection = Literal["push", "pull"]
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
class LexwareSettingsResponse(BaseModel):
enabled: bool = False
api_base_url: str = "https://api.lexware.io"
api_key_is_set: bool = False
organization_name: str = ""
default_tax_rate: Decimal = Decimal("19.00")
default_payment_terms_days: int = 14
source: SettingsSource
class LexwareSettingsUpdate(BaseModel):
enabled: bool = False
api_base_url: str = Field(default="https://api.lexware.io", max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
organization_name: str = Field(default="", max_length=255)
default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100"))
default_payment_terms_days: int = Field(default=14, ge=0, le=365)
@field_validator("api_base_url", "api_key", "organization_name", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str:
return normalize_text(value)
@field_validator("api_base_url")
@classmethod
def normalize_base_url(cls, value: str) -> str:
return (value or "https://api.lexware.io").rstrip("/")
@model_validator(mode="after")
def validate_enabled_configuration(self):
if self.enabled and not self.api_base_url:
raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist")
return self
class LexwareTestConnectionResponse(BaseModel):
success: bool
message: str
source: SettingsSource
api_base_url: str
organization_name: str = ""
class LexwareCustomerMapping(BaseModel):
name: str
email: str
phone: str
search_strategy: str
create_payload: dict
class LexwareLineItemMapping(BaseModel):
title: str
description: str | None
quantity: Decimal
unit: str
unit_price: Decimal
tax_rate: Decimal
total: Decimal
class LexwareInvoicePreparationResponse(BaseModel):
ready_for_export: bool
export_status: AccountingExportStatus
payload_summary: dict
customer_mapping: LexwareCustomerMapping
line_item_mapping: list[LexwareLineItemMapping]
tax_mapping: dict
warnings: list[str]
sync_record_id: int
accounting_note: str = ""
transferred_at: str | None = None
transferred_by_user_id: int | None = None
class AccountingTransferUpdate(BaseModel):
accounting_note: str | None = Field(default=None, max_length=2000)
@field_validator("accounting_note", mode="before")
@classmethod
def normalize_note(cls, value: object) -> str | None:
text = normalize_text(value)
return text or None

View file

@ -3,8 +3,6 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from app.schemas.repair_estimate import PublicEstimateResponse
RepairStatus = Literal[
"new",
"accepted",
@ -21,16 +19,6 @@ RepairStatus = Literal[
]
RepairPriority = Literal["low", "normal", "high", "urgent"]
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
RepairDocumentType = Literal[
"device_photo",
"fault_photo",
"measurement",
"estimate",
"repair_report",
"shipping",
"other",
]
RepairDocumentVisibility = Literal["internal", "customer"]
def normalize_text(value: object) -> str:
@ -204,7 +192,6 @@ class RepairPublicStatusResponse(BaseModel):
device_model: str
status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime
estimate: PublicEstimateResponse | None = None
class RepairNotificationTemplateResponse(BaseModel):
@ -222,9 +209,7 @@ class RepairNotificationEventResponse(BaseModel):
channel: str
recipient: str
subject: str
template: str
status: str
success: bool
error_message: str | None
created_at: datetime
sent_at: datetime | None
@ -235,39 +220,3 @@ class RepairNotificationEventResponse(BaseModel):
class RepairNotificationOverviewResponse(BaseModel):
templates: list[RepairNotificationTemplateResponse]
events: list[RepairNotificationEventResponse]
class RepairDocumentBase(BaseModel):
title: str = Field(min_length=1, max_length=255)
document_type: RepairDocumentType = "other"
visibility: RepairDocumentVisibility = "internal"
note: str | None = None
@field_validator("title", "note", mode="before")
@classmethod
def normalize_document_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class RepairDocumentUpdate(RepairDocumentBase):
pass
class RepairDocumentResponse(RepairDocumentBase):
id: int
repair_id: int
original_filename: str
stored_filename: str
storage_path: str
mime_type: str
size_bytes: int
checksum_sha256: str
uploaded_by_user_id: int | None
uploaded_by_username: str = ""
uploaded_by_display_name: str = ""
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -1,208 +0,0 @@
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
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", "revoked"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RepairEstimateItemPayload(BaseModel):
item_type: EstimateItemType = "other"
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)
unit_price_cents: int = Field(ge=0)
@field_validator("title", "description", "unit", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
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)
customer_message: str = ""
internal_note: str | None = None
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
currency: str = Field(default="EUR", max_length=3)
valid_until: date | None = None
items: list[RepairEstimateItemPayload]
@field_validator("title", "customer_message", "internal_note", "currency", 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_items(self):
if not self.items:
raise ValueError("Mindestens eine Position ist erforderlich")
return self
class RepairEstimateCreate(RepairEstimatePayload):
pass
class RepairEstimateUpdate(RepairEstimatePayload):
pass
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
description: str | None
quantity: Decimal
unit: str
unit_price_cents: int
total_cents: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateEventResponse(BaseModel):
id: int
estimate_id: int
event_type: str
actor_type: str
actor_user_id: int | None
note: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateResponse(BaseModel):
id: int
repair_id: int
estimate_number: str
status: EstimateStatus
title: str
customer_message: str
internal_note: str | None
subtotal_cents: int
tax_rate_percent: Decimal
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
sent_at: datetime | None
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
items: list[RepairEstimateItemResponse]
model_config = ConfigDict(from_attributes=True)
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
unit: str
unit_price_cents: int
total_cents: int
class PublicEstimateResponse(BaseModel):
estimate_number: str
status: EstimateStatus
status_label: str
title: str
customer_message: str
subtotal_cents: int
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
items: list[PublicEstimateItemResponse]
class PublicEstimateDecisionRequest(BaseModel):
message: str | None = Field(default=None, max_length=2000)
@field_validator("message", mode="before")
@classmethod
def normalize_message(cls, value: object) -> str | None:
if value is None:
return None
text = normalize_text(value)
return text or None
def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int:
total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(total)
def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int:
tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(tax)

View file

@ -1,70 +0,0 @@
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
SettingsSource = Literal["database", "environment", "missing"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class SmtpSettingsResponse(BaseModel):
host: str = ""
port: int = 587
username: str = ""
password_is_set: bool = False
from_email: str = ""
from_name: str = "Funktechnik Schubert"
use_tls: bool = True
enabled: bool = False
source: SettingsSource
class SmtpSettingsUpdate(BaseModel):
host: str = Field(default="", max_length=255)
port: int = Field(default=587, ge=1, le=65535)
username: str = Field(default="", max_length=255)
password: str | None = Field(default=None, max_length=1000)
from_email: str = Field(default="", max_length=255)
from_name: str = Field(default="Funktechnik Schubert", max_length=255)
use_tls: bool = True
enabled: bool = False
@field_validator("host", "username", "password", "from_email", "from_name", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str:
return normalize_text(value)
@model_validator(mode="after")
def validate_enabled_configuration(self):
if self.enabled and (not self.host or not self.from_email):
raise ValueError("SMTP Host und Absenderadresse sind erforderlich, wenn SMTP aktiviert ist")
return self
class SmtpTestRequest(BaseModel):
recipient: EmailStr
class SmtpTestResponse(BaseModel):
success: bool
message: str
source: SettingsSource
class PublicLinksSettingsResponse(BaseModel):
repair_status_base_url: str = ""
source: SettingsSource
class PublicLinksSettingsUpdate(BaseModel):
repair_status_base_url: str = Field(default="", max_length=500)
@field_validator("repair_status_base_url", mode="before")
@classmethod
def normalize_url(cls, value: object) -> str:
return normalize_text(value).rstrip("/")

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@ -165,63 +165,6 @@ def action_title(action: str) -> str:
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
"repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt",
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
"repairs.status_mail.sent": "Statusmail versendet",
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"repair_estimates.create": "Kostenvoranschlag erstellt",
"repair_estimates.update": "Kostenvoranschlag geändert",
"repair_estimates.send": "Kostenvoranschlag gesendet",
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
"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)

View file

@ -1,373 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import zipfile
from fastapi import HTTPException, status
from sqlalchemy.engine import make_url
from app.core.config import settings
from app.models.user import User
from app.schemas.backup import (
BackupListResponse,
BackupManifest,
BackupSummary,
BackupValidationResponse,
)
BACKUP_FILENAME_PREFIX = "olympus-backup-"
BACKUP_FILENAME_SUFFIX = ".zip"
DATABASE_DUMP_NAME = "database.dump"
MANIFEST_NAME = "manifest.json"
STORAGE_DIR_NAME = "storage"
BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
RESTORE_DISABLED_MESSAGE = (
"Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
"Bitte Restore ueber CLI-Script ausfuehren."
)
@dataclass(frozen=True)
class BackupStats:
total_count: int
total_size_bytes: int
latest_backup_at: datetime | None
class BackupService:
@staticmethod
def get_backup_dir() -> Path:
backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
backup_dir.mkdir(parents=True, exist_ok=True)
return backup_dir
@staticmethod
def list_backups() -> BackupListResponse:
items = [
BackupService._read_summary(path)
for path in sorted(
BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
key=lambda item: item.stat().st_mtime,
reverse=True,
)
]
latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
return BackupListResponse(
items=items,
total_count=len(items),
total_size_bytes=sum(item.size_bytes for item in items),
latest_backup_at=latest_backup_at,
)
@staticmethod
def get_backup_stats() -> BackupStats:
backups = BackupService.list_backups()
return BackupStats(
total_count=backups.total_count,
total_size_bytes=backups.total_size_bytes,
latest_backup_at=backups.latest_backup_at,
)
@staticmethod
def create_backup(*, actor: User) -> BackupSummary:
backup_dir = BackupService.get_backup_dir()
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
target_path = backup_dir / filename
with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
temp_dir = Path(temp_dir_name)
dump_path = temp_dir / DATABASE_DUMP_NAME
storage_temp_dir = temp_dir / STORAGE_DIR_NAME
manifest_path = temp_dir / MANIFEST_NAME
archive_path = temp_dir / filename
BackupService._run_pg_dump(dump_path)
file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
dump_size = dump_path.stat().st_size
checksum_sha256 = BackupService._calculate_archive_checksum(
dump_path=dump_path,
storage_dir=storage_temp_dir,
)
manifest = BackupManifest(
backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
created_at=datetime.now(UTC),
app_version=settings.app_version,
backup_type="full",
database_url_host_anonymized=BackupService._anonymized_database_host(),
database_name=BackupService._database_name(),
storage_base_path=settings.storage_base_path,
included_sections=["database", "storage"],
file_count=file_count + 1,
total_size_bytes=total_size_bytes + dump_size,
checksum_sha256=checksum_sha256,
created_by_user_id=actor.id,
created_by_username=actor.username,
)
manifest_path.write_text(
json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
encoding="utf-8",
)
BackupService._write_archive(
archive_path=archive_path,
manifest_path=manifest_path,
dump_path=dump_path,
storage_dir=storage_temp_dir,
)
shutil.move(str(archive_path), target_path)
return BackupService._read_summary(target_path)
@staticmethod
def validate_backup(filename: str) -> BackupValidationResponse:
path = BackupService.resolve_backup_path(filename)
issues: list[str] = []
manifest: BackupManifest | None = None
checksum_valid = False
try:
with zipfile.ZipFile(path) as archive:
names = set(archive.namelist())
if MANIFEST_NAME not in names:
issues.append("manifest.json fehlt")
if DATABASE_DUMP_NAME not in names:
issues.append("database.dump fehlt")
if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
issues.append("storage/ fehlt")
if MANIFEST_NAME in names:
try:
with archive.open(MANIFEST_NAME) as manifest_file:
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
except Exception:
issues.append("manifest.json ist ungueltig")
if manifest is not None:
checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
if not checksum_valid:
issues.append("Checksumme ist ungueltig")
except zipfile.BadZipFile:
issues.append("ZIP-Datei ist ungueltig")
valid = len(issues) == 0
return BackupValidationResponse(
filename=path.name,
valid=valid,
message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
issues=issues,
checksum_valid=checksum_valid,
restore_supported=False,
requires_cli_restore=True,
manifest=manifest,
)
@staticmethod
def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
if confirm_text != BACKUP_CONFIRM_TEXT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bestaetigungstext stimmt nicht ueberein",
)
validation = BackupService.validate_backup(filename)
if not validation.valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
)
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=RESTORE_DISABLED_MESSAGE,
)
@staticmethod
def delete_backup(filename: str) -> None:
path = BackupService.resolve_backup_path(filename)
path.unlink(missing_ok=False)
@staticmethod
def resolve_backup_path(filename: str) -> Path:
if not filename.endswith(BACKUP_FILENAME_SUFFIX):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
if Path(filename).name != filename or ".." in Path(filename).parts:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
path = (BackupService.get_backup_dir() / filename).resolve()
backup_dir = BackupService.get_backup_dir()
if backup_dir != path.parent or not path.exists() or not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
return path
@staticmethod
def _run_pg_dump(dump_path: Path) -> None:
pg_dump_url = BackupService._pg_dump_database_url()
command = [
"pg_dump",
"--format=custom",
"--no-owner",
"--no-privileges",
f"--file={dump_path}",
f"--dbname={pg_dump_url}",
]
try:
subprocess.run(
command,
check=True,
capture_output=True,
text=True,
env=os.environ.copy(),
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="pg_dump ist im Hermes-Container nicht verfuegbar",
) from exc
except subprocess.CalledProcessError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PostgreSQL-Dump konnte nicht erstellt werden",
) from exc
@staticmethod
def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
source_dir = Path(settings.storage_base_path).resolve()
backup_dir = BackupService.get_backup_dir()
source_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True)
file_count = 0
total_size_bytes = 0
for source_path in sorted(source_dir.rglob("*")):
if source_path == backup_dir or backup_dir in source_path.parents:
continue
relative_path = source_path.relative_to(source_dir)
destination_path = target_dir / relative_path
if source_path.is_dir():
destination_path.mkdir(parents=True, exist_ok=True)
continue
if not source_path.is_file():
continue
destination_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, destination_path)
file_count += 1
total_size_bytes += source_path.stat().st_size
return file_count, total_size_bytes
@staticmethod
def _write_archive(
*,
archive_path: Path,
manifest_path: Path,
dump_path: Path,
storage_dir: Path,
) -> None:
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr(f"{STORAGE_DIR_NAME}/", "")
archive.write(manifest_path, MANIFEST_NAME)
archive.write(dump_path, DATABASE_DUMP_NAME)
for file_path in sorted(storage_dir.rglob("*")):
if file_path.is_dir():
continue
archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
@staticmethod
def _read_summary(path: Path) -> BackupSummary:
default_summary = BackupSummary(
filename=path.name,
size_bytes=path.stat().st_size,
validation_status="warning",
validation_message="Manifest konnte nicht gelesen werden",
)
try:
with zipfile.ZipFile(path) as archive:
with archive.open(MANIFEST_NAME) as manifest_file:
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
return BackupSummary(
filename=path.name,
size_bytes=path.stat().st_size,
created_at=manifest.created_at,
app_version=manifest.app_version,
backup_type=manifest.backup_type,
database_name=manifest.database_name,
storage_base_path=manifest.storage_base_path,
file_count=manifest.file_count,
total_size_bytes=manifest.total_size_bytes,
created_by_user_id=manifest.created_by_user_id,
created_by_username=manifest.created_by_username,
validation_status="valid",
validation_message="Backup ist lesbar",
)
except Exception:
return default_summary
@staticmethod
def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
digest = hashlib.sha256()
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
BackupService._update_digest_from_file(digest, dump_path)
for file_path in sorted(storage_dir.rglob("*")):
if file_path.is_dir():
continue
digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
BackupService._update_digest_from_file(digest, file_path)
return digest.hexdigest()
@staticmethod
def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
digest = hashlib.sha256()
if DATABASE_DUMP_NAME not in archive.namelist():
return False
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
with archive.open(DATABASE_DUMP_NAME) as dump_file:
BackupService._update_digest_from_stream(digest, dump_file)
for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
digest.update(name.encode("utf-8"))
with archive.open(name) as storage_file:
BackupService._update_digest_from_stream(digest, storage_file)
return digest.hexdigest() == expected_checksum
@staticmethod
def _anonymized_database_host() -> str:
parsed = make_url(settings.database_url)
host = parsed.host or "unknown"
digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
return f"sha256:{digest}"
@staticmethod
def _database_name() -> str:
parsed = make_url(settings.database_url)
return parsed.database or "unknown"
@staticmethod
def _pg_dump_database_url() -> str:
parsed = make_url(settings.database_url)
normalized = parsed.set(drivername="postgresql")
return normalized.render_as_string(hide_password=False)
@staticmethod
def _update_digest_from_file(digest, file_path: Path) -> None:
with file_path.open("rb") as file_handle:
BackupService._update_digest_from_stream(digest, file_handle)
@staticmethod
def _update_digest_from_stream(digest, stream) -> None:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)

View file

@ -1,415 +0,0 @@
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

View file

@ -1,518 +0,0 @@
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest
from urllib.request import urlopen
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.lexware import LexwareSyncRecord
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.lexware import (
AccountingTransferUpdate,
LexwareCustomerMapping,
LexwareInvoicePreparationResponse,
LexwareLineItemMapping,
LexwareSettingsResponse,
LexwareSettingsUpdate,
LexwareTestConnectionResponse,
)
from app.schemas.system_setting import SettingsSource
from app.services.audit_service import write_audit_log
from app.services.system_settings_service import parse_bool
LEXWARE_KEYS = (
"lexware.enabled",
"lexware.api_base_url",
"lexware.api_key",
"lexware.organization_name",
"lexware.default_tax_rate",
"lexware.default_payment_terms_days",
)
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
DEFAULT_API_BASE_URL = "https://api.lexware.io"
@dataclass(frozen=True)
class LexwareRuntimeConfig:
enabled: bool
api_base_url: str
api_key: str
organization_name: str
default_tax_rate: Decimal
default_payment_terms_days: int
source: SettingsSource
@property
def api_key_is_set(self) -> bool:
return bool(self.api_key)
@property
def is_configured(self) -> bool:
return self.enabled and bool(self.api_base_url and self.api_key)
def _decimal(value: object, *, default: Decimal) -> Decimal:
try:
return Decimal(str(value or "").replace(",", "."))
except Exception:
return default
def _int(value: object, *, default: int) -> int:
try:
parsed = int(str(value or "").strip())
except ValueError:
return default
return parsed if 0 <= parsed <= 365 else default
def _euros(cents: int) -> Decimal:
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _safe_error_message(error: Exception) -> str:
if isinstance(error, HTTPError):
if error.code in {401, 403}:
return "Lexware hat den API-Key abgelehnt."
if error.code == 404:
return "Lexware-Endpunkt wurde nicht gefunden."
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
if isinstance(error, URLError):
return "Lexware ist momentan nicht erreichbar."
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
class LexwareService:
@staticmethod
def get_settings(db: Session) -> LexwareSettingsResponse:
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
@staticmethod
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
db_api_key = values.get("lexware.api_key") or ""
db_has_config = db_enabled or db_base_url or db_api_key
if db_has_config:
return LexwareRuntimeConfig(
enabled=db_enabled,
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
api_key=db_api_key,
organization_name=values.get("lexware.organization_name", ""),
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
source="database",
)
if settings.lexware_enabled or settings.lexware_api_key:
return LexwareRuntimeConfig(
enabled=settings.lexware_enabled,
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
api_key=settings.lexware_api_key or "",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="environment",
)
return LexwareRuntimeConfig(
enabled=False,
api_base_url=DEFAULT_API_BASE_URL,
api_key="",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="missing",
)
@staticmethod
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
updates = {
"lexware.enabled": "true" if payload.enabled else "false",
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
"lexware.api_key": api_key,
"lexware.organization_name": payload.organization_name,
"lexware.default_tax_rate": str(payload.default_tax_rate),
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
}
for key, value in updates.items():
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
db.commit()
write_audit_log(
db,
action="lexware.settings.update",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={
"enabled": payload.enabled,
"api_base_url": payload.api_base_url,
"organization_name": payload.organization_name,
"default_tax_rate": str(payload.default_tax_rate),
"default_payment_terms_days": payload.default_payment_terms_days,
"api_key_changed": bool(payload.api_key),
},
)
return LexwareService.get_settings(db)
@staticmethod
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
config = LexwareService.get_runtime_config(db)
if not config.is_configured:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": "lexware_not_configured", "source": config.source},
)
return LexwareTestConnectionResponse(
success=False,
message="Lexware ist nicht vollständig konfiguriert.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
try:
profile = LexwareService._get_profile(config)
except Exception as exc:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
)
return LexwareTestConnectionResponse(
success=False,
message=_safe_error_message(exc),
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
write_audit_log(
db,
action="lexware.connection.test_success",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
)
return LexwareTestConnectionResponse(
success=True,
message="Lexware-Verbindung erfolgreich geprüft.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=organization_name,
)
@staticmethod
def prepare_invoice(
db: Session,
repair: Repair,
estimate: RepairEstimate,
*,
actor: User,
request: Request,
) -> LexwareInvoicePreparationResponse:
config = LexwareService.get_runtime_config(db)
if estimate.status != "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
warnings: list[str] = []
if not config.enabled:
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
if not config.api_key_is_set:
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
if not repair.customer_email:
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
if not estimate.items:
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
customer_payload = {
"roles": {"customer": {}},
"company": {"name": repair.customer_name},
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
}
customer_mapping = LexwareCustomerMapping(
name=repair.customer_name,
email=repair.customer_email,
phone=repair.customer_phone,
search_strategy="email" if repair.customer_email else "name",
create_payload=customer_payload,
)
line_items = [
LexwareLineItemMapping(
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price=_euros(item.unit_price_cents),
tax_rate=estimate.tax_rate_percent,
total=_euros(item.total_cents),
)
for item in estimate.items
]
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
record = LexwareSyncRecord(
entity_type="repair_estimate",
entity_id=estimate.id,
lexware_resource_type="invoice",
status="pending" if not warnings else "skipped",
direction="push",
export_status="prepared",
accounting_note=estimate.accounting_note,
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
error_message="; ".join(warnings) if warnings else None,
)
estimate.accounting_export_status = "prepared"
db.add(record)
db.commit()
db.refresh(record)
db.refresh(estimate)
write_audit_log(
db,
action="accounting.invoice.handoff",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"ready_for_export": not warnings,
"sync_record_id": record.id,
"export_status": record.export_status,
},
)
return LexwareService._invoice_preparation_response(
record=record,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_items=line_items,
estimate=estimate,
config=config,
warnings=warnings,
)
@staticmethod
def mark_transferred(
db: Session,
repair: Repair,
estimate: RepairEstimate,
payload: AccountingTransferUpdate,
*,
actor: User,
request: Request,
) -> LexwareInvoicePreparationResponse:
record = LexwareService._latest_invoice_record(db, estimate.id)
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
if record.export_status not in {"prepared", "transferred"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
now = datetime.now(UTC)
record.export_status = "transferred"
record.status = "success"
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
record.transferred_at = now
record.transferred_by_user_id = actor.id
record.synced_at = now
estimate.accounting_export_status = "transferred"
estimate.accounting_note = record.accounting_note
estimate.accounting_transferred_at = now
estimate.accounting_transferred_by_user_id = actor.id
db.commit()
db.refresh(record)
db.refresh(estimate)
write_audit_log(
db,
action="accounting.invoice.mark_transferred",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"sync_record_id": record.id,
"export_status": record.export_status,
"transferred_at": record.transferred_at,
},
)
if note_changed:
write_audit_log(
db,
action="accounting.invoice.note_update",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
)
config = LexwareService.get_runtime_config(db)
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
warnings: list[str] = []
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
return LexwareService._invoice_preparation_response(
record=record,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_items=line_items,
estimate=estimate,
config=config,
warnings=warnings,
)
@staticmethod
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
customer_payload = {
"roles": {"customer": {}},
"company": {"name": repair.customer_name},
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
}
customer_mapping = LexwareCustomerMapping(
name=repair.customer_name,
email=repair.customer_email,
phone=repair.customer_phone,
search_strategy="email" if repair.customer_email else "name",
create_payload=customer_payload,
)
line_items = [
LexwareLineItemMapping(
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price=_euros(item.unit_price_cents),
tax_rate=estimate.tax_rate_percent,
total=_euros(item.total_cents),
)
for item in estimate.items
]
return customer_mapping, line_items
@staticmethod
def _invoice_payload_summary(
config: LexwareRuntimeConfig,
repair: Repair,
estimate: RepairEstimate,
line_items: list[LexwareLineItemMapping],
) -> dict:
return {
"type": "invoice",
"title": f"Rechnung zu Reparatur {repair.repair_number}",
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
"repair_number": repair.repair_number,
"estimate_number": estimate.estimate_number,
"currency": estimate.currency,
"payment_terms_days": config.default_payment_terms_days,
"subtotal": str(_euros(estimate.subtotal_cents)),
"tax": str(_euros(estimate.tax_cents)),
"total": str(_euros(estimate.total_cents)),
"line_item_count": len(line_items),
}
@staticmethod
def _invoice_preparation_response(
*,
record: LexwareSyncRecord,
payload_summary: dict,
customer_mapping: LexwareCustomerMapping,
line_items: list[LexwareLineItemMapping],
estimate: RepairEstimate,
config: LexwareRuntimeConfig,
warnings: list[str],
) -> LexwareInvoicePreparationResponse:
return LexwareInvoicePreparationResponse(
ready_for_export=not warnings,
export_status=record.export_status,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_item_mapping=line_items,
tax_mapping={
"source": "repair_estimate",
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
"default_tax_rate": str(config.default_tax_rate),
"tax_amount": str(_euros(estimate.tax_cents)),
},
warnings=warnings,
sync_record_id=record.id,
accounting_note=record.accounting_note or "",
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
transferred_by_user_id=record.transferred_by_user_id,
)
@staticmethod
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
from sqlalchemy import select
return db.scalar(
select(LexwareSyncRecord)
.where(LexwareSyncRecord.entity_type == "repair_estimate")
.where(LexwareSyncRecord.entity_id == estimate_id)
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
.limit(1)
)
@staticmethod
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
return LexwareSettingsResponse(
enabled=config.enabled,
api_base_url=config.api_base_url,
api_key_is_set=config.api_key_is_set,
organization_name=config.organization_name,
default_tax_rate=config.default_tax_rate,
default_payment_terms_days=config.default_payment_terms_days,
source=config.source,
)
@staticmethod
def _get_profile(config: LexwareRuntimeConfig) -> dict:
request = UrlRequest(
f"{config.api_base_url}/v1/profile",
headers={
"Authorization": f"Bearer {config.api_key}",
"Accept": "application/json",
},
method="GET",
)
with urlopen(request, timeout=15) as response:
body = response.read().decode("utf-8")
if not body:
return {}
data = json.loads(body)
return data if isinstance(data, dict) else {}

View file

@ -1,225 +0,0 @@
import mimetypes
from pathlib import Path
from typing import Literal
from urllib.parse import quote
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair, RepairDocument
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairDocumentUpdate
from app.services.audit_service import write_audit_log
from app.storage import get_storage_service
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
}
DOCUMENT_TYPE_LABELS = {
"device_photo": "Gerätefoto",
"fault_photo": "Fehlerbild",
"measurement": "Messbild",
"estimate": "Kostenvoranschlag",
"repair_report": "Reparaturbericht",
"shipping": "Versandbeleg",
"other": "Dokument",
}
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
detail = str(exc) or "Ungültige Datei"
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=status_code, detail=detail)
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _document_label(document: RepairDocument) -> str:
return f"{document.title} · {document.original_filename}"
def _audit_document_data(document: RepairDocument) -> dict:
return {
"id": document.id,
"repair_id": document.repair_id,
"title": document.title,
"document_type": document.document_type,
"original_filename": document.original_filename,
"mime_type": document.mime_type,
"size_bytes": document.size_bytes,
"visibility": document.visibility,
"uploaded_by_user_id": document.uploaded_by_user_id,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
class RepairDocumentService:
@staticmethod
async def upload_document(
db: Session,
repair: Repair,
*,
file: UploadFile,
title: str,
document_type: str,
visibility: str,
note: str | None,
actor: User,
request: Request,
) -> RepairDocument:
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
original_filename = file.filename or ""
mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type)
RepairDocumentService._validate_repair_document_type(original_filename, mime_type)
try:
metadata = storage_service.save_file(
namespace=f"repairs/{repair.id}/documents",
content=content,
original_filename=original_filename,
mime_type=mime_type,
)
except StorageValidationError as exc:
raise storage_validation_error(exc) from exc
try:
document = RepairRepository.create_document(
db,
repair_id=repair.id,
title=title,
document_type=document_type,
original_filename=metadata.original_filename,
stored_filename=metadata.stored_filename,
storage_path=metadata.storage_key,
mime_type=metadata.mime_type,
size_bytes=metadata.size,
checksum_sha256=metadata.checksum_sha256,
visibility=visibility,
note=note,
uploaded_by_user_id=actor.id,
)
except Exception:
storage_service.delete_file(metadata.storage_key)
raise
write_audit_log(
db,
action="repairs.documents.upload",
entity_type="repair_documents",
entity_id=document.id,
entity_label=_document_label(document),
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"document_type": document.document_type,
"visibility": document.visibility,
"mime_type": document.mime_type,
"size_bytes": document.size_bytes,
},
)
return document
@staticmethod
def update_document(
db: Session,
repair: Repair,
document: RepairDocument,
payload: RepairDocumentUpdate,
*,
actor: User,
request: Request,
) -> RepairDocument:
before_data = _audit_document_data(document)
updated = RepairRepository.update_document(db, document, payload)
write_audit_log(
db,
action="repairs.documents.update",
entity_type="repair_documents",
entity_id=updated.id,
entity_label=_document_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_document_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return updated
@staticmethod
def delete_document(
db: Session,
repair: Repair,
document: RepairDocument,
*,
actor: User,
request: Request,
) -> None:
before_data = _audit_document_data(document)
storage_path = document.storage_path
label = _document_label(document)
try:
get_storage_service().delete_file(storage_path)
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
RepairRepository.delete_document(db, document)
write_audit_log(
db,
action="repairs.documents.delete",
entity_type="repair_documents",
entity_id=document.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def open_document_file(document: RepairDocument) -> Path:
if not document.storage_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
try:
return get_storage_service().open_file(document.storage_path)
except StorageFileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str:
filename = document.original_filename or document.stored_filename or "reparatur-dokument"
safe_filename = filename.replace('"', "")
encoded_filename = quote(filename)
return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}'
@staticmethod
def _resolve_mime_type(original_filename: str, content_type: str | None) -> str:
if content_type and content_type != "application/octet-stream":
return content_type
return mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
@staticmethod
def _validate_repair_document_type(original_filename: str, mime_type: str) -> None:
extension = Path(original_filename or "").suffix.lower()
if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")

View file

@ -1,662 +0,0 @@
from datetime import UTC, datetime
from decimal import Decimal
from html import escape
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
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
from app.schemas.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateItemResponse,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateItemPayload,
RepairEstimateUpdate,
calculate_item_total,
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
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _estimate_label(estimate: RepairEstimate) -> str:
return f"{estimate.estimate_number} · {estimate.title}"
def _money(cents: int, currency: str = "EUR") -> str:
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
return {
"id": estimate.id,
"repair_id": estimate.repair_id,
"estimate_number": estimate.estimate_number,
"status": estimate.status,
"title": estimate.title,
"currency": estimate.currency,
"valid_until": estimate.valid_until,
"sent_at": estimate.sent_at,
"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:
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
estimate = RepairEstimate(
repair_id=repair.id,
estimate_number=estimate_number,
status="draft",
created_by_user_id=actor.id,
)
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
RepairEstimateService._apply_payload(db, estimate, payload)
try:
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
except IntegrityError:
db.rollback()
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.create",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
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
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
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)
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)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.update",
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},
)
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", "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,
action="repair_estimates.delete",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
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,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
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:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_mail",
channel="email",
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
template="repair_estimate",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
write_audit_log(
db,
action="repair_estimates.send",
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, "mail_sent": mail_sent},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
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()
db.refresh(estimate)
write_audit_log(
db,
action="repair_estimates.cancel",
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},
)
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:
return None
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,
tax_cents=estimate.tax_cents,
total_cents=estimate.total_cents,
currency=estimate.currency,
valid_until=estimate.valid_until,
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,
unit=item.unit,
unit_price_cents=item.unit_price_cents,
total_cents=item.total_cents,
)
for item in estimate.items
],
)
@staticmethod
def customer_decision(
db: Session,
repair: Repair,
estimate: RepairEstimate,
decision: str,
payload: PublicEstimateDecisionRequest,
*,
request: Request | None,
) -> RepairEstimate:
if estimate.status != "sent":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
now = datetime.now(UTC)
estimate.customer_response_message = payload.message
if decision == "approve":
estimate.status = "approved"
estimate.approved_at = now
event_type = "approved"
repair_status = "approved"
note = "Kunde hat den Kostenvoranschlag freigegeben"
audit_action = "repair_estimates.approve"
elif decision == "decline":
estimate.status = "declined"
estimate.declined_at = now
event_type = "declined"
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"
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
audit_action = "repair_estimates.question"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action=audit_action,
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
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(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(db, payload.items)
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
estimate.subtotal_cents = subtotal
estimate.tax_cents = tax_cents
estimate.total_cents = subtotal + tax_cents
estimate.items = items
@staticmethod
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):
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=item_type,
title=title,
description=description,
quantity=payload.quantity,
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 (
f"Hallo {repair.customer_name},\n\n"
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n"
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
</td></tr></table></body></html>"""
@staticmethod
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
f"Reparatur: {repair.repair_number}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _revoked_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Status ansehen</a></p>
</td></tr></table></body></html>"""

View file

@ -1,18 +1,26 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from html import escape
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair, RepairNotificationEvent
from app.models.user import User
from app.models.repair import Repair
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
from app.services.audit_service import write_audit_log
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
from app.services.system_settings_service import SystemSettingsService
STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"accepted": "Angenommen",
"diagnosis": "Diagnose",
"estimate": "Kostenvoranschlag",
"waiting_for_customer": "Wartet auf Kunde",
"approved": "Freigegeben",
"repair": "Reparatur",
"final_test": "Endprüfung",
"ready_for_pickup": "Abholbereit",
"shipped": "Versand",
"completed": "Abgeschlossen",
"cancelled": "Storniert",
}
@dataclass(frozen=True)
@ -88,83 +96,13 @@ TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
)
def _device_label(repair: Repair) -> str:
return f"{repair.device_manufacturer} {repair.device_model}".strip()
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"der Status Ihrer Reparatur wurde aktualisiert.\n\n"
f"Reparaturnummer:\n{repair.repair_number}\n\n"
f"Gerät:\n{_device_label(repair)}\n\n"
f"Neuer Status:\n{status_label}\n\n"
f"Status online ansehen:\n{public_status_url}\n\n"
"Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n"
"Funktechnik Schubert"
)
def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de">
<body style="margin:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#172033;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid #dce5ef;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#082a60;color:#ffffff;padding:24px 28px;">
<div style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Olympus CRM</div>
<div style="font-size:24px;font-weight:800;margin-top:6px;">Funktechnik Schubert</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 18px;font-size:16px;">Hallo {escape(repair.customer_name)},</p>
<p style="margin:0 0 22px;font-size:16px;line-height:1.55;">der Status Ihrer Reparatur wurde aktualisiert.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 24px;">
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;">Reparaturnummer</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;">{escape(repair.repair_number)}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Gerät</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(_device_label(repair))}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Neuer Status</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(status_label)}</td>
</tr>
</table>
<p style="margin:0 0 24px;">
<a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#ffffff;text-decoration:none;font-weight:800;border-radius:8px;padding:13px 18px;">Reparaturstatus ansehen</a>
</p>
<p style="margin:0;color:#657184;font-size:14px;line-height:1.5;">Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f8fafc;color:#657184;font-size:13px;">Funktechnik Schubert</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
class RepairNotificationService:
@staticmethod
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
placeholders = {
"repair_number": repair.repair_number,
"customer_name": repair.customer_name,
"device": _device_label(repair),
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
"status_label": STATUS_LABELS.get(repair.status, repair.status),
"public_status_url": public_status_url or "wird später bereitgestellt",
"company_name": "Olympus CRM",
@ -186,112 +124,3 @@ class RepairNotificationService:
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
events=RepairRepository.list_notification_events(db, repair.id),
)
@staticmethod
def send_status_mail(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
manual: bool = False,
) -> RepairNotificationEvent:
recipient = repair.customer_email.strip()
subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert"
status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status)
template = "repair_status_update"
if not recipient:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient="",
subject=subject,
template=template,
status="skipped",
success=False,
error_message="Keine Kunden-E-Mail hinterlegt",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient")
return event
link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create"
public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action)
public_status_url = public_link.public_status_path
text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
if not smtp_config.is_configured:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="skipped",
success=False,
error_message="SMTP nicht konfiguriert",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured")
return event
try:
SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html)
except Exception:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="failed",
success=False,
error_message="Statusmail konnte nicht versendet werden",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed")
return event
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="sent",
success=True,
sent_at=datetime.now(UTC),
)
write_audit_log(
db,
action="repairs.status_mail.sent",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"notification_event_id": event.id},
)
return event
@staticmethod
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"reason": reason},
)

View file

@ -11,7 +11,6 @@ from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.schemas.repair import (
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
@ -19,32 +18,18 @@ from app.schemas.repair import (
RepairPublicStatusResponse,
)
from app.services.audit_service import write_audit_log
from app.services.repair_status_labels import STATUS_LABELS
from app.services.system_settings_service import SystemSettingsService
from app.services.repair_notification_service import STATUS_LABELS
from app.services.repair_service import repair_label
def normalize_token(token: str) -> str:
return token.strip()
def repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def public_status_path(db: Session, token: str) -> str:
normalized_token = normalize_token(token)
path = f"/status/{normalized_token}"
base_url = SystemSettingsService.get_public_repair_status_base_url(db)
if not base_url:
return path
return f"{base_url}/{normalized_token}"
def public_status_path(token: str) -> str:
return f"/status/{token}"
class RepairPublicLinkService:
@staticmethod
def hash_token(token: str) -> str:
normalized_token = normalize_token(token)
return hmac.new(settings.secret_key.encode("utf-8"), normalized_token.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def create_token() -> str:
@ -69,23 +54,6 @@ class RepairPublicLinkService:
@staticmethod
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
return RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.create",
)
@staticmethod
def create_with_audit(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
audit_action: str,
) -> RepairPublicLinkCreateResponse:
token = RepairPublicLinkService.create_token()
public_link = RepairRepository.create_public_link(
db,
@ -95,7 +63,7 @@ class RepairPublicLinkService:
)
write_audit_log(
db,
action=audit_action,
action="repairs.public_link.create",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
@ -111,7 +79,7 @@ class RepairPublicLinkService:
last_used_at=public_link.last_used_at,
created_at=public_link.created_at,
revoked_at=public_link.revoked_at,
public_status_path=public_status_path(db, token),
public_status_path=public_status_path(token),
)
@staticmethod
@ -132,13 +100,14 @@ class RepairPublicLinkService:
@staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
RepairRepository.mark_public_link_used(db, public_link)
repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id)
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
from app.services.repair_estimate_service import RepairEstimateService
response = RepairPublicStatusResponse(
return RepairPublicStatusResponse(
repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
device_manufacturer=repair.device_manufacturer,
@ -152,18 +121,4 @@ class RepairPublicLinkService:
for item in history
],
updated_at=repair.updated_at,
estimate=RepairEstimateService.public_response(estimate),
)
RepairRepository.mark_public_link_used(db, public_link)
return response
@staticmethod
def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
return public_link

View file

@ -11,7 +11,6 @@ from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate
from app.services.audit_service import sanitize, write_audit_log
from app.services.repair_notification_service import RepairNotificationService
def repair_label(repair: Repair) -> str:
@ -75,19 +74,6 @@ class RepairService:
after_data=updated,
metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status},
)
try:
RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request)
except Exception:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
metadata={"reason": "unexpected_notification_error"},
)
return updated
@staticmethod

View file

@ -1,29 +0,0 @@
STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"accepted": "Angenommen",
"diagnosis": "Diagnose",
"estimate": "Kostenvoranschlag",
"waiting_for_customer": "Wartet auf Kunde",
"approved": "Freigegeben",
"repair": "Reparatur",
"final_test": "Endprüfung",
"ready_for_pickup": "Abholbereit",
"shipped": "Versand",
"completed": "Abgeschlossen",
"cancelled": "Storniert",
}
MAIL_STATUS_LABELS: dict[str, str] = {
"new": "Neu eingegangen",
"accepted": "Reparatur angenommen",
"diagnosis": "Gerät in Diagnose",
"estimate": "Kostenvoranschlag erstellt",
"waiting_for_customer": "Wartet auf Ihre Rückmeldung",
"approved": "Reparatur freigegeben",
"repair": "Reparatur wird durchgeführt",
"final_test": "Gerät wird geprüft",
"ready_for_pickup": "Gerät ist abholbereit",
"shipped": "Gerät wurde versendet",
"completed": "Reparatur abgeschlossen",
"cancelled": "Reparatur wurde storniert",
}

View file

@ -1,316 +0,0 @@
import smtplib
from dataclasses import dataclass
from email.message import EmailMessage
from pydantic import EmailStr
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.system_setting import (
PublicLinksSettingsResponse,
PublicLinksSettingsUpdate,
SmtpSettingsResponse,
SmtpSettingsUpdate,
SmtpTestResponse,
SettingsSource,
)
from app.services.audit_service import write_audit_log
SMTP_KEYS = (
"smtp.host",
"smtp.port",
"smtp.username",
"smtp.password",
"smtp.from_email",
"smtp.from_name",
"smtp.use_tls",
"smtp.enabled",
)
SECRET_KEYS = {"smtp.password"}
@dataclass(frozen=True)
class SmtpRuntimeConfig:
host: str
port: int
username: str
password: str
from_email: str
from_name: str
use_tls: bool
enabled: bool
source: SettingsSource
@property
def is_configured(self) -> bool:
return self.enabled and bool(self.host and self.from_email)
@property
def password_is_set(self) -> bool:
return bool(self.password)
@property
def from_header(self) -> str:
if self.from_name:
return f"{self.from_name} <{self.from_email}>"
return self.from_email
def parse_bool(value: str | None, *, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def parse_int(value: str | None, *, default: int) -> int:
try:
parsed = int(str(value or "").strip())
except ValueError:
return default
if parsed < 1 or parsed > 65535:
return default
return parsed
class SystemSettingsService:
@staticmethod
def get_smtp_settings(db: Session) -> SmtpSettingsResponse:
return SystemSettingsService.smtp_response(SystemSettingsService.get_smtp_runtime_config(db))
@staticmethod
def get_smtp_runtime_config(db: Session) -> SmtpRuntimeConfig:
values = SystemSettingsService._get_values(db, SMTP_KEYS)
database_enabled = parse_bool(values.get("smtp.enabled"), default=False)
database_host = values.get("smtp.host", "")
database_from_email = values.get("smtp.from_email", "")
if database_enabled and database_host and database_from_email:
return SmtpRuntimeConfig(
host=database_host,
port=parse_int(values.get("smtp.port"), default=587),
username=values.get("smtp.username", ""),
password=values.get("smtp.password", ""),
from_email=database_from_email,
from_name=values.get("smtp.from_name", "Funktechnik Schubert") or "Funktechnik Schubert",
use_tls=parse_bool(values.get("smtp.use_tls"), default=True),
enabled=True,
source="database",
)
if settings.smtp_host and settings.smtp_from_email:
return SmtpRuntimeConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username or "",
password=settings.smtp_password or "",
from_email=settings.smtp_from_email,
from_name=settings.smtp_from_name,
use_tls=settings.smtp_use_tls,
enabled=True,
source="environment",
)
return SmtpRuntimeConfig(
host=database_host or settings.smtp_host or "",
port=parse_int(values.get("smtp.port"), default=settings.smtp_port),
username=values.get("smtp.username", "") or settings.smtp_username or "",
password=values.get("smtp.password", "") or settings.smtp_password or "",
from_email=database_from_email or settings.smtp_from_email or "",
from_name=values.get("smtp.from_name", "") or settings.smtp_from_name,
use_tls=parse_bool(values.get("smtp.use_tls"), default=settings.smtp_use_tls),
enabled=False,
source="missing",
)
@staticmethod
def update_smtp_settings(
db: Session,
payload: SmtpSettingsUpdate,
*,
actor: User,
request: Request,
) -> SmtpSettingsResponse:
current_values = SystemSettingsService._get_values(db, SMTP_KEYS)
password = payload.password if payload.password else current_values.get("smtp.password", "")
updates = {
"smtp.host": payload.host,
"smtp.port": str(payload.port),
"smtp.username": payload.username,
"smtp.password": password,
"smtp.from_email": payload.from_email,
"smtp.from_name": payload.from_name or "Funktechnik Schubert",
"smtp.use_tls": "true" if payload.use_tls else "false",
"smtp.enabled": "true" if payload.enabled else "false",
}
for key, value in updates.items():
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in SECRET_KEYS)
db.commit()
write_audit_log(
db,
action="system_settings.smtp.update",
entity_type="system_settings",
entity_label="SMTP-Konfiguration",
actor=actor,
request=request,
metadata={
"enabled": payload.enabled,
"host": payload.host,
"from_email": payload.from_email,
"password_changed": bool(payload.password),
},
)
return SystemSettingsService.get_smtp_settings(db)
@staticmethod
def get_public_links_settings(db: Session) -> PublicLinksSettingsResponse:
value = SystemSettingsService.get_public_repair_status_base_url(db)
source: SettingsSource = "missing"
if value:
db_value = SystemSettingsRepository.get(db, "public.repair_status_base_url")
source = "database" if db_value and db_value.value.strip() else "environment"
return PublicLinksSettingsResponse(repair_status_base_url=value, source=source)
@staticmethod
def get_public_repair_status_base_url(db: Session) -> str:
setting = SystemSettingsRepository.get(db, "public.repair_status_base_url")
if setting and setting.value.strip():
return setting.value.strip().rstrip("/")
return (settings.public_repair_status_base_url or "").strip().rstrip("/")
@staticmethod
def update_public_links_settings(
db: Session,
payload: PublicLinksSettingsUpdate,
*,
actor: User,
request: Request,
) -> PublicLinksSettingsResponse:
SystemSettingsRepository.upsert(
db,
key="public.repair_status_base_url",
value=payload.repair_status_base_url,
is_secret=False,
)
db.commit()
write_audit_log(
db,
action="system_settings.public_links.update",
entity_type="system_settings",
entity_label="Öffentliche Links",
actor=actor,
request=request,
metadata={"repair_status_base_url": payload.repair_status_base_url},
)
return SystemSettingsService.get_public_links_settings(db)
@staticmethod
def send_test_mail(
db: Session,
recipient: EmailStr,
*,
actor: User,
request: Request,
) -> SmtpTestResponse:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
if not smtp_config.is_configured:
write_audit_log(
db,
action="system_settings.smtp.test_failed",
entity_type="system_settings",
entity_label="SMTP-Testmail",
actor=actor,
request=request,
metadata={"reason": "smtp_not_configured", "source": smtp_config.source},
)
return SmtpTestResponse(success=False, message="SMTP ist nicht vollständig konfiguriert.", source=smtp_config.source)
try:
SystemSettingsService.send_email(
smtp_config,
recipient=str(recipient),
subject="Olympus CRM SMTP-Test",
text=(
"Hallo,\n\n"
"dies ist eine Testmail aus Olympus CRM. "
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.\n\n"
"Olympus CRM"
),
html=(
"<p>Hallo,</p>"
"<p>dies ist eine Testmail aus Olympus CRM. "
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.</p>"
"<p>Olympus CRM</p>"
),
)
except Exception:
write_audit_log(
db,
action="system_settings.smtp.test_failed",
entity_type="system_settings",
entity_label="SMTP-Testmail",
actor=actor,
request=request,
metadata={"reason": "smtp_send_failed", "source": smtp_config.source},
)
return SmtpTestResponse(success=False, message="Testmail konnte nicht versendet werden.", source=smtp_config.source)
write_audit_log(
db,
action="system_settings.smtp.test_sent",
entity_type="system_settings",
entity_label="SMTP-Testmail",
actor=actor,
request=request,
metadata={"source": smtp_config.source},
)
return SmtpTestResponse(success=True, message="Testmail wurde versendet.", source=smtp_config.source)
@staticmethod
def send_email(
smtp_config: SmtpRuntimeConfig,
*,
recipient: str,
subject: str,
text: str,
html: str,
) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = smtp_config.from_header
message["To"] = recipient
message.set_content(text)
message.add_alternative(html, subtype="html")
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=15) as smtp:
if smtp_config.use_tls:
smtp.starttls()
if smtp_config.username and smtp_config.password:
smtp.login(smtp_config.username, smtp_config.password)
smtp.send_message(message)
@staticmethod
def smtp_response(smtp_config: SmtpRuntimeConfig) -> SmtpSettingsResponse:
return SmtpSettingsResponse(
host=smtp_config.host,
port=smtp_config.port,
username=smtp_config.username,
password_is_set=smtp_config.password_is_set,
from_email=smtp_config.from_email,
from_name=smtp_config.from_name,
use_tls=smtp_config.use_tls,
enabled=smtp_config.enabled,
source=smtp_config.source,
)
@staticmethod
def _get_values(db: Session, keys: tuple[str, ...]) -> dict[str, str]:
settings_by_key = SystemSettingsRepository.get_many(db, keys)
return {key: settings_by_key[key].value.strip() for key in keys if key in settings_by_key}

View file

@ -87,7 +87,6 @@ class LocalDiskStorageProvider(StorageProvider):
"customers",
"projects",
"tickets",
"repairs",
"imports",
"temp",
]:

View file

@ -7,7 +7,7 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API
APP_VERSION: 0.9.1
APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
@ -22,9 +22,6 @@ services:
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage

View file

@ -2,10 +2,6 @@ FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN pip install uv

View file

@ -10,7 +10,7 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API
APP_VERSION: 0.9.1
APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
@ -26,18 +26,6 @@ services:
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-}
# Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang.
PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`);
}

View file

@ -1,14 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function GET(request: NextRequest, context: Params) {
const { filename } = await context.params;
return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`);
}

View file

@ -1,12 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/backups/create");
}

View file

@ -1,7 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/backups");
}

View file

@ -1,33 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyCategoryRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/categories/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyCategoryRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyCategoryRequest(request, context);
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/categories");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/categories");
}

View file

@ -1,12 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/movements`);
}

View file

@ -1,37 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyItemRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyItemRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyItemRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyItemRequest(request, context);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`);
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/items");
}

View file

@ -1,7 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`);
}

View file

@ -1,33 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyLocationRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/locations/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyLocationRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyLocationRequest(request, context);
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/locations");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/locations");
}

View file

@ -1,33 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxySupplierRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/suppliers/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxySupplierRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxySupplierRequest(request, context);
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/suppliers");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/suppliers");
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/lexware/settings");
}
export async function PUT(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/lexware/settings");
}

View file

@ -1,14 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/lexware/test-connection");
}

View file

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{
id: string;
documentId: string;
}>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id, documentId } = await params;
return proxyHermesStreamRequest(
request,
`/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`,
);
}

View file

@ -1,40 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
id: string;
documentId: string;
}>;
};
async function proxyDocumentRequest(request: NextRequest, { params }: Params) {
const { id, documentId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyDocumentRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyDocumentRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyDocumentRequest(request, context);
}

View file

@ -1,14 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{
id: string;
}>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/documents`);
}

View file

@ -1,21 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
id: string;
}>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/documents/upload`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`);
}

View file

@ -1,12 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`);
}

View file

@ -1,37 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
async function proxyEstimateRequest(request: NextRequest, { params }: Params) {
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyEstimateRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyEstimateRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyEstimateRequest(request, context);
}

View file

@ -1,19 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`);
}

View file

@ -1,24 +0,0 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
}
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
}

Some files were not shown because too many files have changed in this diff Show more