Compare commits
21 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c917df515 | ||
|
|
7835d3ca75 | ||
|
|
46eeaa1f2e | ||
|
|
ffffb68898 | ||
|
|
8d37eb29b3 | ||
|
|
31bdc1047d | ||
|
|
663bdc41b1 | ||
|
|
fb5c2cc26a | ||
|
|
abc6e308dd | ||
|
|
111d8d12df | ||
|
|
4436fe5f73 | ||
|
|
6e7e75f864 | ||
|
|
884a20e043 | ||
|
|
c58e212e3a | ||
|
|
3ed6596bf0 | ||
|
|
09cce1f2b6 | ||
|
|
e9ec207617 | ||
|
|
22e54f212c | ||
|
|
612e1ad8ed | ||
|
|
964b545bc5 | ||
|
|
228da8f814 |
170 changed files with 19001 additions and 25 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
38
.env.example
38
.env.example
|
|
@ -12,3 +12,41 @@ INITIAL_ADMIN_EMAIL=
|
|||
INITIAL_ADMIN_PASSWORD=
|
||||
INITIAL_ADMIN_FIRST_NAME=
|
||||
INITIAL_ADMIN_LAST_NAME=
|
||||
|
||||
# Zentrales Storage Framework.
|
||||
# Lokal kann STORAGE_HOST_PATH ein Repo-lokaler Ordner sein.
|
||||
# Auf dem VPS wird empfohlen: STORAGE_HOST_PATH=/opt/olympus-storage
|
||||
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
|
||||
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
7
.gitignore
vendored
|
|
@ -39,6 +39,13 @@ out/
|
|||
# ===========================
|
||||
*.log
|
||||
|
||||
# ===========================
|
||||
# Runtime storage
|
||||
# ===========================
|
||||
storage/
|
||||
/backups/
|
||||
*.dump
|
||||
|
||||
# ===========================
|
||||
# Coverage
|
||||
# ===========================
|
||||
|
|
|
|||
629
ARCHITECTURE.md
629
ARCHITECTURE.md
|
|
@ -166,6 +166,58 @@ Hermes
|
|||
|
||||
Athena ist die einzige API-Oberflaeche fuer den Browser.
|
||||
|
||||
## Lexware Office Integration
|
||||
|
||||
Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation.
|
||||
|
||||
Verantwortlichkeiten:
|
||||
|
||||
- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege.
|
||||
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
|
||||
- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt.
|
||||
|
||||
Komponenten:
|
||||
|
||||
- Hermes speichert Lexware-Konfiguration in `system_settings`.
|
||||
- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`.
|
||||
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
|
||||
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
|
||||
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
|
||||
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
```text
|
||||
Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office
|
||||
Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL
|
||||
```
|
||||
|
||||
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
|
||||
|
||||
Buchhaltungsworkflow:
|
||||
|
||||
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
|
||||
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
|
||||
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
|
||||
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
|
||||
|
||||
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
|
||||
|
||||
Neue zentrale Endpunkte:
|
||||
|
||||
- `GET /lexware/settings`
|
||||
- `PUT /lexware/settings`
|
||||
- `POST /lexware/test-connection`
|
||||
- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice`
|
||||
|
||||
RBAC:
|
||||
|
||||
- `lexware.read`
|
||||
- `lexware.manage`
|
||||
- `lexware.export`
|
||||
|
||||
Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
|
||||
|
|
@ -177,6 +229,7 @@ Beispiele:
|
|||
- Kundenstatistiken nur mit `customers.read`
|
||||
- Benutzerstatistiken nur mit `users.read`
|
||||
- Rollenstatus nur mit `roles.read`
|
||||
- Reparaturkennzahlen nur mit `repairs.read`
|
||||
|
||||
Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung.
|
||||
|
||||
|
|
@ -188,6 +241,339 @@ Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes
|
|||
|
||||
Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt.
|
||||
|
||||
## Reparaturmanagement
|
||||
|
||||
Das Modul `Reparaturen` verwaltet interne Werkstattvorgaenge. Es liegt fachlich in Hermes und wird im Browser ausschliesslich ueber Athena erreicht.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
```text
|
||||
Browser -> Athena /api/repairs... -> Hermes /repairs... -> PostgreSQL
|
||||
```
|
||||
|
||||
Kernfunktionen:
|
||||
|
||||
- Reparaturen manuell anlegen und bearbeiten
|
||||
- eindeutige Reparaturnummer im Format `R<jahr>-<laufende nummer sechsstellig>`, z. B. `R2026-000001`
|
||||
- Statusmodell von `new` bis `completed` oder `cancelled`
|
||||
- deutsche UI-Statuslabels in Athena, die internen API-Statuscodes bleiben englisch und stabil
|
||||
- Statushistorie in `repair_status_history`
|
||||
- werkstattgerechte Status-Timeline mit Datum, Benutzer und Notiz
|
||||
- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno, Intake und Statuslink-Verwaltung
|
||||
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben, laufende Reparaturen, Endpruefung und abgeschlossene Reparaturen
|
||||
|
||||
Tabellen:
|
||||
|
||||
- `repairs`
|
||||
- `repair_status_history`
|
||||
- `repair_intake_events`
|
||||
- `repair_documents`
|
||||
- `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`
|
||||
- `GET /repairs/{id}`
|
||||
- `POST /repairs`
|
||||
- `PUT /repairs/{id}`
|
||||
- `DELETE /repairs/{id}` als fachliches Storno
|
||||
- `PUT /repairs/{id}/status`
|
||||
- `GET /repairs/{id}/history`
|
||||
- `GET /repairs/{id}/public-link`
|
||||
- `POST /repairs/{id}/public-link`
|
||||
- `DELETE /repairs/{id}/public-link`
|
||||
- `GET /repairs/{id}/notifications`
|
||||
- `GET /public/repairs/status/{token}`
|
||||
|
||||
Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei.
|
||||
|
||||
Statuslabels:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
### Sicherer Reparaturstatus-Link
|
||||
|
||||
v0.8.1 bereitet einen oeffentlichen Statuslink vor. Es wird noch kein Kundenportal und kein Kundenlogin gebaut.
|
||||
|
||||
Sicherheitsmodell:
|
||||
|
||||
- Klartexttoken wird lang und zufaellig erzeugt.
|
||||
- In PostgreSQL wird nur ein HMAC/SHA-256-Hash des Tokens gespeichert.
|
||||
- Der Klartexttoken wird nur einmal bei Erstellung an Athena zurueckgegeben.
|
||||
- Tokens werden niemals geloggt.
|
||||
- `repair_public_access_tokens` speichert Aktivstatus, Ablaufdatum, letzte Nutzung und Widerruf.
|
||||
- `GET /public/repairs/status/{token}` gibt nur kundenfreundliche Statusdaten aus.
|
||||
- Keine Kundendaten, internen Notizen oder Diagnosedetails werden oeffentlich ausgegeben.
|
||||
|
||||
Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die eigentliche Website wird in Olympus nicht veraendert.
|
||||
|
||||
### Reparatur-Benachrichtigungen
|
||||
|
||||
`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert.
|
||||
|
||||
Vorbereitete Ereignisse:
|
||||
|
||||
- Reparatur erfasst
|
||||
- Geraet angenommen
|
||||
- Diagnose laeuft
|
||||
- Kostenvoranschlag erstellt
|
||||
- Wartet auf Kundenfreigabe
|
||||
- Reparatur laeuft
|
||||
- Endpruefung
|
||||
- Abholbereit
|
||||
- Versand
|
||||
- Abgeschlossen
|
||||
|
||||
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.
|
||||
|
||||
Sicherheitsmodell:
|
||||
|
||||
- keine Browser-Cookie- oder JWT-Authentifizierung
|
||||
- Token per Header `X-Olympus-Intake-Token`
|
||||
- Token aus `OLYMPUS_REPAIR_INTAKE_TOKEN`
|
||||
- keine CORS-Freigabe fuer Browser erforderlich
|
||||
- Token wird nicht geloggt
|
||||
|
||||
Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`.
|
||||
|
||||
Eine spaetere Statusseite ist architektonisch vorbereitet, aber noch nicht in der oeffentlichen Website implementiert.
|
||||
|
||||
## RBAC: Rollen und Berechtigungen
|
||||
|
||||
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
|
||||
|
|
@ -244,6 +630,16 @@ Kunden:
|
|||
- `customers.read`
|
||||
- `customers.create`
|
||||
- `customers.update`
|
||||
- `customers.delete`
|
||||
|
||||
Knowledge:
|
||||
|
||||
- `knowledge.read`
|
||||
- `knowledge.create`
|
||||
- `knowledge.update`
|
||||
- `knowledge.delete`
|
||||
- `knowledge.upload`
|
||||
- `knowledge.download`
|
||||
|
||||
### Initial Admin Bootstrap
|
||||
|
||||
|
|
@ -294,7 +690,6 @@ customer_number;company_name;legal_name;customer_type;status;industry;website;em
|
|||
```
|
||||
|
||||
Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert.
|
||||
- `customers.delete`
|
||||
|
||||
Projekte:
|
||||
|
||||
|
|
@ -511,6 +906,211 @@ Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlerm
|
|||
|
||||
Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind.
|
||||
|
||||
## Wissensdatenbank / Service Library
|
||||
|
||||
v0.6.0 fuehrt das Modul Wissensdatenbank ein. Es verwaltet Hersteller, Geraete und Modelle, Dokumente sowie Reparaturhinweise fuer Funktechnik, Elektronikreparatur und Werkstattbetrieb.
|
||||
|
||||
Hermes stellt dafuer Tabellen mit dem Prefix `knowledge_` bereit:
|
||||
|
||||
- `knowledge_manufacturers`
|
||||
- `knowledge_devices`
|
||||
- `knowledge_documents`
|
||||
- `knowledge_notes`
|
||||
- `knowledge_categories`
|
||||
|
||||
Athena stellt die BFF-Routen unter `/api/knowledge/...` bereit. Der Browser spricht weiterhin ausschliesslich mit Athena.
|
||||
|
||||
### Knowledge-Workflow
|
||||
|
||||
Seit v0.7.1 fuehrt die UI Benutzer explizit durch den fachlichen Ablauf:
|
||||
|
||||
```text
|
||||
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
|
||||
```
|
||||
|
||||
Dokumente wie Schaltplaene, Service Manuals oder Abgleichanleitungen werden immer einem konkreten Geraet zugeordnet. Der Upload wird in Athena deaktiviert, solange Hersteller oder Geraet fehlen. Empty States erklaeren, was fehlt, und bieten die naechste sinnvolle Aktion an.
|
||||
|
||||
Upload-Voraussetzungen:
|
||||
|
||||
- Hersteller ist Pflicht.
|
||||
- Geraet ist Pflicht.
|
||||
- Dokumenttyp ist Pflicht.
|
||||
- Datei ist Pflicht bei neuen Uploads.
|
||||
- Das Geraet muss zum ausgewaehlten Hersteller gehoeren.
|
||||
|
||||
Hermes validiert diese Regeln serverseitig und liefert Benutzerfehler mit klaren Meldungen statt 500er-Antworten.
|
||||
|
||||
### Standard-Hersteller-Seeding
|
||||
|
||||
Hermes legt beim Startup idempotent Standard-Hersteller an, sofern sie noch nicht existieren:
|
||||
|
||||
- Stabo
|
||||
- President
|
||||
- Albrecht
|
||||
- Marconi
|
||||
- Rohde & Schwarz
|
||||
- HP
|
||||
- CRT
|
||||
- Alinco
|
||||
- Motorola
|
||||
- Team
|
||||
|
||||
Das Seeding ueberschreibt bestehende Hersteller nicht. Fehlende Slugs werden ergaenzt. Websites und Notizen bleiben leer, solange keine sicheren Stammdaten gepflegt sind.
|
||||
|
||||
### Empty States
|
||||
|
||||
Athena verwendet fuer Knowledge-Listen die zentrale Komponente `EmptyState`.
|
||||
|
||||
Sie wird eingesetzt fuer:
|
||||
|
||||
- leere Herstellerliste
|
||||
- leere Geraeteliste
|
||||
- leere Dokumentliste
|
||||
- leere Notizliste
|
||||
- Suche oder Filter ohne Treffer
|
||||
|
||||
Jeder Empty State erklaert kurz den fehlenden Zustand und bietet eine passende Aktion an.
|
||||
|
||||
### Knowledge-Dateispeicherung
|
||||
|
||||
Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. KnowledgeService speichert, liest und loescht Dateien nicht mehr direkt ueber verstreute Dateioperationen, sondern ueber `StorageService`.
|
||||
|
||||
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`
|
||||
- `STORAGE_BASE_PATH`, Default `/data/storage`
|
||||
- `STORAGE_MAX_UPLOAD_MB`, Default `50`
|
||||
- `KNOWLEDGE_STORAGE_PATH`, Legacy-Fallback fuer bestehende Installationen
|
||||
- `KNOWLEDGE_MAX_UPLOAD_MB`, Legacy-Fallback fuer bestehende Installationen
|
||||
|
||||
Docker Compose bindet den Host-Pfad aus `STORAGE_HOST_PATH` nach `/data/storage` ein. Fuer den VPS ist `/opt/olympus-storage` empfohlen. Bestehende Dateien unter `/data/knowledge` werden nicht automatisch verschoben; wenn Migration noetig ist, muss sie kontrolliert geplant und vorher gebackupt werden.
|
||||
|
||||
Erlaubte Uploadtypen:
|
||||
|
||||
- PDF
|
||||
- JPG/JPEG
|
||||
- PNG
|
||||
- WEBP
|
||||
- TXT
|
||||
- ZIP
|
||||
|
||||
Hermes normalisiert Dateinamen, erzeugt eindeutige gespeicherte Dateinamen, validiert Extension und MIME-Type, begrenzt die Uploadgroesse, berechnet SHA256 und verhindert Path Traversal.
|
||||
|
||||
## Storage Framework
|
||||
|
||||
Das Storage Framework liegt unter `backend/hermes/app/storage`.
|
||||
|
||||
Bestandteile:
|
||||
|
||||
- `StorageProvider` als Interface
|
||||
- `LocalDiskStorageProvider` als erste Implementierung
|
||||
- `StorageService` als zentrale API fuer Fachmodule
|
||||
- Storage-Schemas und eigene Storage-Exceptions
|
||||
|
||||
Zentrale Operationen:
|
||||
|
||||
- `save_file()`
|
||||
- `open_file()`
|
||||
- `delete_file()`
|
||||
- `file_exists()`
|
||||
- `get_file_metadata()`
|
||||
- `calculate_checksum()`
|
||||
- `safe_filename()`
|
||||
- `validate_file_type()`
|
||||
- `validate_file_size()`
|
||||
|
||||
Der aktuelle Provider ist `local`. Die Architektur ist bewusst fuer spaetere Provider vorbereitet:
|
||||
|
||||
- NAS
|
||||
- S3
|
||||
- MinIO
|
||||
- Paperless
|
||||
- Azure Blob
|
||||
- Backblaze / Wasabi
|
||||
|
||||
Pfadstruktur im lokalen Provider:
|
||||
|
||||
```text
|
||||
/data/storage/
|
||||
knowledge/
|
||||
documents/
|
||||
thumbnails/
|
||||
customers/
|
||||
projects/
|
||||
tickets/
|
||||
imports/
|
||||
temp/
|
||||
```
|
||||
|
||||
Datei-Inhalte werden nicht geloggt. Browser greifen nie direkt auf Storage oder Hermes-Dateipfade zu; Downloads laufen ueber Athena-BFF und Hermes-Berechtigungspruefung.
|
||||
|
||||
## Backup und Deployment
|
||||
|
||||
Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
|
||||
|
||||
- `scripts/migrate.sh`
|
||||
- `scripts/healthcheck.sh`
|
||||
- `scripts/deploy.sh`
|
||||
- `scripts/backup.sh`
|
||||
- `scripts/restore.sh`
|
||||
|
||||
`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`.
|
||||
|
||||
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
|
||||
|
||||
### Backup-Modul ab v0.9.1
|
||||
|
||||
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
|
||||
|
||||
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
|
||||
- Hermes-API: `backend/hermes/app/api/backups.py`
|
||||
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
|
||||
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
|
||||
- Athena-BFF: `/api/backups/...`
|
||||
|
||||
Sicherheitsregeln:
|
||||
|
||||
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
|
||||
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
|
||||
- Hermes anonymisiert den Datenbank-Host im Manifest.
|
||||
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
|
||||
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
|
||||
|
||||
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
|
||||
|
||||
### Knowledge-RBAC
|
||||
|
||||
Neue Berechtigungen:
|
||||
|
||||
- `knowledge.read`
|
||||
- `knowledge.create`
|
||||
- `knowledge.update`
|
||||
- `knowledge.delete`
|
||||
- `knowledge.upload`
|
||||
- `knowledge.download`
|
||||
|
||||
Standardrollen:
|
||||
|
||||
- `administrator`: alle Knowledge-Rechte
|
||||
- `management`: `knowledge.read`, `knowledge.download`
|
||||
- `technician`: `knowledge.read`, `knowledge.create`, `knowledge.update`, `knowledge.upload`, `knowledge.download`
|
||||
- `support`: `knowledge.read`, `knowledge.download`
|
||||
- `guest`: keine Knowledge-Rechte
|
||||
|
||||
Hermes erzwingt diese Rechte serverseitig. Athena nutzt sie nur fuer Navigation und UI.
|
||||
|
||||
### Paperless-Vorbereitung
|
||||
|
||||
Eine echte Paperless-ngx-Integration ist in v0.6.0 nicht aktiv. Dokumente enthalten bereits `paperless_document_id` und `external_url`, damit spaeter ein PaperlessConnector angebunden werden kann. API-Keys oder verpflichtende Paperless-Umgebungsvariablen werden in v0.6.0 nicht eingefuehrt.
|
||||
|
||||
Spaetere Verknuepfungen sind fuer Kunden, Projekte, Tickets und Reparaturen vorgesehen.
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```text
|
||||
|
|
@ -564,6 +1164,33 @@ docker-compose.yml
|
|||
`LOG_LEVEL`
|
||||
: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`.
|
||||
|
||||
`STORAGE_PROVIDER`
|
||||
: Storage Provider. Aktuell produktiv implementiert: `local`.
|
||||
|
||||
`STORAGE_BASE_PATH`
|
||||
: Interner Storage-Basispfad in Hermes. Default `/data/storage`.
|
||||
|
||||
`STORAGE_MAX_UPLOAD_MB`
|
||||
: Maximale Uploadgroesse fuer Storage-Dateien in MB. Default `50`.
|
||||
|
||||
`STORAGE_HOST_PATH`
|
||||
: Docker-Host-Pfad, der nach `/data/storage` gemountet wird. Lokal z. B. `./storage`, auf dem VPS empfohlen `/opt/olympus-storage`.
|
||||
|
||||
`KNOWLEDGE_STORAGE_PATH`
|
||||
: Legacy-Fallback fuer bestehende Knowledge-Dateien. Neue Installationen sollen `STORAGE_*` verwenden.
|
||||
|
||||
`KNOWLEDGE_MAX_UPLOAD_MB`
|
||||
: Legacy-Fallback fuer die maximale Knowledge-Uploadgroesse.
|
||||
|
||||
`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`
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind:
|
|||
- keine `.venv` im Git
|
||||
- keine toten Imports
|
||||
- keine ungenutzten Dateien
|
||||
- keine Debug-Ausgaben wie `console.log`, `alert` oder `confirm`
|
||||
- keine Debug-Ausgaben oder Browser-Dialoge im Anwendungscode
|
||||
|
||||
Standard-Checks:
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ uv run alembic upgrade head
|
|||
- Wiederverwendbare Komponenten bevorzugen.
|
||||
- UI-Zustaende immer abbilden: Loading, Error, Empty State.
|
||||
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
|
||||
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
|
||||
- Keine nativen Browser-Dialoge fuer produktive UI-Flows.
|
||||
- Keine Tokens in Browser-JavaScript speichern.
|
||||
- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet.
|
||||
|
||||
|
|
@ -120,10 +120,12 @@ uv run alembic upgrade head
|
|||
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
|
||||
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
|
||||
- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen.
|
||||
- Dateiablagen laufen ueber `StorageService`; direkte Dateioperationen in Fachservices sind nur mit guter Begruendung zulaessig.
|
||||
- Uploads muessen Dateityp, MIME-Type, Groesse, Dateiname und Storage-Pfad validieren.
|
||||
|
||||
### Allgemein
|
||||
|
||||
- Keine TODOs als Ersatz fuer fertige Implementierung.
|
||||
- Keine Platzhalter-Kommentare als Ersatz fuer fertige Implementierung.
|
||||
- Keine Quickfixes.
|
||||
- Keine Workarounds.
|
||||
- Keine ungeprueften Annahmen bei Auth, Datenbank oder Docker.
|
||||
|
|
@ -156,6 +158,7 @@ Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
|
|||
Beispiele fuer spaetere Module:
|
||||
|
||||
- Kunden
|
||||
- Reparaturen
|
||||
- Projekte
|
||||
- Lieferanten
|
||||
- Kontakte
|
||||
|
|
@ -220,10 +223,64 @@ Importmodi:
|
|||
|
||||
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
|
||||
|
||||
### Wissensdatenbank
|
||||
|
||||
Das Knowledge-Modul folgt dem bestehenden Modulstandard:
|
||||
|
||||
- SQLAlchemy-Modell in `backend/hermes/app/models/knowledge.py`
|
||||
- Pydantic-Schemas in `backend/hermes/app/schemas/knowledge.py`
|
||||
- Repository in `backend/hermes/app/repositories/knowledge_repository.py`
|
||||
- Service-Logik in `backend/hermes/app/services/knowledge_service.py`
|
||||
- FastAPI-Router in `backend/hermes/app/api/knowledge.py`
|
||||
- Athena-BFF unter `/api/knowledge/...`
|
||||
- Frontend-Seiten unter `/knowledge`
|
||||
|
||||
Upload-Regeln:
|
||||
|
||||
- Browser sendet Dateien nur an Athena.
|
||||
- Athena leitet FormData serverseitig an Hermes weiter.
|
||||
- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad.
|
||||
- Lokale Dateien liegen ueber Docker unter `STORAGE_BASE_PATH`; alte Knowledge-Pfade bleiben nur als Legacy-Fallback erhalten.
|
||||
- Dokumentuploads benoetigen Hersteller, Geraet, Dokumenttyp und Datei.
|
||||
- Ein Geraet muss serverseitig zum ausgewaehlten Hersteller gehoeren.
|
||||
- Fehlende Upload-Voraussetzungen muessen im Formular sichtbar sein und duerfen keinen Upload starten.
|
||||
- Audit Logs duerfen keine Datei-Inhalte enthalten.
|
||||
- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen.
|
||||
|
||||
Workflow-Regel:
|
||||
|
||||
```text
|
||||
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
|
||||
```
|
||||
|
||||
Knowledge-Listen sollen `EmptyState` nutzen, wenn Daten fehlen oder Suche/Filter keinen Treffer liefern.
|
||||
|
||||
Paperless-ngx ist nur vorbereitet. `paperless_document_id` und `external_url` duerfen gepflegt werden, aber es werden keine Paperless-Secrets oder API-Keys eingefuehrt.
|
||||
|
||||
### Storage
|
||||
|
||||
Neue Dateiablagen muessen das zentrale Storage Framework verwenden.
|
||||
|
||||
Regeln:
|
||||
|
||||
- Keine unvalidierten Pfade aus Benutzereingaben zusammensetzen.
|
||||
- Keine absoluten User-Pfade akzeptieren.
|
||||
- Keine Path-Traversal-Moeglichkeiten zulassen.
|
||||
- Originaldateinamen und gespeicherte Dateinamen fachlich unterscheiden.
|
||||
- SHA256 fuer gespeicherte Dateien berechnen, sofern das Modul Datei-Metadaten persistiert.
|
||||
- Keine Datei-Inhalte loggen.
|
||||
- Bestehende Dateien nicht automatisch verschieben oder loeschen.
|
||||
|
||||
Der lokale Provider nutzt `STORAGE_BASE_PATH`; Docker mountet den Host-Pfad aus `STORAGE_HOST_PATH` nach `/data/storage`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
|
||||
|
||||
Dateien benoetigen einen persistenten Storage-Mount. Vor produktiven Deployments muessen `STORAGE_PROVIDER`, `STORAGE_BASE_PATH`, `STORAGE_MAX_UPLOAD_MB` und `STORAGE_HOST_PATH` bewusst gesetzt oder die Defaults akzeptiert werden.
|
||||
|
||||
Die Skripte unter `scripts/` sind die bevorzugte Grundlage fuer Migration, Healthcheck, Deploy, Backup und Restore.
|
||||
|
||||
### Neue Permissions
|
||||
|
||||
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
||||
|
|
@ -274,6 +331,9 @@ Vor Merge pruefen:
|
|||
- Audit Logs fuer relevante Aenderungen vorhanden
|
||||
- API-Fehlerantworten konsistent
|
||||
- Toasts fuer mutierende UI-Aktionen vorhanden
|
||||
- Dateiablagen verwenden `StorageService`
|
||||
- Storage-Pfade sind gegen Path Traversal geschuetzt
|
||||
- Backup-/Restore-Auswirkungen fuer Dateiablagen dokumentiert
|
||||
|
||||
## Migrationsregeln
|
||||
|
||||
|
|
|
|||
418
README-DEV.md
Normal file
418
README-DEV.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# Olympus CRM Entwicklungssetup
|
||||
|
||||
Dieses Dokument beschreibt das lokale Setup fuer neue Rechner und parallele Entwicklung auf mehreren Macs.
|
||||
|
||||
## Setup auf neuem Mac
|
||||
|
||||
Voraussetzungen:
|
||||
|
||||
- Git
|
||||
- Docker Desktop
|
||||
- Node.js passend zu Athena
|
||||
- Python/uv fuer lokale Hermes-Checks
|
||||
|
||||
Repository klonen:
|
||||
|
||||
```bash
|
||||
git clone <repository-url> Olympus
|
||||
cd Olympus
|
||||
```
|
||||
|
||||
Umgebung anlegen:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Wichtige lokale Werte:
|
||||
|
||||
```env
|
||||
AUTH_COOKIE_SECURE=false
|
||||
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
|
||||
STORAGE_PROVIDER=local
|
||||
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
|
||||
|
||||
Das gemeinsame Compose-File nutzt ein externes Docker-Netzwerk:
|
||||
|
||||
```bash
|
||||
docker network create olympus-network
|
||||
```
|
||||
|
||||
Wenn das Netzwerk bereits existiert, meldet Docker das nur als Hinweis.
|
||||
|
||||
## Docker Compose Start
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Hermes ist im gemeinsamen Stack nur intern im Docker-Netzwerk erreichbar. Athena ist lokal ueber den Browser erreichbar:
|
||||
|
||||
- Athena: `http://localhost:3001`
|
||||
- Hermes intern: `http://hermes:8000`
|
||||
|
||||
Der Browser spricht nicht direkt mit Hermes.
|
||||
|
||||
## Migrationen
|
||||
|
||||
Migrationen ausfuehren:
|
||||
|
||||
```bash
|
||||
scripts/migrate.sh
|
||||
```
|
||||
|
||||
Alternativ lokal im Backend:
|
||||
|
||||
```bash
|
||||
cd backend/hermes
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
## Initial Admin
|
||||
|
||||
Fuer neue Installationen ohne aktive Benutzer kann Hermes beim Startup einen initialen Admin anlegen.
|
||||
|
||||
```env
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_EMAIL=admin@example.local
|
||||
INITIAL_ADMIN_PASSWORD=<lokales-passwort>
|
||||
INITIAL_ADMIN_FIRST_NAME=
|
||||
INITIAL_ADMIN_LAST_NAME=
|
||||
```
|
||||
|
||||
Diese Werte nur lokal oder sicher auf dem Zielsystem setzen. Nach dem ersten produktiven Login sollte das Passwort geaendert und die Bootstrap-Variablen wieder entfernt werden.
|
||||
|
||||
## Login
|
||||
|
||||
Nach Start und Migration:
|
||||
|
||||
1. Browser auf `http://localhost:3001` oeffnen.
|
||||
2. Mit dem initialen Admin oder einem bestehenden Benutzer anmelden.
|
||||
3. Athena setzt das HttpOnly-Cookie. Hermes setzt keine Browser-Cookies.
|
||||
|
||||
## Storage
|
||||
|
||||
Neue Dateiablagen laufen ueber das Storage Framework.
|
||||
|
||||
Lokaler Standard:
|
||||
|
||||
```env
|
||||
STORAGE_HOST_PATH=./storage
|
||||
STORAGE_BASE_PATH=/data/storage
|
||||
```
|
||||
|
||||
VPS-Empfehlung:
|
||||
|
||||
```env
|
||||
STORAGE_HOST_PATH=/opt/olympus-storage
|
||||
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:
|
||||
|
||||
```text
|
||||
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
|
||||
```
|
||||
|
||||
Hermes legt beim Startup Standard-Hersteller an, wenn sie noch fehlen. Danach koennen Geraete angelegt werden. Dokumentuploads sind erst sinnvoll, wenn Hersteller und Geraet vorhanden sind; Athena blockiert unvollstaendige Uploads direkt im Formular.
|
||||
|
||||
## Reparaturmanagement
|
||||
|
||||
Das Reparaturmodul ist ab v0.8.0 aktiv.
|
||||
|
||||
Lokaler Ablauf:
|
||||
|
||||
```bash
|
||||
cd backend/hermes
|
||||
SECRET_KEY=local-check uv run alembic upgrade head
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Ab v0.8.1 zeigt Athena deutsche Statuslabels, waehrend die API-Statuscodes englisch und stabil bleiben. Statuswechsel werden als Timeline mit Benutzer, Datum und Notiz angezeigt.
|
||||
|
||||
Statuslink-Konzept:
|
||||
|
||||
- Interne Verwaltung ueber `GET|POST|DELETE /repairs/{id}/public-link`.
|
||||
- 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.
|
||||
|
||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||
|
||||
- `/status/<token>`
|
||||
- `/portal/login`
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
Hermes restartet wegen fehlender Migration:
|
||||
|
||||
```bash
|
||||
scripts/migrate.sh
|
||||
docker compose restart hermes
|
||||
```
|
||||
|
||||
`SECRET_KEY` mit `$` wird falsch interpretiert:
|
||||
|
||||
- Wert in `.env` korrekt escapen oder quoten.
|
||||
- Keine Secrets in Commit oder Logs schreiben.
|
||||
|
||||
Docker Container Name Conflict:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
Danach blockierenden Altcontainer gezielt entfernen, wenn er wirklich nicht mehr gebraucht wird.
|
||||
|
||||
External network fehlt:
|
||||
|
||||
```bash
|
||||
docker network create olympus-network
|
||||
```
|
||||
|
||||
DB leer oder Admin fehlt:
|
||||
|
||||
- `INITIAL_ADMIN_*` Werte setzen.
|
||||
- `scripts/migrate.sh` ausfuehren.
|
||||
- `docker compose restart hermes`.
|
||||
|
||||
Athena nicht erreichbar:
|
||||
|
||||
- `docker compose ps` pruefen.
|
||||
- Port `3001` auf dem Rechner pruefen.
|
||||
- `scripts/healthcheck.sh` ausfuehren.
|
||||
|
||||
## MacBook und Mac mini parallel
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- `.env` pro Rechner lokal pflegen und nicht committen.
|
||||
- `STORAGE_HOST_PATH` pro Rechner bewusst setzen.
|
||||
- Datenbank- und Storage-Backups nicht ungeprueft zwischen Rechnern ueberschreiben.
|
||||
- Vor Branch-Wechseln Migrationen pruefen.
|
||||
- Bei paralleler Arbeit keine Docker-Volumes loeschen, solange ungesicherte Uploads existieren.
|
||||
|
||||
## Qualitaetschecks
|
||||
|
||||
```bash
|
||||
python3 -m compileall backend/hermes/app
|
||||
cd frontend/athena
|
||||
npm run lint
|
||||
npx next build --webpack
|
||||
```
|
||||
|
||||
Skripte pruefen:
|
||||
|
||||
```bash
|
||||
bash -n scripts/*.sh
|
||||
```
|
||||
192
ROADMAP.md
192
ROADMAP.md
|
|
@ -44,15 +44,181 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Excel-freundliche CSV-Vorlage ueber Athena
|
||||
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten
|
||||
|
||||
## v0.6.0 - Projektmodul, geplant
|
||||
## v0.6.0 - Wissensdatenbank / Service Library
|
||||
|
||||
- Projektstammdaten
|
||||
- Projektstatus und Verantwortliche
|
||||
- Verknuepfung mit Kunden
|
||||
- RBAC-Permissions fuer Projekte
|
||||
- Audit Logs fuer Projektaktionen
|
||||
- Herstellerverwaltung
|
||||
- Geraete- und Modellverwaltung
|
||||
- Dokumentverwaltung mit lokalem Upload und Download
|
||||
- Reparaturhinweise und Knowledge Notes
|
||||
- Suche ueber Hersteller, Modelle, Dokumente und Notizen
|
||||
- RBAC-Permissions fuer Knowledge-Lesen, Upload und Download
|
||||
- Paperless-ngx vorbereitet ueber `paperless_document_id` und `external_url`
|
||||
- Persistentes Docker-Volume fuer Knowledge-Dateien
|
||||
|
||||
## v0.7.0 - Tickets, geplant
|
||||
## v0.7.0 - Storage Framework und Deployment-Grundlage
|
||||
|
||||
- Zentrales Storage Framework in Hermes
|
||||
- LocalDisk Storage Provider
|
||||
- Knowledge-Dateilogik ueber StorageService
|
||||
- Storage-Konfiguration fuer lokale Entwicklung und VPS
|
||||
- Empfohlener VPS-Pfad `/opt/olympus-storage`
|
||||
- Deployment-, Migrations-, Healthcheck-, Backup- und Restore-Skripte
|
||||
- README-DEV fuer mehrere Entwicklungsrechner
|
||||
|
||||
## v0.7.1 - Knowledge UX und Workflow
|
||||
|
||||
- Gefuehrter Workflow Hersteller -> Geraet -> Dokument -> Notiz
|
||||
- Dokumentupload erst nach Hersteller- und Geraeteanlage
|
||||
- Inline-Validierung fuer Upload-Pflichtfelder
|
||||
- Professionelle Empty States fuer Knowledge-Listen und Suchen
|
||||
- Idempotentes Standard-Hersteller-Seeding
|
||||
- Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen
|
||||
|
||||
## v0.8.0 - Reparaturmanagement
|
||||
|
||||
- Reparaturstammdaten und Werkstattvorgaenge
|
||||
- Reparaturnummern im Format `R<jahr>-000001`
|
||||
- Statusmodell und Statushistorie
|
||||
- Website Repair Intake API vorbereitet
|
||||
- RBAC-Permissions fuer Reparaturen
|
||||
- Audit Logs und Activity Feed fuer Reparaturaktionen
|
||||
- Dashboard-Kennzahlen fuer Reparaturen
|
||||
- Athena-BFF-Routen und Reparatur-UI
|
||||
|
||||
## v0.8.1 - Reparaturstatus und Kundenkommunikation
|
||||
|
||||
- Deutsche Statuslabels in Athena bei stabilen englischen API-Statuscodes
|
||||
- Werkstattgerechte Status-Timeline auf der Reparaturdetailseite
|
||||
- Sicheres Public-Token-Modell fuer spaetere Statuslinks
|
||||
- Oeffentlicher Hermes-Endpunkt `GET /public/repairs/status/{token}` mit minimalen Kundendaten
|
||||
- Interne Statuslink-Verwaltung in Athena
|
||||
- E-Mail-Vorlagen und `repair_notification_events` vorbereitet
|
||||
- Keine Kundenlogin-Funktion und keine Aenderungen an der oeffentlichen Website
|
||||
|
||||
## v0.8.2 - Website-Statusintegration, geplant
|
||||
|
||||
- `/status/<token>` als spaeterer Einstieg fuer Statusabfragen auf der Website
|
||||
- Website ruft Statusdaten serverseitig oder ueber einen eigenen BFF ab
|
||||
- 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
|
||||
|
||||
- `/portal/login` fuer spaeteren Kundenlogin
|
||||
- Separates Authentifizierungsmodell fuer Kunden
|
||||
- Keine Vermischung mit internen Olympus-Benutzern
|
||||
|
||||
## v0.9.3 - Tickets, geplant
|
||||
|
||||
- Ticketverwaltung
|
||||
- Status- und Prioritaetsmodell
|
||||
|
|
@ -60,15 +226,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- RBAC-Permissions fuer Tickets
|
||||
- Audit Logs fuer Ticketaktionen
|
||||
|
||||
## v0.8.0 - Kalender und Aufgaben, geplant
|
||||
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
|
||||
|
||||
- Paperless-ngx Connector fuer Wissensdokumente
|
||||
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
|
||||
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
||||
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
||||
|
||||
## v1.0.0 - Kalender, Aufgaben und produktiver Beta-Release
|
||||
|
||||
- Aufgabenverwaltung
|
||||
- Kalenderansicht
|
||||
- Faelligkeiten und Verantwortlichkeiten
|
||||
- Verknuepfung mit Kunden, Projekten und Tickets
|
||||
|
||||
## v1.0.0 - Produktiver Beta-Release
|
||||
|
||||
- Gehaertete Produktionskonfiguration
|
||||
- Vollstaendige Migrationsstrategie
|
||||
- Deployment-Dokumentation
|
||||
|
|
|
|||
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
|
|
@ -13,3 +13,26 @@ INITIAL_ADMIN_EMAIL=
|
|||
INITIAL_ADMIN_PASSWORD=
|
||||
INITIAL_ADMIN_FIRST_NAME=
|
||||
INITIAL_ADMIN_LAST_NAME=
|
||||
STORAGE_PROVIDER=local
|
||||
STORAGE_BASE_PATH=/data/storage
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ from alembic import context
|
|||
from app.core.config import settings
|
||||
from app.db.database import Base
|
||||
import app.models.user
|
||||
import app.models.customer
|
||||
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
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,58 @@
|
|||
"""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")
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""add repair estimate revoke permission
|
||||
|
||||
Revision ID: a7c3e9d4b821
|
||||
Revises: f4a9c2d7e118
|
||||
Create Date: 2026-07-05 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "a7c3e9d4b821"
|
||||
down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PERMISSION_NAME = "repair_estimates.revoke"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (
|
||||
:name,
|
||||
'Kostenvoranschläge zurücknehmen',
|
||||
'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
|
||||
'repair_estimates'
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=PERMISSION_NAME)
|
||||
)
|
||||
for role_name in ("administrator", "management"):
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=PERMISSION_NAME)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE name = :permission_name
|
||||
)
|
||||
"""
|
||||
).bindparams(permission_name=PERMISSION_NAME)
|
||||
)
|
||||
op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"""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")
|
||||
219
backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py
Normal file
219
backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""create knowledge module
|
||||
|
||||
Revision ID: b6f3d8c1a920
|
||||
Revises: a5e4b7c9d012
|
||||
Create Date: 2026-07-03 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "b6f3d8c1a920"
|
||||
down_revision: Union[str, Sequence[str], None] = "a5e4b7c9d012"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
KNOWLEDGE_PERMISSIONS = [
|
||||
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
|
||||
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
|
||||
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
|
||||
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
|
||||
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
|
||||
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
|
||||
]
|
||||
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"administrator": [permission[0] for permission in KNOWLEDGE_PERMISSIONS],
|
||||
"management": ["knowledge.read", "knowledge.download"],
|
||||
"technician": ["knowledge.read", "knowledge.create", "knowledge.update", "knowledge.upload", "knowledge.download"],
|
||||
"support": ["knowledge.read", "knowledge.download"],
|
||||
}
|
||||
|
||||
|
||||
def insert_permission(name: str, display_name: str, description: str, module: str) -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
SELECT :name, :display_name, :description, :module
|
||||
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = :name)
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
|
||||
def grant_permission(role_name: str, permission_name: str) -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.name = :permission_name
|
||||
WHERE roles.name = :role_name
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions
|
||||
WHERE role_permissions.role_id = roles.id
|
||||
AND role_permissions.permission_id = permissions.id
|
||||
)
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"knowledge_manufacturers",
|
||||
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("website", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("notes", sa.Text(), server_default="", 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"),
|
||||
)
|
||||
op.create_index(op.f("ix_knowledge_manufacturers_name"), "knowledge_manufacturers", ["name"], unique=True)
|
||||
op.create_index(op.f("ix_knowledge_manufacturers_slug"), "knowledge_manufacturers", ["slug"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"knowledge_categories",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("slug", sa.String(length=140), nullable=False),
|
||||
sa.Column("description", sa.Text(), server_default="", 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"),
|
||||
)
|
||||
op.create_index(op.f("ix_knowledge_categories_name"), "knowledge_categories", ["name"], unique=True)
|
||||
op.create_index(op.f("ix_knowledge_categories_slug"), "knowledge_categories", ["slug"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"knowledge_devices",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=180), nullable=False),
|
||||
sa.Column("slug", sa.String(length=200), nullable=False),
|
||||
sa.Column("model_number", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("device_type", sa.String(length=80), server_default="", nullable=False),
|
||||
sa.Column("frequency_range", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("production_year_from", sa.Integer(), nullable=True),
|
||||
sa.Column("production_year_to", sa.Integer(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), server_default="", 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(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
|
||||
)
|
||||
op.create_index(op.f("ix_knowledge_devices_manufacturer_id"), "knowledge_devices", ["manufacturer_id"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_devices_name"), "knowledge_devices", ["name"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_devices_slug"), "knowledge_devices", ["slug"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_devices_model_number"), "knowledge_devices", ["model_number"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_devices_device_type"), "knowledge_devices", ["device_type"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"knowledge_documents",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("device_id", sa.Integer(), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("slug", sa.String(length=280), nullable=False),
|
||||
sa.Column("document_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("language", sa.String(length=20), server_default="de", nullable=False),
|
||||
sa.Column("file_name", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("file_path", sa.String(length=500), server_default="", nullable=False),
|
||||
sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("file_size", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False),
|
||||
sa.Column("external_url", sa.String(length=500), server_default="", nullable=False),
|
||||
sa.Column("paperless_document_id", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("description", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("tags", sa.JSON(), server_default="[]", 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(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_knowledge_documents_manufacturer_id"), "knowledge_documents", ["manufacturer_id"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_device_id"), "knowledge_documents", ["device_id"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_title"), "knowledge_documents", ["title"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_slug"), "knowledge_documents", ["slug"], unique=True)
|
||||
op.create_index(op.f("ix_knowledge_documents_document_type"), "knowledge_documents", ["document_type"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_language"), "knowledge_documents", ["language"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_checksum_sha256"), "knowledge_documents", ["checksum_sha256"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_documents_paperless_document_id"), "knowledge_documents", ["paperless_document_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"knowledge_notes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=True),
|
||||
sa.Column("device_id", sa.Integer(), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("note_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("severity", sa.String(length=40), server_default="", nullable=False),
|
||||
sa.Column("tags", sa.JSON(), server_default="[]", 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(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_knowledge_notes_manufacturer_id"), "knowledge_notes", ["manufacturer_id"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_notes_device_id"), "knowledge_notes", ["device_id"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_notes_title"), "knowledge_notes", ["title"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_notes_note_type"), "knowledge_notes", ["note_type"], unique=False)
|
||||
op.create_index(op.f("ix_knowledge_notes_severity"), "knowledge_notes", ["severity"], unique=False)
|
||||
|
||||
for permission in KNOWLEDGE_PERMISSIONS:
|
||||
insert_permission(*permission)
|
||||
for role_name, permission_names in ROLE_PERMISSIONS.items():
|
||||
for permission_name in permission_names:
|
||||
grant_permission(role_name, permission_name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
USING permissions
|
||||
WHERE role_permissions.permission_id = permissions.id
|
||||
AND permissions.module = 'knowledge'
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'knowledge'")
|
||||
op.drop_index(op.f("ix_knowledge_notes_severity"), table_name="knowledge_notes")
|
||||
op.drop_index(op.f("ix_knowledge_notes_note_type"), table_name="knowledge_notes")
|
||||
op.drop_index(op.f("ix_knowledge_notes_title"), table_name="knowledge_notes")
|
||||
op.drop_index(op.f("ix_knowledge_notes_device_id"), table_name="knowledge_notes")
|
||||
op.drop_index(op.f("ix_knowledge_notes_manufacturer_id"), table_name="knowledge_notes")
|
||||
op.drop_table("knowledge_notes")
|
||||
op.drop_index(op.f("ix_knowledge_documents_paperless_document_id"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_checksum_sha256"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_language"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_document_type"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_slug"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_title"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_device_id"), table_name="knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_documents_manufacturer_id"), table_name="knowledge_documents")
|
||||
op.drop_table("knowledge_documents")
|
||||
op.drop_index(op.f("ix_knowledge_devices_device_type"), table_name="knowledge_devices")
|
||||
op.drop_index(op.f("ix_knowledge_devices_model_number"), table_name="knowledge_devices")
|
||||
op.drop_index(op.f("ix_knowledge_devices_slug"), table_name="knowledge_devices")
|
||||
op.drop_index(op.f("ix_knowledge_devices_name"), table_name="knowledge_devices")
|
||||
op.drop_index(op.f("ix_knowledge_devices_manufacturer_id"), table_name="knowledge_devices")
|
||||
op.drop_table("knowledge_devices")
|
||||
op.drop_index(op.f("ix_knowledge_categories_slug"), table_name="knowledge_categories")
|
||||
op.drop_index(op.f("ix_knowledge_categories_name"), table_name="knowledge_categories")
|
||||
op.drop_table("knowledge_categories")
|
||||
op.drop_index(op.f("ix_knowledge_manufacturers_slug"), table_name="knowledge_manufacturers")
|
||||
op.drop_index(op.f("ix_knowledge_manufacturers_name"), table_name="knowledge_manufacturers")
|
||||
op.drop_table("knowledge_manufacturers")
|
||||
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal file
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""create repairs module
|
||||
|
||||
Revision ID: c8e2f1a9b704
|
||||
Revises: b6f3d8c1a920
|
||||
Create Date: 2026-07-04 11:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c8e2f1a9b704"
|
||||
down_revision: Union[str, Sequence[str], None] = "b6f3d8c1a920"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
REPAIR_PERMISSIONS = [
|
||||
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
|
||||
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
|
||||
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
|
||||
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
|
||||
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
|
||||
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
||||
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
||||
]
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"administrator": [permission[0] for permission in REPAIR_PERMISSIONS],
|
||||
"management": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update"],
|
||||
"technician": ["repairs.read", "repairs.update", "repairs.status.update"],
|
||||
"support": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update", "repairs.intake"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repairs",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_number", sa.String(length=20), nullable=False),
|
||||
sa.Column("customer_id", sa.Integer(), nullable=True),
|
||||
sa.Column("contact_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("priority", sa.String(length=30), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("source_reference", sa.String(length=120), nullable=True),
|
||||
sa.Column("customer_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("customer_email", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("customer_phone", sa.String(length=80), server_default="", nullable=False),
|
||||
sa.Column("device_manufacturer", sa.String(length=160), nullable=False),
|
||||
sa.Column("device_model", sa.String(length=160), nullable=False),
|
||||
sa.Column("device_serial_number", sa.String(length=160), nullable=True),
|
||||
sa.Column("device_type", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("accessories", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("fault_description", sa.Text(), nullable=False),
|
||||
sa.Column("previous_work", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("device_opened", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("intake_notes", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("diagnosis_notes", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("repair_notes", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("estimate_notes", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("estimated_cost_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), 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.CheckConstraint("status in ('new','accepted','diagnosis','estimate','waiting_for_customer','approved','repair','final_test','ready_for_pickup','shipped','completed','cancelled')", name="ck_repairs_status"),
|
||||
sa.CheckConstraint("priority in ('low','normal','high','urgent')", name="ck_repairs_priority"),
|
||||
sa.CheckConstraint("source in ('manual','website','customer_portal','email','phone')", name="ck_repairs_source"),
|
||||
sa.ForeignKeyConstraint(["contact_id"], ["customer_contacts.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("repair_number"),
|
||||
)
|
||||
op.create_index(op.f("ix_repairs_contact_id"), "repairs", ["contact_id"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_customer_id"), "repairs", ["customer_id"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_customer_name"), "repairs", ["customer_name"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_device_manufacturer"), "repairs", ["device_manufacturer"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_device_model"), "repairs", ["device_model"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_priority"), "repairs", ["priority"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_repair_number"), "repairs", ["repair_number"], unique=True)
|
||||
op.create_index(op.f("ix_repairs_source"), "repairs", ["source"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_source_reference"), "repairs", ["source_reference"], unique=False)
|
||||
op.create_index(op.f("ix_repairs_status"), "repairs", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"repair_status_history",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("old_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("new_status", sa.String(length=50), nullable=False),
|
||||
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(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_status_history_actor_user_id"), "repair_status_history", ["actor_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_status_history_new_status"), "repair_status_history", ["new_status"], unique=False)
|
||||
op.create_index(op.f("ix_repair_status_history_repair_id"), "repair_status_history", ["repair_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"repair_intake_events",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=True),
|
||||
sa.Column("external_source", sa.String(length=80), nullable=False),
|
||||
sa.Column("external_reference", sa.String(length=160), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
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("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_intake_events_external_reference"), "repair_intake_events", ["external_reference"], unique=False)
|
||||
op.create_index(op.f("ix_repair_intake_events_external_source"), "repair_intake_events", ["external_source"], unique=False)
|
||||
op.create_index(op.f("ix_repair_intake_events_repair_id"), "repair_intake_events", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_intake_events_status"), "repair_intake_events", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"repair_documents",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("file_id", sa.Integer(), nullable=True),
|
||||
sa.Column("storage_path", sa.String(length=500), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("document_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_documents_document_type"), "repair_documents", ["document_type"], unique=False)
|
||||
op.create_index(op.f("ix_repair_documents_repair_id"), "repair_documents", ["repair_id"], unique=False)
|
||||
|
||||
for name, display_name, description, module in REPAIR_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)
|
||||
)
|
||||
|
||||
for role_name, permissions in ROLE_PERMISSIONS.items():
|
||||
for permission_name in permissions:
|
||||
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:
|
||||
for permission_name, *_ in REPAIR_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
delete from role_permissions
|
||||
using permissions
|
||||
where role_permissions.permission_id = permissions.id
|
||||
and permissions.name = :permission_name
|
||||
"""
|
||||
).bindparams(permission_name=permission_name)
|
||||
)
|
||||
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=permission_name))
|
||||
|
||||
op.drop_index(op.f("ix_repair_documents_repair_id"), table_name="repair_documents")
|
||||
op.drop_index(op.f("ix_repair_documents_document_type"), table_name="repair_documents")
|
||||
op.drop_table("repair_documents")
|
||||
op.drop_index(op.f("ix_repair_intake_events_status"), table_name="repair_intake_events")
|
||||
op.drop_index(op.f("ix_repair_intake_events_repair_id"), table_name="repair_intake_events")
|
||||
op.drop_index(op.f("ix_repair_intake_events_external_source"), table_name="repair_intake_events")
|
||||
op.drop_index(op.f("ix_repair_intake_events_external_reference"), table_name="repair_intake_events")
|
||||
op.drop_table("repair_intake_events")
|
||||
op.drop_index(op.f("ix_repair_status_history_repair_id"), table_name="repair_status_history")
|
||||
op.drop_index(op.f("ix_repair_status_history_new_status"), table_name="repair_status_history")
|
||||
op.drop_index(op.f("ix_repair_status_history_actor_user_id"), table_name="repair_status_history")
|
||||
op.drop_table("repair_status_history")
|
||||
op.drop_index(op.f("ix_repairs_status"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_source_reference"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_source"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_repair_number"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_priority"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_device_model"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_device_manufacturer"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_customer_name"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_customer_id"), table_name="repairs")
|
||||
op.drop_index(op.f("ix_repairs_contact_id"), table_name="repairs")
|
||||
op.drop_table("repairs")
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""add lexware foundation
|
||||
|
||||
Revision ID: c9d4e5f6a7b8
|
||||
Revises: a7c3e9d4b821
|
||||
Create Date: 2026-07-05 16:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c9d4e5f6a7b8"
|
||||
down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEXWARE_PERMISSIONS = [
|
||||
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
||||
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
||||
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lexware_sync_records",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("entity_id", sa.Integer(), nullable=False),
|
||||
sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
|
||||
sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
|
||||
sa.Column("payload_summary", sa.Text(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
for name, display_name, description, module in LEXWARE_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (:name, :display_name, :description, :module)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
"administrator": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||
"management": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||
"support": ["lexware.read"],
|
||||
}
|
||||
for role_name, permission_names in role_permissions.items():
|
||||
for permission_name in permission_names:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'lexware'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'lexware'")
|
||||
op.drop_column("repair_estimates", "lexware_synced_at")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_status")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_number")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_id")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
|
||||
op.drop_table("lexware_sync_records")
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""add accounting export status
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c9d4e5f6a7b8
|
||||
Create Date: 2026-07-05 17:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d2e3f4a5b6c7"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
|
||||
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimates_accounting_transferred_by_user_id",
|
||||
"repair_estimates",
|
||||
"users",
|
||||
["accounting_transferred_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_at")
|
||||
op.drop_column("repair_estimates", "accounting_note")
|
||||
op.drop_column("repair_estimates", "accounting_export_status")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
|
||||
op.drop_column("lexware_sync_records", "transferred_by_user_id")
|
||||
op.drop_column("lexware_sync_records", "transferred_at")
|
||||
op.drop_column("lexware_sync_records", "accounting_note")
|
||||
op.drop_column("lexware_sync_records", "export_status")
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
"""add repair public links and notifications
|
||||
|
||||
Revision ID: d4f6a2b8c901
|
||||
Revises: c8e2f1a9b704
|
||||
Create Date: 2026-07-04 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d4f6a2b8c901"
|
||||
down_revision: Union[str, Sequence[str], None] = "c8e2f1a9b704"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PUBLIC_LINK_PERMISSION = (
|
||||
"repairs.public_link.manage",
|
||||
"Reparatur-Statuslinks verwalten",
|
||||
"Öffentliche Statuslinks für Reparaturen verwalten",
|
||||
"repairs",
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"administrator": ["repairs.public_link.manage"],
|
||||
"management": ["repairs.public_link.manage"],
|
||||
"support": ["repairs.public_link.manage"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repair_public_access_tokens",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("token_hint", sa.String(length=16), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_is_active"), "repair_public_access_tokens", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_repair_id"), "repair_public_access_tokens", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_token_hash"), "repair_public_access_tokens", ["token_hash"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"repair_notification_events",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("channel", sa.String(length=40), nullable=False),
|
||||
sa.Column("recipient", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
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("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("status in ('pending','sent','failed','skipped')", name="ck_repair_notification_events_status"),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_notification_events_channel"), "repair_notification_events", ["channel"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_event_type"), "repair_notification_events", ["event_type"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_repair_id"), "repair_notification_events", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_status"), "repair_notification_events", ["status"], unique=False)
|
||||
|
||||
name, display_name, description, module = PUBLIC_LINK_PERMISSION
|
||||
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)
|
||||
)
|
||||
|
||||
for role_name, permissions in ROLE_PERMISSIONS.items():
|
||||
for permission_name in permissions:
|
||||
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
|
||||
using permissions
|
||||
where role_permissions.permission_id = permissions.id
|
||||
and permissions.name = :permission_name
|
||||
"""
|
||||
).bindparams(permission_name=PUBLIC_LINK_PERMISSION[0])
|
||||
)
|
||||
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=PUBLIC_LINK_PERMISSION[0]))
|
||||
|
||||
op.drop_index(op.f("ix_repair_notification_events_status"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_repair_id"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_event_type"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_channel"), table_name="repair_notification_events")
|
||||
op.drop_table("repair_notification_events")
|
||||
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_token_hash"), table_name="repair_public_access_tokens")
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_repair_id"), table_name="repair_public_access_tokens")
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_is_active"), table_name="repair_public_access_tokens")
|
||||
op.drop_table("repair_public_access_tokens")
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""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")
|
||||
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""create inventory
|
||||
|
||||
Revision ID: ef8a2d5c9017
|
||||
Revises: b4e8f1c7a205
|
||||
Create Date: 2026-07-05 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ef8a2d5c9017"
|
||||
down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
INVENTORY_PERMISSIONS = [
|
||||
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
|
||||
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
|
||||
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
||||
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
||||
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"inventory_categories",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("slug", sa.String(length=180), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
|
||||
op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_locations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_suppliers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=180), nullable=False),
|
||||
sa.Column("contact_name", sa.String(length=180), nullable=True),
|
||||
sa.Column("email", sa.String(length=255), nullable=True),
|
||||
sa.Column("phone", sa.String(length=80), nullable=True),
|
||||
sa.Column("website", sa.String(length=255), nullable=True),
|
||||
sa.Column("customer_number", sa.String(length=120), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("sku", sa.String(length=40), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.Column("manufacturer", sa.String(length=180), nullable=True),
|
||||
sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
|
||||
sa.Column("compatible_devices", sa.Text(), nullable=True),
|
||||
sa.Column("location_id", sa.Integer(), nullable=True),
|
||||
sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
|
||||
sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("selling_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
|
||||
op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"inventory_stock_movements",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("movement_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("quantity", sa.Integer(), nullable=False),
|
||||
sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("reference_type", sa.String(length=80), nullable=True),
|
||||
sa.Column("reference_id", sa.Integer(), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
|
||||
|
||||
for name, display_name, description, module in INVENTORY_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (:name, :display_name, :description, :module)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
"administrator": [item[0] for item in INVENTORY_PERMISSIONS],
|
||||
"management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||
"technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
|
||||
"support": ["inventory.read"],
|
||||
"warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||
}
|
||||
for role_name, permission_names in role_permissions.items():
|
||||
for permission_name in permission_names:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'inventory'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
|
||||
op.drop_table("inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
|
||||
op.drop_table("inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
|
||||
op.drop_table("inventory_suppliers")
|
||||
op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
|
||||
op.drop_table("inventory_locations")
|
||||
op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
|
||||
op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
|
||||
op.drop_table("inventory_categories")
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"""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")
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"""extend repair estimate items inventory
|
||||
|
||||
Revision ID: f4a9c2d7e118
|
||||
Revises: ef8a2d5c9017
|
||||
Create Date: 2026-07-05 13:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f4a9c2d7e118"
|
||||
down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
|
||||
op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimate_items_inventory_item_id",
|
||||
"repair_estimate_items",
|
||||
"inventory_items",
|
||||
["inventory_item_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_name")
|
||||
op.drop_column("repair_estimate_items", "inventory_item_id")
|
||||
Binary file not shown.
|
|
@ -54,6 +54,20 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
|||
return "customers.read" in permissions
|
||||
if action.startswith("roles."):
|
||||
return "roles.read" in permissions
|
||||
if action.startswith("knowledge."):
|
||||
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."):
|
||||
|
|
|
|||
150
backend/hermes/app/api/backups.py
Normal file
150
backend/hermes/app/api/backups.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.api_response import ApiSuccess
|
||||
from app.schemas.backup import BackupRestoreRequest
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
router = APIRouter(prefix="/backups", tags=["Backups"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiSuccess)
|
||||
def list_backups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.read")),
|
||||
):
|
||||
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
|
||||
|
||||
|
||||
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
|
||||
def create_backup(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.create")),
|
||||
):
|
||||
backup = BackupService.create_backup(actor=current_user)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.create",
|
||||
entity_type="backup",
|
||||
entity_label=backup.filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={
|
||||
"filename": backup.filename,
|
||||
"size_bytes": backup.size_bytes,
|
||||
"app_version": backup.app_version,
|
||||
},
|
||||
)
|
||||
return ApiSuccess(data=backup, message="Backup erstellt")
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.download")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.download",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
|
||||
)
|
||||
return FileResponse(path=path, media_type="application/zip", filename=path.name)
|
||||
|
||||
|
||||
@router.delete("/{filename}", response_model=ApiSuccess)
|
||||
def delete_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.delete")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
size_bytes = path.stat().st_size
|
||||
BackupService.delete_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.delete",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": size_bytes},
|
||||
)
|
||||
return ApiSuccess(message="Backup geloescht")
|
||||
|
||||
|
||||
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
|
||||
def validate_backup_restore(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
validation = BackupService.validate_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.validate",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
|
||||
)
|
||||
return ApiSuccess(data=validation, message=validation.message)
|
||||
|
||||
|
||||
@router.post("/{filename}/restore", response_model=ApiSuccess)
|
||||
def restore_backup(
|
||||
filename: str,
|
||||
payload: BackupRestoreRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_started",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
try:
|
||||
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_failed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
raise
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_completed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
return ApiSuccess(data=validation, message="Restore abgeschlossen")
|
||||
|
|
@ -4,13 +4,21 @@ 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
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||
from app.services.backup_service import BackupService
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -30,6 +38,9 @@ def get_dashboard_summary(
|
|||
latest_customers = []
|
||||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
inventory: list[MetricCard] = []
|
||||
system_status: list[SystemStatusItem] = []
|
||||
|
||||
if "customers.read" in permissions:
|
||||
customers = [
|
||||
|
|
@ -54,6 +65,126 @@ def get_dashboard_summary(
|
|||
)
|
||||
]
|
||||
|
||||
if "repairs.read" in permissions:
|
||||
repairs = [
|
||||
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
|
||||
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
|
||||
MetricCard(label="Wartet auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
|
||||
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(
|
||||
|
|
@ -61,6 +192,9 @@ def get_dashboard_summary(
|
|||
latest_customers=latest_customers,
|
||||
users=users,
|
||||
roles=roles,
|
||||
repairs=repairs,
|
||||
inventory=inventory,
|
||||
system_status=system_status,
|
||||
activities=EmptyWidget(
|
||||
title="Letzte Aktivitäten",
|
||||
message="Noch keine Aktivitäten vorhanden.",
|
||||
|
|
|
|||
319
backend/hermes/app/api/inventory.py
Normal file
319
backend/hermes/app/api/inventory.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.schemas.inventory import (
|
||||
InventoryCategoryPayload,
|
||||
InventoryCategoryResponse,
|
||||
InventoryItemCreate,
|
||||
InventoryItemListResponse,
|
||||
InventoryItemResponse,
|
||||
InventoryItemUpdate,
|
||||
InventoryLocationPayload,
|
||||
InventoryLocationResponse,
|
||||
InventoryStockAction,
|
||||
InventoryStockMovementResponse,
|
||||
InventorySupplierPayload,
|
||||
InventorySupplierResponse,
|
||||
)
|
||||
from app.services.inventory_service import InventoryService
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["Inventory"])
|
||||
|
||||
|
||||
def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
|
||||
item = InventoryRepository.get_item(db, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
|
||||
category = InventoryRepository.get_category(db, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
|
||||
return category
|
||||
|
||||
|
||||
def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
|
||||
location = InventoryRepository.get_location(db, location_id)
|
||||
if location is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
|
||||
return location
|
||||
|
||||
|
||||
def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
|
||||
supplier = InventoryRepository.get_supplier(db, supplier_id)
|
||||
if supplier is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
|
||||
return supplier
|
||||
|
||||
|
||||
@router.get("/items", response_model=InventoryItemListResponse)
|
||||
def list_items(
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
supplier_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
low_stock: bool | None = None,
|
||||
active: bool | None = True,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
items, total = InventoryRepository.list_items(
|
||||
db,
|
||||
q=q,
|
||||
category_id=category_id,
|
||||
supplier_id=supplier_id,
|
||||
location_id=location_id,
|
||||
low_stock=low_stock,
|
||||
active=active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/items/search", response_model=list[InventoryItemResponse])
|
||||
def search_items(
|
||||
q: str | None = None,
|
||||
category: int | None = None,
|
||||
manufacturer: str | None = None,
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
|
||||
|
||||
|
||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_item(
|
||||
payload: InventoryItemCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.create")),
|
||||
):
|
||||
return InventoryService.create_item(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
def get_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return get_item_or_404(db, item_id)
|
||||
|
||||
|
||||
@router.put("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
def update_item(
|
||||
item_id: int,
|
||||
payload: InventoryItemUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.update")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
|
||||
def delete_item(
|
||||
item_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.delete")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.delete_item(db, item, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
|
||||
def adjust_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.adjust")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
|
||||
def reserve_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
|
||||
def release_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
|
||||
def consume_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.consume")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
|
||||
def list_movements(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
get_item_or_404(db, item_id)
|
||||
return InventoryRepository.list_movements(db, item_id)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[InventoryCategoryResponse])
|
||||
def list_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_categories(db)
|
||||
|
||||
|
||||
@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(
|
||||
payload: InventoryCategoryPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_category(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
|
||||
def update_category(
|
||||
category_id: int,
|
||||
payload: InventoryCategoryPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
category = get_category_or_404(db, category_id)
|
||||
return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
category = get_category_or_404(db, category_id)
|
||||
InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[InventoryLocationResponse])
|
||||
def list_locations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_locations(db)
|
||||
|
||||
|
||||
@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_location(
|
||||
payload: InventoryLocationPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_location(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
|
||||
def update_location(
|
||||
location_id: int,
|
||||
payload: InventoryLocationPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
location = get_location_or_404(db, location_id)
|
||||
return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_location(
|
||||
location_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
location = get_location_or_404(db, location_id)
|
||||
InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/suppliers", response_model=list[InventorySupplierResponse])
|
||||
def list_suppliers(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_suppliers(db)
|
||||
|
||||
|
||||
@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_supplier(
|
||||
payload: InventorySupplierPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
|
||||
def update_supplier(
|
||||
supplier_id: int,
|
||||
payload: InventorySupplierPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
supplier = get_supplier_or_404(db, supplier_id)
|
||||
return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_supplier(
|
||||
supplier_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
supplier = get_supplier_or_404(db, supplier_id)
|
||||
InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
|
||||
return None
|
||||
334
backend/hermes/app/api/knowledge.py
Normal file
334
backend/hermes/app/api/knowledge.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.repositories.knowledge_repository import KnowledgeRepository
|
||||
from app.schemas.knowledge import (
|
||||
DocumentType,
|
||||
KnowledgeDeviceCreate,
|
||||
KnowledgeDeviceResponse,
|
||||
KnowledgeDeviceUpdate,
|
||||
KnowledgeDocumentCreate,
|
||||
KnowledgeDocumentResponse,
|
||||
KnowledgeDocumentUpdate,
|
||||
KnowledgeManufacturerCreate,
|
||||
KnowledgeManufacturerResponse,
|
||||
KnowledgeManufacturerUpdate,
|
||||
KnowledgeNoteCreate,
|
||||
KnowledgeNoteResponse,
|
||||
KnowledgeNoteUpdate,
|
||||
KnowledgeSearchResult,
|
||||
NoteType,
|
||||
normalize_tags,
|
||||
)
|
||||
from app.services.audit_service import sanitize, write_audit_log
|
||||
from app.services.knowledge_service import KnowledgeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["Knowledge"])
|
||||
|
||||
|
||||
def get_manufacturer_or_404(db: Session, manufacturer_id: int):
|
||||
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
|
||||
if manufacturer is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||
return manufacturer
|
||||
|
||||
|
||||
def get_device_or_404(db: Session, device_id: int):
|
||||
device = KnowledgeRepository.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
return device
|
||||
|
||||
|
||||
def get_document_or_404(db: Session, document_id: int):
|
||||
document = KnowledgeRepository.get_document(db, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
return document
|
||||
|
||||
|
||||
def get_note_or_404(db: Session, note_id: int):
|
||||
note = KnowledgeRepository.get_note(db, note_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden")
|
||||
return note
|
||||
|
||||
|
||||
def delete_or_conflict(db: Session, entity) -> None:
|
||||
try:
|
||||
db.delete(entity)
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag wird noch verwendet") from exc
|
||||
|
||||
|
||||
@router.get("/manufacturers", response_model=list[KnowledgeManufacturerResponse])
|
||||
def list_manufacturers(
|
||||
q: str = "",
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.read")),
|
||||
):
|
||||
logger.info("knowledge.manufacturers.list", extra={"actor_user_id": current_user.id})
|
||||
return KnowledgeRepository.list_manufacturers(db, q=q, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
|
||||
def get_manufacturer(manufacturer_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
|
||||
logger.info("knowledge.manufacturers.detail", extra={"actor_user_id": current_user.id, "target_manufacturer_id": manufacturer_id})
|
||||
return get_manufacturer_or_404(db, manufacturer_id)
|
||||
|
||||
|
||||
@router.post("/manufacturers", response_model=KnowledgeManufacturerResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_manufacturer(
|
||||
payload: KnowledgeManufacturerCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.create")),
|
||||
):
|
||||
manufacturer = KnowledgeService.create_manufacturer(db, payload)
|
||||
write_audit_log(db, action="knowledge.manufacturers.create", entity_type="knowledge_manufacturers", entity_id=manufacturer.id, entity_label=manufacturer.name, actor=current_user, request=request, after_data=manufacturer)
|
||||
return manufacturer
|
||||
|
||||
|
||||
@router.put("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
|
||||
def update_manufacturer(
|
||||
manufacturer_id: int,
|
||||
payload: KnowledgeManufacturerUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.update")),
|
||||
):
|
||||
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
|
||||
before_data = sanitize(manufacturer)
|
||||
updated = KnowledgeService.update_manufacturer(db, manufacturer, payload)
|
||||
write_audit_log(db, action="knowledge.manufacturers.update", entity_type="knowledge_manufacturers", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/manufacturers/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_manufacturer(
|
||||
manufacturer_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.delete")),
|
||||
):
|
||||
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
|
||||
before_data = sanitize(manufacturer)
|
||||
label = manufacturer.name
|
||||
delete_or_conflict(db, manufacturer)
|
||||
write_audit_log(db, action="knowledge.manufacturers.delete", entity_type="knowledge_manufacturers", entity_id=manufacturer_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[KnowledgeDeviceResponse])
|
||||
def list_devices(
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_type: str = "",
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.read")),
|
||||
):
|
||||
logger.info("knowledge.devices.list", extra={"actor_user_id": current_user.id})
|
||||
return KnowledgeRepository.list_devices(db, q=q, manufacturer_id=manufacturer_id, device_type=device_type, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
|
||||
def get_device(device_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
|
||||
logger.info("knowledge.devices.detail", extra={"actor_user_id": current_user.id, "target_device_id": device_id})
|
||||
return get_device_or_404(db, device_id)
|
||||
|
||||
|
||||
@router.post("/devices", response_model=KnowledgeDeviceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_device(payload: KnowledgeDeviceCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
|
||||
device = KnowledgeService.create_device(db, payload)
|
||||
write_audit_log(db, action="knowledge.devices.create", entity_type="knowledge_devices", entity_id=device.id, entity_label=device.name, actor=current_user, request=request, after_data=device)
|
||||
return device
|
||||
|
||||
|
||||
@router.put("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
|
||||
def update_device(device_id: int, payload: KnowledgeDeviceUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
|
||||
device = get_device_or_404(db, device_id)
|
||||
before_data = sanitize(device)
|
||||
updated = KnowledgeService.update_device(db, device, payload)
|
||||
write_audit_log(db, action="knowledge.devices.update", entity_type="knowledge_devices", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_device(device_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
|
||||
device = get_device_or_404(db, device_id)
|
||||
before_data = sanitize(device)
|
||||
label = device.name
|
||||
delete_or_conflict(db, device)
|
||||
write_audit_log(db, action="knowledge.devices.delete", entity_type="knowledge_devices", entity_id=device_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/documents", response_model=list[KnowledgeDocumentResponse])
|
||||
def list_documents(
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_id: int | None = None,
|
||||
document_type: str = "",
|
||||
language: str = "",
|
||||
tag: str = "",
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.read")),
|
||||
):
|
||||
logger.info("knowledge.documents.list", extra={"actor_user_id": current_user.id})
|
||||
return KnowledgeRepository.list_documents(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, document_type=document_type, language=language, tag=tag, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
|
||||
def get_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
|
||||
logger.info("knowledge.documents.detail", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
|
||||
return get_document_or_404(db, document_id)
|
||||
|
||||
|
||||
@router.post("/documents", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_document(payload: KnowledgeDocumentCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
|
||||
document = KnowledgeService.create_document(db, payload)
|
||||
write_audit_log(db, action="knowledge.documents.create", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data=document)
|
||||
return document
|
||||
|
||||
|
||||
@router.post("/documents/upload", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
manufacturer_id: int = Form(...),
|
||||
title: str = Form(...),
|
||||
document_type: DocumentType = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
device_id: int | None = Form(default=None),
|
||||
language: str = Form(default="de"),
|
||||
external_url: str = Form(default=""),
|
||||
paperless_document_id: str = Form(default=""),
|
||||
description: str = Form(default=""),
|
||||
tags: str = Form(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.upload")),
|
||||
):
|
||||
payload = KnowledgeDocumentCreate(
|
||||
manufacturer_id=manufacturer_id,
|
||||
device_id=device_id,
|
||||
title=title,
|
||||
document_type=document_type,
|
||||
language=language,
|
||||
external_url=external_url or None,
|
||||
paperless_document_id=paperless_document_id,
|
||||
description=description,
|
||||
tags=normalize_tags(tags),
|
||||
)
|
||||
document = await KnowledgeService.upload_document(db, file=file, payload=payload)
|
||||
write_audit_log(db, action="knowledge.documents.upload", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data={key: value for key, value in sanitize(document).items() if key != "file_path"})
|
||||
return document
|
||||
|
||||
|
||||
@router.put("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
|
||||
def update_document(document_id: int, payload: KnowledgeDocumentUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
|
||||
document = get_document_or_404(db, document_id)
|
||||
before_data = sanitize(document)
|
||||
updated = KnowledgeService.update_document(db, document, payload)
|
||||
write_audit_log(db, action="knowledge.documents.update", entity_type="knowledge_documents", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id:int}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_document(document_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
|
||||
document = get_document_or_404(db, document_id)
|
||||
before_data = sanitize(document)
|
||||
label = document.title
|
||||
file_path = document.file_path
|
||||
delete_or_conflict(db, document)
|
||||
if file_path:
|
||||
KnowledgeService.delete_storage_key(file_path)
|
||||
write_audit_log(db, action="knowledge.documents.delete", entity_type="knowledge_documents", entity_id=document_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id:int}/download")
|
||||
def download_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.download"))):
|
||||
document = get_document_or_404(db, document_id)
|
||||
path = KnowledgeService.open_document_file(document)
|
||||
logger.info("knowledge.documents.download", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
|
||||
return FileResponse(path, media_type=document.mime_type or "application/octet-stream", filename=document.file_name)
|
||||
|
||||
|
||||
@router.get("/notes", response_model=list[KnowledgeNoteResponse])
|
||||
def list_notes(
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_id: int | None = None,
|
||||
note_type: str = "",
|
||||
tag: str = "",
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("knowledge.read")),
|
||||
):
|
||||
logger.info("knowledge.notes.list", extra={"actor_user_id": current_user.id})
|
||||
return KnowledgeRepository.list_notes(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, note_type=note_type, tag=tag, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/notes/{note_id}", response_model=KnowledgeNoteResponse)
|
||||
def get_note(note_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
|
||||
logger.info("knowledge.notes.detail", extra={"actor_user_id": current_user.id, "target_note_id": note_id})
|
||||
return get_note_or_404(db, note_id)
|
||||
|
||||
|
||||
@router.post("/notes", response_model=KnowledgeNoteResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_note(payload: KnowledgeNoteCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
|
||||
note = KnowledgeService.create_note(db, payload)
|
||||
write_audit_log(db, action="knowledge.notes.create", entity_type="knowledge_notes", entity_id=note.id, entity_label=note.title, actor=current_user, request=request, after_data=note)
|
||||
return note
|
||||
|
||||
|
||||
@router.put("/notes/{note_id}", response_model=KnowledgeNoteResponse)
|
||||
def update_note(note_id: int, payload: KnowledgeNoteUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
|
||||
note = get_note_or_404(db, note_id)
|
||||
before_data = sanitize(note)
|
||||
updated = KnowledgeService.update_note(db, note, payload)
|
||||
write_audit_log(db, action="knowledge.notes.update", entity_type="knowledge_notes", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_note(note_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
|
||||
note = get_note_or_404(db, note_id)
|
||||
before_data = sanitize(note)
|
||||
label = note.title
|
||||
delete_or_conflict(db, note)
|
||||
write_audit_log(db, action="knowledge.notes.delete", entity_type="knowledge_notes", entity_id=note_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[KnowledgeSearchResult])
|
||||
def search_knowledge(q: str = Query(..., min_length=1), db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
|
||||
logger.info("knowledge.search", extra={"actor_user_id": current_user.id})
|
||||
manufacturers = KnowledgeRepository.list_manufacturers(db, q=q, limit=10, offset=0)
|
||||
devices = KnowledgeRepository.list_devices(db, q=q, limit=10, offset=0)
|
||||
documents = KnowledgeRepository.list_documents(db, q=q, limit=10, offset=0)
|
||||
notes = KnowledgeRepository.list_notes(db, q=q, limit=10, offset=0)
|
||||
results: list[KnowledgeSearchResult] = []
|
||||
results.extend(KnowledgeSearchResult(entity_type="manufacturer", id=item.id, title=item.name, href=f"/knowledge/manufacturers?selected={item.id}") for item in manufacturers)
|
||||
results.extend(KnowledgeSearchResult(entity_type="device", id=item.id, title=item.name, subtitle=item.model_number, href=f"/knowledge/devices/{item.id}") for item in devices)
|
||||
results.extend(KnowledgeSearchResult(entity_type="document", id=item.id, title=item.title, subtitle=item.document_type, href=f"/knowledge/documents/{item.id}") for item in documents)
|
||||
results.extend(KnowledgeSearchResult(entity_type="note", id=item.id, title=item.title, subtitle=item.note_type, href=f"/knowledge/notes?selected={item.id}") for item in notes)
|
||||
return results[:30]
|
||||
89
backend/hermes/app/api/lexware.py
Normal file
89
backend/hermes/app/api/lexware.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.services.lexware_service import LexwareService
|
||||
|
||||
router = APIRouter(tags=["Lexware"])
|
||||
|
||||
|
||||
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
|
||||
repair = RepairRepository.get_by_id(db, repair_id)
|
||||
if repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
|
||||
return repair
|
||||
|
||||
|
||||
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
|
||||
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
|
||||
if estimate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||
return estimate
|
||||
|
||||
|
||||
@router.get("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||
def get_lexware_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.read")),
|
||||
):
|
||||
return LexwareService.get_settings(db)
|
||||
|
||||
|
||||
@router.put("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||
def update_lexware_settings(
|
||||
payload: LexwareSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.manage")),
|
||||
):
|
||||
return LexwareService.update_settings(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse)
|
||||
def test_lexware_connection(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.manage")),
|
||||
):
|
||||
return LexwareService.test_connection(db, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse)
|
||||
def prepare_lexware_invoice(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
|
||||
def mark_accounting_transferred(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
payload: AccountingTransferUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
|
||||
194
backend/hermes/app/api/repair_estimates.py
Normal file
194
backend/hermes/app/api/repair_estimates.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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)
|
||||
327
backend/hermes/app/api/repairs.py
Normal file
327
backend/hermes/app/api/repairs.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
from typing import cast
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.repair import Repair
|
||||
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,
|
||||
RepairPublicLinkResponse,
|
||||
RepairPublicStatusResponse,
|
||||
RepairResponse,
|
||||
RepairStatus,
|
||||
RepairStatusHistoryResponse,
|
||||
RepairStatusUpdate,
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["Repairs"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/repairs", response_model=RepairListResponse)
|
||||
@router.get("/repairs/", response_model=RepairListResponse, include_in_schema=False)
|
||||
def get_repairs(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
q: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
source: str | None = None,
|
||||
priority: str | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
items, total = RepairRepository.list(
|
||||
db,
|
||||
status=status_filter,
|
||||
q=q,
|
||||
customer_id=customer_id,
|
||||
source=source,
|
||||
priority=priority,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return RepairListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}", response_model=RepairResponse)
|
||||
def get_repair(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
return get_repair_or_404(db, repair_id)
|
||||
|
||||
|
||||
@router.post("/repairs", response_model=RepairResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/repairs/", response_model=RepairResponse, status_code=status.HTTP_201_CREATED, include_in_schema=False)
|
||||
def create_repair(
|
||||
repair: RepairCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.create")),
|
||||
):
|
||||
return RepairService.create(db, repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/repairs/{repair_id}", response_model=RepairResponse)
|
||||
def update_repair(
|
||||
repair_id: int,
|
||||
repair: RepairUpdate,
|
||||
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 RepairService.update(db, db_repair, repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/repairs/{repair_id}", response_model=RepairResponse)
|
||||
def delete_repair(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.delete")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairService.cancel(db, db_repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/repairs/{repair_id}/status", response_model=RepairResponse)
|
||||
def update_repair_status(
|
||||
repair_id: int,
|
||||
payload: RepairStatusUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.status.update")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairService.update_status(db, db_repair, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/history", response_model=list[RepairStatusHistoryResponse])
|
||||
def get_repair_history(
|
||||
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.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,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.get(db, db_repair)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_public_link(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.create(db, db_repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||
def revoke_public_link(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.revoke(db, db_repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/notifications", response_model=RepairNotificationOverviewResponse)
|
||||
def get_repair_notifications(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
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,
|
||||
request: Request,
|
||||
x_olympus_intake_token: str | None = Header(default=None, alias="X-Olympus-Intake-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
expected_token = settings.olympus_repair_intake_token
|
||||
if not expected_token or not x_olympus_intake_token or x_olympus_intake_token != expected_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht autorisiert")
|
||||
|
||||
repair = RepairService.create_from_intake(db, payload, request=request)
|
||||
return RepairIntakeResponse(
|
||||
repair_number=repair.repair_number,
|
||||
status=cast(RepairStatus, repair.status),
|
||||
message="Reparaturanfrage übernommen",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public/repairs/status/{token}", response_model=RepairPublicStatusResponse)
|
||||
def get_public_repair_status(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return RepairPublicLinkService.public_status(db, token)
|
||||
67
backend/hermes/app/api/system_settings.py
Normal file
67
backend/hermes/app/api/system_settings.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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)
|
||||
Binary file not shown.
|
|
@ -1,3 +1,6 @@
|
|||
from typing import Any
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
|
|
@ -6,7 +9,7 @@ class Settings(BaseSettings):
|
|||
secret_key: str
|
||||
|
||||
app_name: str = "Hermes API"
|
||||
app_version: str = "0.1.0"
|
||||
app_version: str = "0.9.1"
|
||||
access_token_expire_minutes: int = 60
|
||||
jwt_issuer: str = "hermes"
|
||||
log_level: str = "INFO"
|
||||
|
|
@ -15,11 +18,42 @@ class Settings(BaseSettings):
|
|||
initial_admin_password: str | None = None
|
||||
initial_admin_first_name: str = ""
|
||||
initial_admin_last_name: str = ""
|
||||
storage_provider: str = "local"
|
||||
storage_base_path: str = "/data/storage"
|
||||
storage_max_upload_mb: int = 50
|
||||
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",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def apply_legacy_storage_settings(cls, values: Any) -> Any:
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
if "storage_base_path" not in values and "knowledge_storage_path" in values:
|
||||
values["storage_base_path"] = values["knowledge_storage_path"]
|
||||
|
||||
if "storage_max_upload_mb" not in values and "knowledge_max_upload_mb" in values:
|
||||
values["storage_max_upload_mb"] = values["knowledge_max_upload_mb"]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -22,8 +22,14 @@ class Base(DeclarativeBase):
|
|||
# <<< HIER IMPORTIEREN >>>
|
||||
import app.models.rbac
|
||||
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():
|
||||
|
|
|
|||
77
backend/hermes/app/knowledge_seed.py
Normal file
77
backend/hermes/app/knowledge_seed.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.knowledge import KnowledgeManufacturer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MANUFACTURERS = [
|
||||
"Stabo",
|
||||
"President",
|
||||
"Albrecht",
|
||||
"Marconi",
|
||||
"Rohde & Schwarz",
|
||||
"HP",
|
||||
"CRT",
|
||||
"Alinco",
|
||||
"Motorola",
|
||||
"Team",
|
||||
]
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
normalized = normalized.replace("&", "und")
|
||||
normalized = normalized.replace("ß", "ss")
|
||||
normalized = normalized.replace("ö", "oe")
|
||||
normalized = normalized.replace("ä", "ae")
|
||||
normalized = normalized.replace("ü", "ue")
|
||||
|
||||
parts = []
|
||||
current = []
|
||||
|
||||
for char in normalized:
|
||||
if char.isalnum():
|
||||
current.append(char)
|
||||
elif current:
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
|
||||
if current:
|
||||
parts.append("".join(current))
|
||||
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def seed_knowledge_manufacturers(db: Session) -> None:
|
||||
created_count = 0
|
||||
|
||||
existing = {
|
||||
manufacturer.name.lower(): manufacturer
|
||||
for manufacturer in db.scalars(select(KnowledgeManufacturer)).all()
|
||||
}
|
||||
|
||||
for name in DEFAULT_MANUFACTURERS:
|
||||
existing_manufacturer = existing.get(name.lower())
|
||||
|
||||
if existing_manufacturer:
|
||||
if not existing_manufacturer.slug:
|
||||
existing_manufacturer.slug = _slugify(name)
|
||||
continue
|
||||
|
||||
manufacturer = KnowledgeManufacturer(
|
||||
name=name,
|
||||
slug=_slugify(name),
|
||||
notes="Systemseitig initial angelegt",
|
||||
)
|
||||
db.add(manufacturer)
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"knowledge.manufacturers.seeded",
|
||||
extra={"manufacturer_seed_created_count": created_count},
|
||||
)
|
||||
|
|
@ -11,14 +11,22 @@ 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
|
||||
from app.core.logging import configure_logging
|
||||
from app.knowledge_seed import seed_knowledge_manufacturers
|
||||
from app.rbac.seed import seed_rbac
|
||||
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
|
||||
|
||||
|
|
@ -26,17 +34,24 @@ configure_logging()
|
|||
|
||||
app = FastAPI(
|
||||
title="Hermes API",
|
||||
version="0.1.0",
|
||||
version="0.9.1",
|
||||
description="Backend von Olympus",
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(roles_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(customers_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(knowledge_router)
|
||||
app.include_router(repairs_router)
|
||||
app.include_router(repair_estimates_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(system_settings_router)
|
||||
app.include_router(lexware_router)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -74,6 +89,7 @@ def startup_seed_rbac():
|
|||
db = SessionLocal()
|
||||
try:
|
||||
seed_rbac(db)
|
||||
seed_knowledge_manufacturers(db)
|
||||
bootstrap_initial_admin(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
Binary file not shown.
114
backend/hermes/app/models/inventory.py
Normal file
114
backend/hermes/app/models/inventory.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class InventoryCategory(Base):
|
||||
__tablename__ = "inventory_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
|
||||
|
||||
|
||||
class InventoryLocation(Base):
|
||||
__tablename__ = "inventory_locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
|
||||
|
||||
|
||||
class InventorySupplier(Base):
|
||||
__tablename__ = "inventory_suppliers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
|
||||
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
|
||||
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
|
||||
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
|
||||
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
|
||||
movements: Mapped[list["InventoryStockMovement"]] = relationship(
|
||||
back_populates="item",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="InventoryStockMovement.created_at.desc()",
|
||||
)
|
||||
|
||||
|
||||
class InventoryStockMovement(Base):
|
||||
__tablename__ = "inventory_stock_movements"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
|
||||
movement_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="InventoryStockMovement.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
return self.actor.username if self.actor is not None else ""
|
||||
101
backend/hermes/app/models/knowledge.py
Normal file
101
backend/hermes/app/models/knowledge.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class KnowledgeManufacturer(Base):
|
||||
__tablename__ = "knowledge_manufacturers"
|
||||
|
||||
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)
|
||||
website: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
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())
|
||||
|
||||
devices: Mapped[list["KnowledgeDevice"]] = relationship(back_populates="manufacturer", cascade="all, delete-orphan")
|
||||
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="manufacturer")
|
||||
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="manufacturer")
|
||||
|
||||
|
||||
class KnowledgeDevice(Base):
|
||||
__tablename__ = "knowledge_devices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(180), index=True)
|
||||
slug: Mapped[str] = mapped_column(String(200), index=True)
|
||||
model_number: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
|
||||
device_type: Mapped[str] = mapped_column(String(80), default="", server_default="", index=True)
|
||||
frequency_range: Mapped[str] = mapped_column(String(120), default="", server_default="")
|
||||
production_year_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
production_year_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
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())
|
||||
|
||||
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="devices")
|
||||
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="device")
|
||||
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="device")
|
||||
|
||||
|
||||
class KnowledgeDocument(Base):
|
||||
__tablename__ = "knowledge_documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="RESTRICT"), index=True)
|
||||
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
slug: Mapped[str] = mapped_column(String(280), unique=True, index=True)
|
||||
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
language: Mapped[str] = mapped_column(String(20), default="de", server_default="", index=True)
|
||||
file_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
file_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
|
||||
file_size: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
|
||||
external_url: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||
paperless_document_id: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
|
||||
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())
|
||||
|
||||
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="documents")
|
||||
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="documents")
|
||||
|
||||
|
||||
class KnowledgeNote(Base):
|
||||
__tablename__ = "knowledge_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
manufacturer_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
note_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
severity: Mapped[str] = mapped_column(String(40), default="", server_default="", index=True)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
|
||||
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())
|
||||
|
||||
manufacturer: Mapped[KnowledgeManufacturer | None] = relationship(back_populates="notes_rel")
|
||||
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="notes_rel")
|
||||
|
||||
|
||||
class KnowledgeCategory(Base):
|
||||
__tablename__ = "knowledge_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(140), unique=True, index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
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())
|
||||
27
backend/hermes/app/models/lexware.py
Normal file
27
backend/hermes/app/models/lexware.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class LexwareSyncRecord(Base):
|
||||
__tablename__ = "lexware_sync_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
|
||||
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
162
backend/hermes/app/models/repair.py
Normal file
162
backend/hermes/app/models/repair.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Repair(Base):
|
||||
__tablename__ = "repairs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
contact_id: Mapped[int | None] = mapped_column(ForeignKey("customer_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(50), index=True)
|
||||
priority: Mapped[str] = mapped_column(String(30), index=True)
|
||||
source: Mapped[str] = mapped_column(String(50), index=True)
|
||||
source_reference: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
customer_name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
customer_email: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
customer_phone: Mapped[str] = mapped_column(String(80), default="", server_default="")
|
||||
device_manufacturer: Mapped[str] = mapped_column(String(160), index=True)
|
||||
device_model: Mapped[str] = mapped_column(String(160), index=True)
|
||||
device_serial_number: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
device_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
|
||||
accessories: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
fault_description: Mapped[str] = mapped_column(Text)
|
||||
previous_work: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
device_opened: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
intake_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
diagnosis_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
repair_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
estimate_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
estimated_cost_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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())
|
||||
|
||||
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):
|
||||
__tablename__ = "repair_status_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
old_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
new_status: Mapped[str] = mapped_column(String(50), 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())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairStatusHistory.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
return self.actor.username if self.actor is not None else ""
|
||||
|
||||
@property
|
||||
def actor_display_name(self) -> str:
|
||||
if self.actor is None:
|
||||
return ""
|
||||
display_name = f"{self.actor.first_name} {self.actor.last_name}".strip()
|
||||
return display_name or self.actor.username
|
||||
|
||||
|
||||
class RepairIntakeEvent(Base):
|
||||
__tablename__ = "repair_intake_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int | None] = mapped_column(ForeignKey("repairs.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
external_source: Mapped[str] = mapped_column(String(80), index=True)
|
||||
external_reference: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(50), 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())
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class RepairDocument(Base):
|
||||
__tablename__ = "repair_documents"
|
||||
|
||||
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)
|
||||
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):
|
||||
__tablename__ = "repair_public_access_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
token_hint: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="public_access_tokens")
|
||||
|
||||
|
||||
class RepairNotificationEvent(Base):
|
||||
__tablename__ = "repair_notification_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
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)
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="notification_events")
|
||||
108
backend/hermes/app/models/repair_estimate.py
Normal file
108
backend/hermes/app/models/repair_estimate.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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",
|
||||
)
|
||||
21
backend/hermes/app/models/system_setting.py
Normal file
21
backend/hermes/app/models/system_setting.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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(),
|
||||
)
|
||||
|
|
@ -69,7 +69,44 @@ 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"),
|
||||
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
|
||||
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
|
||||
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
|
||||
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
|
||||
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
|
||||
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
|
||||
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
|
||||
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
|
||||
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
|
||||
("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 = {
|
||||
|
|
@ -78,8 +115,33 @@ ROLE_PERMISSION_NAMES = {
|
|||
"dashboard.read",
|
||||
"users.read",
|
||||
"customers.read",
|
||||
"knowledge.read",
|
||||
"knowledge.download",
|
||||
"projects.read",
|
||||
"tickets.read",
|
||||
"repairs.read",
|
||||
"repairs.create",
|
||||
"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",
|
||||
|
|
@ -95,17 +157,51 @@ ROLE_PERMISSION_NAMES = {
|
|||
"tickets.read",
|
||||
"tickets.create",
|
||||
"tickets.update",
|
||||
"knowledge.read",
|
||||
"knowledge.create",
|
||||
"knowledge.update",
|
||||
"knowledge.upload",
|
||||
"knowledge.download",
|
||||
"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",
|
||||
"users.read",
|
||||
"customers.read",
|
||||
"knowledge.read",
|
||||
"knowledge.download",
|
||||
"tickets.read",
|
||||
"tickets.create",
|
||||
"tickets.update",
|
||||
"repairs.read",
|
||||
"repairs.create",
|
||||
"repairs.update",
|
||||
"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",
|
||||
|
|
|
|||
229
backend/hermes/app/repositories/inventory_repository.py
Normal file
229
backend/hermes/app/repositories/inventory_repository.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.inventory import (
|
||||
InventoryCategory,
|
||||
InventoryItem,
|
||||
InventoryLocation,
|
||||
InventoryStockMovement,
|
||||
InventorySupplier,
|
||||
)
|
||||
|
||||
|
||||
class InventoryRepository:
|
||||
@staticmethod
|
||||
def item_query() -> Select[tuple[InventoryItem]]:
|
||||
return select(InventoryItem).options(
|
||||
selectinload(InventoryItem.category),
|
||||
selectinload(InventoryItem.supplier),
|
||||
selectinload(InventoryItem.location),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_items(
|
||||
db: Session,
|
||||
*,
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
supplier_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
low_stock: bool | None = None,
|
||||
active: bool | None = True,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[InventoryItem], int]:
|
||||
query = InventoryRepository.item_query()
|
||||
if q:
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(
|
||||
InventoryItem.sku.ilike(pattern),
|
||||
InventoryItem.name.ilike(pattern),
|
||||
InventoryItem.manufacturer.ilike(pattern),
|
||||
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||
InventoryItem.supplier_part_number.ilike(pattern),
|
||||
))
|
||||
if category_id is not None:
|
||||
query = query.where(InventoryItem.category_id == category_id)
|
||||
if supplier_id is not None:
|
||||
query = query.where(InventoryItem.supplier_id == supplier_id)
|
||||
if location_id is not None:
|
||||
query = query.where(InventoryItem.location_id == location_id)
|
||||
if low_stock is not None:
|
||||
if low_stock:
|
||||
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||
else:
|
||||
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
|
||||
if active is not None:
|
||||
query = query.where(InventoryItem.is_active == active)
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
items = list(
|
||||
db.scalars(
|
||||
query
|
||||
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def search_items(
|
||||
db: Session,
|
||||
*,
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
manufacturer: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[InventoryItem]:
|
||||
query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
|
||||
if q:
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(
|
||||
InventoryItem.sku.ilike(pattern),
|
||||
InventoryItem.name.ilike(pattern),
|
||||
InventoryItem.manufacturer.ilike(pattern),
|
||||
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||
InventoryItem.supplier_part_number.ilike(pattern),
|
||||
InventoryItem.compatible_devices.ilike(pattern),
|
||||
))
|
||||
if category_id is not None:
|
||||
query = query.where(InventoryItem.category_id == category_id)
|
||||
if manufacturer:
|
||||
query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
|
||||
return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
|
||||
|
||||
@staticmethod
|
||||
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
||||
|
||||
@staticmethod
|
||||
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
|
||||
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
|
||||
|
||||
@staticmethod
|
||||
def get_next_sku(db: Session, year: int | None = None) -> str:
|
||||
current_year = year or datetime.now(UTC).year
|
||||
prefix = f"ET-{current_year}-"
|
||||
latest = db.scalar(
|
||||
select(InventoryItem.sku)
|
||||
.where(InventoryItem.sku.like(f"{prefix}%"))
|
||||
.order_by(InventoryItem.sku.desc())
|
||||
.limit(1)
|
||||
)
|
||||
next_number = 1
|
||||
if latest:
|
||||
next_number = int(latest.split("-")[-1]) + 1
|
||||
return f"{prefix}{next_number:06d}"
|
||||
|
||||
@staticmethod
|
||||
def list_categories(db: Session) -> list[InventoryCategory]:
|
||||
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
|
||||
return db.get(InventoryCategory, category_id)
|
||||
|
||||
@staticmethod
|
||||
def list_locations(db: Session) -> list[InventoryLocation]:
|
||||
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
|
||||
return db.get(InventoryLocation, location_id)
|
||||
|
||||
@staticmethod
|
||||
def list_suppliers(db: Session) -> list[InventorySupplier]:
|
||||
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
|
||||
return db.get(InventorySupplier, supplier_id)
|
||||
|
||||
@staticmethod
|
||||
def has_movements(db: Session, item_id: int) -> bool:
|
||||
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
|
||||
|
||||
@staticmethod
|
||||
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(InventoryStockMovement)
|
||||
.where(InventoryStockMovement.item_id == item_id)
|
||||
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_movement(
|
||||
db: Session,
|
||||
*,
|
||||
item_id: int,
|
||||
movement_type: str,
|
||||
quantity: int,
|
||||
reason: str,
|
||||
reference_type: str | None,
|
||||
reference_id: int | None,
|
||||
note: str | None,
|
||||
actor_user_id: int | None,
|
||||
) -> InventoryStockMovement:
|
||||
movement = InventoryStockMovement(
|
||||
item_id=item_id,
|
||||
movement_type=movement_type,
|
||||
quantity=quantity,
|
||||
reason=reason,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
note=note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
db.add(movement)
|
||||
return movement
|
||||
|
||||
@staticmethod
|
||||
def count_active_items(db: Session) -> int:
|
||||
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_low_stock_items(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(InventoryItem.id))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def total_stock_value_cents(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.purchase_price_cents.is_not(None))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_reserved_items(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(InventoryItem.id))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.quantity_reserved > 0)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def reserved_stock_value_cents(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.selling_price_cents.is_not(None))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(InventoryStockMovement)
|
||||
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
177
backend/hermes/app/repositories/knowledge_repository.py
Normal file
177
backend/hermes/app/repositories/knowledge_repository.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models.knowledge import (
|
||||
KnowledgeDevice,
|
||||
KnowledgeDocument,
|
||||
KnowledgeManufacturer,
|
||||
KnowledgeNote,
|
||||
)
|
||||
|
||||
|
||||
def paginate(query, limit: int, offset: int):
|
||||
return query.limit(max(1, min(limit, 100))).offset(max(0, offset))
|
||||
|
||||
|
||||
class KnowledgeRepository:
|
||||
@staticmethod
|
||||
def list_manufacturers(db: Session, *, q: str = "", limit: int = 50, offset: int = 0) -> list[KnowledgeManufacturer]:
|
||||
query = select(KnowledgeManufacturer).order_by(KnowledgeManufacturer.name)
|
||||
if q:
|
||||
pattern = f"%{q.lower()}%"
|
||||
query = query.where(func.lower(KnowledgeManufacturer.name).like(pattern))
|
||||
return list(db.scalars(paginate(query, limit, offset)))
|
||||
|
||||
@staticmethod
|
||||
def get_manufacturer(db: Session, manufacturer_id: int) -> KnowledgeManufacturer | None:
|
||||
return db.get(KnowledgeManufacturer, manufacturer_id)
|
||||
|
||||
@staticmethod
|
||||
def get_manufacturer_by_name(db: Session, name: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
|
||||
query = select(KnowledgeManufacturer).where(func.lower(KnowledgeManufacturer.name) == name.lower())
|
||||
if exclude_id is not None:
|
||||
query = query.where(KnowledgeManufacturer.id != exclude_id)
|
||||
return db.scalar(query)
|
||||
|
||||
@staticmethod
|
||||
def get_manufacturer_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
|
||||
query = select(KnowledgeManufacturer).where(KnowledgeManufacturer.slug == slug)
|
||||
if exclude_id is not None:
|
||||
query = query.where(KnowledgeManufacturer.id != exclude_id)
|
||||
return db.scalar(query)
|
||||
|
||||
@staticmethod
|
||||
def list_devices(
|
||||
db: Session,
|
||||
*,
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_type: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[KnowledgeDevice]:
|
||||
query = select(KnowledgeDevice).options(joinedload(KnowledgeDevice.manufacturer)).order_by(KnowledgeDevice.name)
|
||||
if manufacturer_id is not None:
|
||||
query = query.where(KnowledgeDevice.manufacturer_id == manufacturer_id)
|
||||
if device_type:
|
||||
query = query.where(KnowledgeDevice.device_type == device_type)
|
||||
if q:
|
||||
pattern = f"%{q.lower()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
func.lower(KnowledgeDevice.name).like(pattern),
|
||||
func.lower(KnowledgeDevice.model_number).like(pattern),
|
||||
)
|
||||
)
|
||||
return list(db.scalars(paginate(query, limit, offset)))
|
||||
|
||||
@staticmethod
|
||||
def get_device(db: Session, device_id: int) -> KnowledgeDevice | None:
|
||||
return db.scalar(
|
||||
select(KnowledgeDevice)
|
||||
.where(KnowledgeDevice.id == device_id)
|
||||
.options(joinedload(KnowledgeDevice.manufacturer))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_device_by_slug(
|
||||
db: Session,
|
||||
*,
|
||||
manufacturer_id: int,
|
||||
slug: str,
|
||||
exclude_id: int | None = None,
|
||||
) -> KnowledgeDevice | None:
|
||||
query = select(KnowledgeDevice).where(KnowledgeDevice.manufacturer_id == manufacturer_id, KnowledgeDevice.slug == slug)
|
||||
if exclude_id is not None:
|
||||
query = query.where(KnowledgeDevice.id != exclude_id)
|
||||
return db.scalar(query)
|
||||
|
||||
@staticmethod
|
||||
def list_documents(
|
||||
db: Session,
|
||||
*,
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_id: int | None = None,
|
||||
document_type: str = "",
|
||||
language: str = "",
|
||||
tag: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[KnowledgeDocument]:
|
||||
query = (
|
||||
select(KnowledgeDocument)
|
||||
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
|
||||
.order_by(KnowledgeDocument.created_at.desc())
|
||||
)
|
||||
if manufacturer_id is not None:
|
||||
query = query.where(KnowledgeDocument.manufacturer_id == manufacturer_id)
|
||||
if device_id is not None:
|
||||
query = query.where(KnowledgeDocument.device_id == device_id)
|
||||
if document_type:
|
||||
query = query.where(KnowledgeDocument.document_type == document_type)
|
||||
if language:
|
||||
query = query.where(KnowledgeDocument.language == language)
|
||||
if q:
|
||||
pattern = f"%{q.lower()}%"
|
||||
query = query.where(func.lower(KnowledgeDocument.title).like(pattern))
|
||||
documents = list(db.scalars(paginate(query, limit, offset)))
|
||||
if tag:
|
||||
tag_lower = tag.lower()
|
||||
documents = [document for document in documents if any(item.lower() == tag_lower for item in document.tags)]
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def get_document(db: Session, document_id: int) -> KnowledgeDocument | None:
|
||||
return db.scalar(
|
||||
select(KnowledgeDocument)
|
||||
.where(KnowledgeDocument.id == document_id)
|
||||
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_document_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeDocument | None:
|
||||
query = select(KnowledgeDocument).where(KnowledgeDocument.slug == slug)
|
||||
if exclude_id is not None:
|
||||
query = query.where(KnowledgeDocument.id != exclude_id)
|
||||
return db.scalar(query)
|
||||
|
||||
@staticmethod
|
||||
def list_notes(
|
||||
db: Session,
|
||||
*,
|
||||
q: str = "",
|
||||
manufacturer_id: int | None = None,
|
||||
device_id: int | None = None,
|
||||
note_type: str = "",
|
||||
tag: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[KnowledgeNote]:
|
||||
query = (
|
||||
select(KnowledgeNote)
|
||||
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
|
||||
.order_by(KnowledgeNote.created_at.desc())
|
||||
)
|
||||
if manufacturer_id is not None:
|
||||
query = query.where(KnowledgeNote.manufacturer_id == manufacturer_id)
|
||||
if device_id is not None:
|
||||
query = query.where(KnowledgeNote.device_id == device_id)
|
||||
if note_type:
|
||||
query = query.where(KnowledgeNote.note_type == note_type)
|
||||
if q:
|
||||
pattern = f"%{q.lower()}%"
|
||||
query = query.where(or_(func.lower(KnowledgeNote.title).like(pattern), func.lower(KnowledgeNote.content).like(pattern)))
|
||||
notes = list(db.scalars(paginate(query, limit, offset)))
|
||||
if tag:
|
||||
tag_lower = tag.lower()
|
||||
notes = [note for note in notes if any(item.lower() == tag_lower for item in note.tags)]
|
||||
return notes
|
||||
|
||||
@staticmethod
|
||||
def get_note(db: Session, note_id: int) -> KnowledgeNote | None:
|
||||
return db.scalar(
|
||||
select(KnowledgeNote)
|
||||
.where(KnowledgeNote.id == note_id)
|
||||
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
|
||||
)
|
||||
136
backend/hermes/app/repositories/repair_estimate_repository.py
Normal file
136
backend/hermes/app/repositories/repair_estimate_repository.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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
|
||||
449
backend/hermes/app/repositories/repair_repository.py
Normal file
449
backend/hermes/app/repositories/repair_repository.py
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
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
|
||||
|
||||
|
||||
class RepairRepository:
|
||||
@staticmethod
|
||||
def query(
|
||||
*,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
source: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> Select[tuple[Repair]]:
|
||||
query = select(Repair).options(selectinload(Repair.history))
|
||||
if status:
|
||||
query = query.where(Repair.status == status)
|
||||
if customer_id is not None:
|
||||
query = query.where(Repair.customer_id == customer_id)
|
||||
if source:
|
||||
query = query.where(Repair.source == source)
|
||||
if priority:
|
||||
query = query.where(Repair.priority == priority)
|
||||
if q:
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Repair.repair_number.ilike(term),
|
||||
Repair.customer_name.ilike(term),
|
||||
Repair.device_manufacturer.ilike(term),
|
||||
Repair.device_model.ilike(term),
|
||||
Repair.fault_description.ilike(term),
|
||||
Repair.source_reference.ilike(term),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def list(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
source: str | None = None,
|
||||
priority: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Repair], int]:
|
||||
base_query = RepairRepository.query(
|
||||
status=status,
|
||||
q=q,
|
||||
customer_id=customer_id,
|
||||
source=source,
|
||||
priority=priority,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0
|
||||
items = list(
|
||||
db.scalars(
|
||||
base_query
|
||||
.order_by(Repair.created_at.desc(), Repair.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, repair_id: int) -> Repair | None:
|
||||
return db.scalar(select(Repair).options(selectinload(Repair.history)).where(Repair.id == repair_id))
|
||||
|
||||
@staticmethod
|
||||
def get_next_number(db: Session, year: int) -> str:
|
||||
prefix = f"R{year}-"
|
||||
latest = db.scalar(
|
||||
select(Repair.repair_number)
|
||||
.where(Repair.repair_number.like(f"{prefix}%"))
|
||||
.order_by(Repair.repair_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
next_number = 1
|
||||
if latest:
|
||||
next_number = int(latest.split("-")[-1]) + 1
|
||||
return f"{prefix}{next_number:06d}"
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, payload: RepairCreate, *, repair_number: str) -> Repair:
|
||||
repair = Repair(repair_number=repair_number, **RepairRepository._payload_data(payload))
|
||||
db.add(repair)
|
||||
db.flush()
|
||||
RepairRepository.add_history(db, repair.id, old_status=None, new_status=repair.status, note="Reparatur angelegt", actor_user_id=None)
|
||||
db.commit()
|
||||
db.refresh(repair)
|
||||
return RepairRepository.get_by_id(db, repair.id) or repair
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, repair: Repair, payload: RepairUpdate) -> Repair:
|
||||
for key, value in RepairRepository._payload_data(payload).items():
|
||||
setattr(repair, key, value)
|
||||
RepairRepository._apply_status_timestamps(repair)
|
||||
db.commit()
|
||||
db.refresh(repair)
|
||||
return RepairRepository.get_by_id(db, repair.id) or repair
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor_user_id: int | None) -> Repair:
|
||||
old_status = repair.status
|
||||
repair.status = payload.status
|
||||
RepairRepository._apply_status_timestamps(repair)
|
||||
RepairRepository.add_history(
|
||||
db,
|
||||
repair.id,
|
||||
old_status=old_status,
|
||||
new_status=payload.status,
|
||||
note=payload.note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(repair)
|
||||
return RepairRepository.get_by_id(db, repair.id) or repair
|
||||
|
||||
@staticmethod
|
||||
def cancel(db: Session, repair: Repair, *, actor_user_id: int | None) -> Repair:
|
||||
old_status = repair.status
|
||||
repair.status = "cancelled"
|
||||
repair.completed_at = datetime.now(UTC)
|
||||
RepairRepository.add_history(
|
||||
db,
|
||||
repair.id,
|
||||
old_status=old_status,
|
||||
new_status="cancelled",
|
||||
note="Reparatur storniert",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(repair)
|
||||
return repair
|
||||
|
||||
@staticmethod
|
||||
def add_history(db: Session, repair_id: int, *, old_status: str | None, new_status: str, note: str | None, actor_user_id: int | None) -> RepairStatusHistory:
|
||||
history = RepairStatusHistory(
|
||||
repair_id=repair_id,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
note=note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
db.add(history)
|
||||
return history
|
||||
|
||||
@staticmethod
|
||||
def get_history(db: Session, repair_id: int) -> list[RepairStatusHistory]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairStatusHistory)
|
||||
.options(selectinload(RepairStatusHistory.actor))
|
||||
.where(RepairStatusHistory.repair_id == repair_id)
|
||||
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairStatusHistory)
|
||||
.where(RepairStatusHistory.repair_id == repair_id)
|
||||
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def count_by_status(db: Session, status: str) -> int:
|
||||
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
|
||||
|
||||
@staticmethod
|
||||
def create_intake_event(
|
||||
db: Session,
|
||||
*,
|
||||
external_source: str,
|
||||
external_reference: str | None,
|
||||
payload: dict,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
repair_id: int | None = None,
|
||||
processed_at: datetime | None = None,
|
||||
) -> RepairIntakeEvent:
|
||||
event = RepairIntakeEvent(
|
||||
external_source=external_source,
|
||||
external_reference=external_reference,
|
||||
payload=payload,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
repair_id=repair_id,
|
||||
processed_at=processed_at,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def update_intake_event(db: Session, event: RepairIntakeEvent, *, status: str, repair_id: int | None = None, error_message: str | None = None) -> RepairIntakeEvent:
|
||||
event.status = status
|
||||
event.repair_id = repair_id
|
||||
event.error_message = error_message
|
||||
event.processed_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
|
||||
now = datetime.now(UTC)
|
||||
return db.scalar(
|
||||
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)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
|
||||
now = datetime.now(UTC)
|
||||
return db.scalar(
|
||||
select(RepairPublicAccessToken)
|
||||
.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)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_public_link(
|
||||
db: Session,
|
||||
*,
|
||||
repair_id: int,
|
||||
token_hash: str,
|
||||
token_hint: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> RepairPublicAccessToken:
|
||||
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
|
||||
public_link = RepairPublicAccessToken(
|
||||
repair_id=repair_id,
|
||||
token_hash=token_hash,
|
||||
token_hint=token_hint,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(public_link)
|
||||
db.commit()
|
||||
db.refresh(public_link)
|
||||
return public_link
|
||||
|
||||
@staticmethod
|
||||
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
|
||||
now = datetime.now(UTC)
|
||||
links = list(
|
||||
db.scalars(
|
||||
select(RepairPublicAccessToken)
|
||||
.where(RepairPublicAccessToken.repair_id == repair_id)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
)
|
||||
)
|
||||
for link in links:
|
||||
link.is_active = False
|
||||
link.revoked_at = now
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
|
||||
public_link.last_used_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairNotificationEvent)
|
||||
.where(RepairNotificationEvent.repair_id == repair_id)
|
||||
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_notification_event(
|
||||
db: Session,
|
||||
*,
|
||||
repair_id: int,
|
||||
event_type: str,
|
||||
channel: str,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
template: str,
|
||||
status: str,
|
||||
success: bool,
|
||||
error_message: str | None = None,
|
||||
sent_at: datetime | None = None,
|
||||
) -> RepairNotificationEvent:
|
||||
event = RepairNotificationEvent(
|
||||
repair_id=repair_id,
|
||||
event_type=event_type,
|
||||
channel=channel,
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
template=template,
|
||||
status=status,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
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()
|
||||
data["customer_email"] = str(payload.customer_email or "")
|
||||
data["device_serial_number"] = payload.device_serial_number or None
|
||||
data["source_reference"] = payload.source_reference or None
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _apply_status_timestamps(repair: Repair) -> None:
|
||||
if repair.status == "approved" and repair.approved_at is None:
|
||||
repair.approved_at = datetime.now(UTC)
|
||||
if repair.status in {"completed", "cancelled"} and repair.completed_at is None:
|
||||
repair.completed_at = datetime.now(UTC)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
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
|
||||
57
backend/hermes/app/schemas/backup.py
Normal file
57
backend/hermes/app/schemas/backup.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
backup_id: str
|
||||
created_at: datetime
|
||||
app_version: str
|
||||
backup_type: str = "full"
|
||||
database_url_host_anonymized: str
|
||||
database_name: str
|
||||
storage_base_path: str
|
||||
included_sections: list[str] = Field(default_factory=list)
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
checksum_sha256: str
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
|
||||
|
||||
class BackupSummary(BaseModel):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: datetime | None = None
|
||||
app_version: str = ""
|
||||
backup_type: str = "full"
|
||||
database_name: str = ""
|
||||
storage_base_path: str = ""
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
validation_status: str = "valid"
|
||||
validation_message: str = ""
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
items: list[BackupSummary] = Field(default_factory=list)
|
||||
total_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
latest_backup_at: datetime | None = None
|
||||
|
||||
|
||||
class BackupValidationResponse(BaseModel):
|
||||
filename: str
|
||||
valid: bool
|
||||
message: str
|
||||
issues: list[str] = Field(default_factory=list)
|
||||
checksum_valid: bool = False
|
||||
restore_supported: bool = False
|
||||
requires_cli_restore: bool = True
|
||||
manifest: BackupManifest | None = None
|
||||
|
||||
|
||||
class BackupRestoreRequest(BaseModel):
|
||||
confirm_text: str
|
||||
|
|
@ -13,11 +13,20 @@ 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
|
||||
|
|
|
|||
231
backend/hermes/app/schemas/inventory.py
Normal file
231
backend/hermes/app/schemas/inventory.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
InventoryMovementType = Literal[
|
||||
"initial",
|
||||
"purchase",
|
||||
"adjustment",
|
||||
"reservation",
|
||||
"release",
|
||||
"consumption",
|
||||
"return",
|
||||
"correction",
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_optional_text(value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
||||
|
||||
class InventoryCategoryPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventoryLocationPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventorySupplierPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=180)
|
||||
contact_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
customer_number: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_optional_text(value)
|
||||
|
||||
|
||||
class InventoryItemPayload(BaseModel):
|
||||
sku: str | None = Field(default=None, max_length=40)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
category_id: int | None = None
|
||||
manufacturer: str | None = None
|
||||
manufacturer_part_number: str | None = None
|
||||
supplier_id: int | None = None
|
||||
supplier_part_number: str | None = None
|
||||
compatible_devices: str | None = None
|
||||
location_id: int | None = None
|
||||
quantity_on_hand: int = Field(default=0, ge=0)
|
||||
quantity_reserved: int = Field(default=0, ge=0)
|
||||
reorder_level: int = Field(default=0, ge=0)
|
||||
unit: str = Field(default="Stk.", max_length=40)
|
||||
purchase_price_cents: int | None = Field(default=None, ge=0)
|
||||
selling_price_cents: int | None = Field(default=None, ge=0)
|
||||
currency: str = Field(default="EUR", max_length=3)
|
||||
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
|
||||
notes: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator(
|
||||
"sku",
|
||||
"name",
|
||||
"description",
|
||||
"manufacturer",
|
||||
"manufacturer_part_number",
|
||||
"supplier_part_number",
|
||||
"compatible_devices",
|
||||
"unit",
|
||||
"currency",
|
||||
"notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def normalize_currency(cls, value: str) -> str:
|
||||
return value.upper() or "EUR"
|
||||
|
||||
@field_validator("tax_rate_percent", mode="before")
|
||||
@classmethod
|
||||
def normalize_tax_rate(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip().replace(",", ".")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reserved_quantity(self):
|
||||
if self.quantity_reserved > self.quantity_on_hand:
|
||||
raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||
return self
|
||||
|
||||
|
||||
class InventoryItemCreate(InventoryItemPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryItemUpdate(InventoryItemPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryStockAction(BaseModel):
|
||||
quantity: int = Field(ge=0)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
note: str | None = None
|
||||
reference_type: str | None = Field(default=None, max_length=80)
|
||||
reference_id: int | None = None
|
||||
|
||||
@field_validator("reason", "note", "reference_type", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventoryCategoryResponse(InventoryCategoryPayload):
|
||||
id: int
|
||||
slug: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryLocationResponse(InventoryLocationPayload):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventorySupplierResponse(InventorySupplierPayload):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
id: int
|
||||
sku: str
|
||||
name: str
|
||||
description: str | None
|
||||
category_id: int | None
|
||||
manufacturer: str | None
|
||||
manufacturer_part_number: str | None
|
||||
supplier_id: int | None
|
||||
supplier_part_number: str | None
|
||||
compatible_devices: str | None
|
||||
location_id: int | None
|
||||
quantity_on_hand: int
|
||||
quantity_reserved: int
|
||||
quantity_available: int
|
||||
reorder_level: int
|
||||
unit: str
|
||||
purchase_price_cents: int | None
|
||||
selling_price_cents: int | None
|
||||
currency: str
|
||||
tax_rate_percent: Decimal
|
||||
notes: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: InventoryCategoryResponse | None
|
||||
supplier: InventorySupplierResponse | None
|
||||
location: InventoryLocationResponse | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryItemListResponse(BaseModel):
|
||||
items: list[InventoryItemResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class InventoryStockMovementResponse(BaseModel):
|
||||
id: int
|
||||
item_id: int
|
||||
movement_type: str
|
||||
quantity: int
|
||||
reason: str
|
||||
reference_type: str | None
|
||||
reference_id: int | None
|
||||
note: str | None
|
||||
actor_user_id: int | None
|
||||
actor_username: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
200
backend/hermes/app/schemas/knowledge.py
Normal file
200
backend/hermes/app/schemas/knowledge.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
|
||||
|
||||
DocumentType = Literal[
|
||||
"manual",
|
||||
"service_manual",
|
||||
"schematic",
|
||||
"alignment",
|
||||
"parts_list",
|
||||
"firmware",
|
||||
"datasheet",
|
||||
"service_bulletin",
|
||||
"other",
|
||||
]
|
||||
NoteType = Literal["repair", "known_fault", "alignment", "spare_part", "general"]
|
||||
NoteSeverity = Literal["low", "medium", "high", "critical", ""]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_tags(value: object) -> list[str]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = value.split(",")
|
||||
elif isinstance(value, list):
|
||||
parts = value
|
||||
else:
|
||||
parts = []
|
||||
seen: set[str] = set()
|
||||
tags: list[str] = []
|
||||
for part in parts:
|
||||
tag = str(part).strip()
|
||||
key = tag.lower()
|
||||
if tag and key not in seen:
|
||||
seen.add(key)
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
class KnowledgeManufacturerBase(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
website: HttpUrl | None = None
|
||||
notes: str = ""
|
||||
|
||||
@field_validator("name", "notes", mode="before")
|
||||
@classmethod
|
||||
def normalize_text_fields(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class KnowledgeManufacturerCreate(KnowledgeManufacturerBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeManufacturerUpdate(KnowledgeManufacturerBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeManufacturerResponse(KnowledgeManufacturerBase):
|
||||
id: int
|
||||
slug: str
|
||||
website: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KnowledgeDeviceBase(BaseModel):
|
||||
manufacturer_id: int
|
||||
name: str = Field(min_length=2, max_length=180)
|
||||
model_number: str = Field(default="", max_length=120)
|
||||
device_type: str = Field(default="", max_length=80)
|
||||
frequency_range: str = Field(default="", max_length=120)
|
||||
production_year_from: int | None = Field(default=None, ge=1900, le=2100)
|
||||
production_year_to: int | None = Field(default=None, ge=1900, le=2100)
|
||||
notes: str = ""
|
||||
|
||||
@field_validator("name", "model_number", "device_type", "frequency_range", "notes", mode="before")
|
||||
@classmethod
|
||||
def normalize_text_fields(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class KnowledgeDeviceCreate(KnowledgeDeviceBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeDeviceUpdate(KnowledgeDeviceBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeDeviceResponse(KnowledgeDeviceBase):
|
||||
id: int
|
||||
slug: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
manufacturer: KnowledgeManufacturerResponse
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KnowledgeDocumentBase(BaseModel):
|
||||
manufacturer_id: int
|
||||
device_id: int | None = None
|
||||
title: str = Field(min_length=2, max_length=255)
|
||||
document_type: DocumentType
|
||||
language: str = Field(default="de", min_length=2, max_length=20)
|
||||
external_url: HttpUrl | None = None
|
||||
paperless_document_id: str = Field(default="", max_length=120)
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("title", "language", "paperless_document_id", "description", mode="before")
|
||||
@classmethod
|
||||
def normalize_text_fields(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("tags", mode="before")
|
||||
@classmethod
|
||||
def normalize_tag_fields(cls, value: object) -> list[str]:
|
||||
return normalize_tags(value)
|
||||
|
||||
|
||||
class KnowledgeDocumentCreate(KnowledgeDocumentBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeDocumentUpdate(KnowledgeDocumentBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeDocumentResponse(KnowledgeDocumentBase):
|
||||
id: int
|
||||
slug: str
|
||||
external_url: str
|
||||
file_name: str
|
||||
file_path: str
|
||||
mime_type: str
|
||||
file_size: int
|
||||
checksum_sha256: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
manufacturer: KnowledgeManufacturerResponse
|
||||
device: KnowledgeDeviceResponse | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KnowledgeNoteBase(BaseModel):
|
||||
manufacturer_id: int | None = None
|
||||
device_id: int | None = None
|
||||
title: str = Field(min_length=2, max_length=255)
|
||||
content: str = Field(min_length=1)
|
||||
note_type: NoteType = "general"
|
||||
severity: NoteSeverity = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("title", "content", "severity", mode="before")
|
||||
@classmethod
|
||||
def normalize_text_fields(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("tags", mode="before")
|
||||
@classmethod
|
||||
def normalize_tag_fields(cls, value: object) -> list[str]:
|
||||
return normalize_tags(value)
|
||||
|
||||
|
||||
class KnowledgeNoteCreate(KnowledgeNoteBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeNoteUpdate(KnowledgeNoteBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeNoteResponse(KnowledgeNoteBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
manufacturer: KnowledgeManufacturerResponse | None = None
|
||||
device: KnowledgeDeviceResponse | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KnowledgeSearchResult(BaseModel):
|
||||
entity_type: str
|
||||
id: int
|
||||
title: str
|
||||
subtitle: str = ""
|
||||
href: str
|
||||
96
backend/hermes/app/schemas/lexware.py
Normal file
96
backend/hermes/app/schemas/lexware.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.system_setting import SettingsSource, normalize_text
|
||||
|
||||
|
||||
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
|
||||
LexwareSyncDirection = Literal["push", "pull"]
|
||||
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
|
||||
|
||||
|
||||
class LexwareSettingsResponse(BaseModel):
|
||||
enabled: bool = False
|
||||
api_base_url: str = "https://api.lexware.io"
|
||||
api_key_is_set: bool = False
|
||||
organization_name: str = ""
|
||||
default_tax_rate: Decimal = Decimal("19.00")
|
||||
default_payment_terms_days: int = 14
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class LexwareSettingsUpdate(BaseModel):
|
||||
enabled: bool = False
|
||||
api_base_url: str = Field(default="https://api.lexware.io", max_length=500)
|
||||
api_key: str | None = Field(default=None, max_length=2000)
|
||||
organization_name: str = Field(default="", max_length=255)
|
||||
default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100"))
|
||||
default_payment_terms_days: int = Field(default=14, ge=0, le=365)
|
||||
|
||||
@field_validator("api_base_url", "api_key", "organization_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("api_base_url")
|
||||
@classmethod
|
||||
def normalize_base_url(cls, value: str) -> str:
|
||||
return (value or "https://api.lexware.io").rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_configuration(self):
|
||||
if self.enabled and not self.api_base_url:
|
||||
raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist")
|
||||
return self
|
||||
|
||||
|
||||
class LexwareTestConnectionResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
source: SettingsSource
|
||||
api_base_url: str
|
||||
organization_name: str = ""
|
||||
|
||||
|
||||
class LexwareCustomerMapping(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
phone: str
|
||||
search_strategy: str
|
||||
create_payload: dict
|
||||
|
||||
|
||||
class LexwareLineItemMapping(BaseModel):
|
||||
title: str
|
||||
description: str | None
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
unit_price: Decimal
|
||||
tax_rate: Decimal
|
||||
total: Decimal
|
||||
|
||||
|
||||
class LexwareInvoicePreparationResponse(BaseModel):
|
||||
ready_for_export: bool
|
||||
export_status: AccountingExportStatus
|
||||
payload_summary: dict
|
||||
customer_mapping: LexwareCustomerMapping
|
||||
line_item_mapping: list[LexwareLineItemMapping]
|
||||
tax_mapping: dict
|
||||
warnings: list[str]
|
||||
sync_record_id: int
|
||||
accounting_note: str = ""
|
||||
transferred_at: str | None = None
|
||||
transferred_by_user_id: int | None = None
|
||||
|
||||
|
||||
class AccountingTransferUpdate(BaseModel):
|
||||
accounting_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("accounting_note", mode="before")
|
||||
@classmethod
|
||||
def normalize_note(cls, value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
273
backend/hermes/app/schemas/repair.py
Normal file
273
backend/hermes/app/schemas/repair.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
from datetime import datetime
|
||||
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",
|
||||
"diagnosis",
|
||||
"estimate",
|
||||
"waiting_for_customer",
|
||||
"approved",
|
||||
"repair",
|
||||
"final_test",
|
||||
"ready_for_pickup",
|
||||
"shipped",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
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:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class RepairBase(BaseModel):
|
||||
customer_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
status: RepairStatus = "new"
|
||||
priority: RepairPriority = "normal"
|
||||
source: RepairSource = "manual"
|
||||
source_reference: str | None = Field(default=None, max_length=120)
|
||||
customer_name: str = Field(default="", max_length=255)
|
||||
customer_email: EmailStr | None = None
|
||||
customer_phone: str = Field(default="", max_length=80)
|
||||
device_manufacturer: str = Field(min_length=1, max_length=160)
|
||||
device_model: str = Field(min_length=1, max_length=160)
|
||||
device_serial_number: str | None = Field(default=None, max_length=160)
|
||||
device_type: str = Field(default="", max_length=120)
|
||||
accessories: str = ""
|
||||
fault_description: str = Field(min_length=1)
|
||||
previous_work: str = ""
|
||||
device_opened: bool = False
|
||||
intake_notes: str = ""
|
||||
diagnosis_notes: str = ""
|
||||
repair_notes: str = ""
|
||||
estimate_notes: str = ""
|
||||
estimated_cost_cents: int | None = Field(default=None, ge=0)
|
||||
|
||||
@field_validator(
|
||||
"source_reference",
|
||||
"customer_name",
|
||||
"customer_phone",
|
||||
"device_manufacturer",
|
||||
"device_model",
|
||||
"device_serial_number",
|
||||
"device_type",
|
||||
"accessories",
|
||||
"fault_description",
|
||||
"previous_work",
|
||||
"intake_notes",
|
||||
"diagnosis_notes",
|
||||
"repair_notes",
|
||||
"estimate_notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_customer_identity(self):
|
||||
if self.customer_id is None and not self.customer_name:
|
||||
raise ValueError("Kundenname ist erforderlich, wenn kein Kunde ausgewählt ist")
|
||||
return self
|
||||
|
||||
|
||||
class RepairCreate(RepairBase):
|
||||
pass
|
||||
|
||||
|
||||
class RepairUpdate(RepairBase):
|
||||
pass
|
||||
|
||||
|
||||
class RepairStatusUpdate(BaseModel):
|
||||
status: RepairStatus
|
||||
note: str | None = None
|
||||
|
||||
@field_validator("note", mode="before")
|
||||
@classmethod
|
||||
def normalize_note(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
||||
|
||||
class RepairResponse(RepairBase):
|
||||
id: int
|
||||
repair_number: str
|
||||
customer_email: str
|
||||
device_serial_number: str | None
|
||||
approved_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RepairListResponse(BaseModel):
|
||||
items: list[RepairResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class RepairStatusHistoryResponse(BaseModel):
|
||||
id: int
|
||||
repair_id: int
|
||||
old_status: str | None
|
||||
new_status: str
|
||||
note: str | None
|
||||
actor_user_id: int | None
|
||||
actor_username: str = ""
|
||||
actor_display_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RepairIntakePayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
email: EmailStr | None = None
|
||||
phone: str = Field(default="", max_length=80)
|
||||
manufacturer: str = Field(min_length=1, max_length=160)
|
||||
model: str = Field(min_length=1, max_length=160)
|
||||
serial_number: str | None = Field(default=None, max_length=160)
|
||||
device_type: str = Field(default="", max_length=120)
|
||||
accessories: str = ""
|
||||
fault_description: str = Field(min_length=1)
|
||||
previous_work: str = ""
|
||||
device_opened: bool = False
|
||||
source_reference: str | None = Field(default=None, max_length=120)
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def normalize_payload(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
|
||||
class RepairIntakeResponse(BaseModel):
|
||||
repair_number: str
|
||||
status: RepairStatus
|
||||
message: str
|
||||
|
||||
|
||||
class RepairPublicLinkResponse(BaseModel):
|
||||
is_active: bool
|
||||
token_hint: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
public_status_path: str | None = None
|
||||
|
||||
|
||||
class RepairPublicLinkCreateResponse(RepairPublicLinkResponse):
|
||||
token: str
|
||||
public_status_path: str
|
||||
|
||||
|
||||
class RepairPublicStatusHistoryItem(BaseModel):
|
||||
status: RepairStatus
|
||||
status_label: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RepairPublicStatusResponse(BaseModel):
|
||||
repair_number: str
|
||||
public_status_label: str
|
||||
device_manufacturer: str
|
||||
device_model: str
|
||||
status_history_public: list[RepairPublicStatusHistoryItem]
|
||||
updated_at: datetime
|
||||
estimate: PublicEstimateResponse | None = None
|
||||
|
||||
|
||||
class RepairNotificationTemplateResponse(BaseModel):
|
||||
event_type: str
|
||||
status: RepairStatus | None = None
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
|
||||
|
||||
class RepairNotificationEventResponse(BaseModel):
|
||||
id: int
|
||||
repair_id: int
|
||||
event_type: str
|
||||
channel: str
|
||||
recipient: str
|
||||
subject: str
|
||||
template: str
|
||||
status: str
|
||||
success: bool
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
sent_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
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)
|
||||
208
backend/hermes/app/schemas/repair_estimate.py
Normal file
208
backend/hermes/app/schemas/repair_estimate.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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)
|
||||
70
backend/hermes/app/schemas/system_setting.py
Normal file
70
backend/hermes/app/schemas/system_setting.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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("/")
|
||||
|
|
@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"}
|
||||
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
|
||||
|
||||
|
||||
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
|
||||
|
|
@ -130,7 +130,98 @@ def action_title(action: str) -> str:
|
|||
"customer_contacts.create": "Ansprechpartner erstellt",
|
||||
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
||||
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
||||
"knowledge.manufacturers.create": "Hersteller erstellt",
|
||||
"knowledge.manufacturers.update": "Hersteller bearbeitet",
|
||||
"knowledge.manufacturers.delete": "Hersteller gelöscht",
|
||||
"knowledge.devices.create": "Gerät erstellt",
|
||||
"knowledge.devices.update": "Gerät bearbeitet",
|
||||
"knowledge.devices.delete": "Gerät gelöscht",
|
||||
"knowledge.documents.create": "Dokument erstellt",
|
||||
"knowledge.documents.upload": "Dokument hochgeladen",
|
||||
"knowledge.documents.update": "Dokument bearbeitet",
|
||||
"knowledge.documents.delete": "Dokument gelöscht",
|
||||
"knowledge.notes.create": "Notiz erstellt",
|
||||
"knowledge.notes.update": "Notiz bearbeitet",
|
||||
"knowledge.notes.delete": "Notiz gelöscht",
|
||||
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
|
||||
"knowledge.manufacturers.create": "Hersteller erstellt",
|
||||
"knowledge.manufacturers.update": "Hersteller bearbeitet",
|
||||
"knowledge.manufacturers.delete": "Hersteller gelöscht",
|
||||
"knowledge.devices.create": "Gerät erstellt",
|
||||
"knowledge.devices.update": "Gerät bearbeitet",
|
||||
"knowledge.devices.delete": "Gerät gelöscht",
|
||||
"knowledge.documents.create": "Dokument erstellt",
|
||||
"knowledge.documents.upload": "Dokument hochgeladen",
|
||||
"knowledge.documents.update": "Dokument bearbeitet",
|
||||
"knowledge.documents.delete": "Dokument gelöscht",
|
||||
"knowledge.notes.create": "Wissensnotiz erstellt",
|
||||
"knowledge.notes.update": "Wissensnotiz bearbeitet",
|
||||
"knowledge.notes.delete": "Wissensnotiz gelöscht",
|
||||
"repairs.create": "Reparatur erstellt",
|
||||
"repairs.update": "Reparatur bearbeitet",
|
||||
"repairs.status_update": "Reparaturstatus geändert",
|
||||
"repairs.cancel": "Reparatur storniert",
|
||||
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
|
||||
"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)
|
||||
|
||||
|
|
|
|||
373
backend/hermes/app/services/backup_service.py
Normal file
373
backend/hermes/app/services/backup_service.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.backup import (
|
||||
BackupListResponse,
|
||||
BackupManifest,
|
||||
BackupSummary,
|
||||
BackupValidationResponse,
|
||||
)
|
||||
|
||||
BACKUP_FILENAME_PREFIX = "olympus-backup-"
|
||||
BACKUP_FILENAME_SUFFIX = ".zip"
|
||||
DATABASE_DUMP_NAME = "database.dump"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
STORAGE_DIR_NAME = "storage"
|
||||
BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
|
||||
RESTORE_DISABLED_MESSAGE = (
|
||||
"Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
|
||||
"Bitte Restore ueber CLI-Script ausfuehren."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupStats:
|
||||
total_count: int
|
||||
total_size_bytes: int
|
||||
latest_backup_at: datetime | None
|
||||
|
||||
|
||||
class BackupService:
|
||||
@staticmethod
|
||||
def get_backup_dir() -> Path:
|
||||
backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
return backup_dir
|
||||
|
||||
@staticmethod
|
||||
def list_backups() -> BackupListResponse:
|
||||
items = [
|
||||
BackupService._read_summary(path)
|
||||
for path in sorted(
|
||||
BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
|
||||
return BackupListResponse(
|
||||
items=items,
|
||||
total_count=len(items),
|
||||
total_size_bytes=sum(item.size_bytes for item in items),
|
||||
latest_backup_at=latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_backup_stats() -> BackupStats:
|
||||
backups = BackupService.list_backups()
|
||||
return BackupStats(
|
||||
total_count=backups.total_count,
|
||||
total_size_bytes=backups.total_size_bytes,
|
||||
latest_backup_at=backups.latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_backup(*, actor: User) -> BackupSummary:
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
|
||||
target_path = backup_dir / filename
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
|
||||
temp_dir = Path(temp_dir_name)
|
||||
dump_path = temp_dir / DATABASE_DUMP_NAME
|
||||
storage_temp_dir = temp_dir / STORAGE_DIR_NAME
|
||||
manifest_path = temp_dir / MANIFEST_NAME
|
||||
archive_path = temp_dir / filename
|
||||
|
||||
BackupService._run_pg_dump(dump_path)
|
||||
file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
|
||||
dump_size = dump_path.stat().st_size
|
||||
checksum_sha256 = BackupService._calculate_archive_checksum(
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
|
||||
manifest = BackupManifest(
|
||||
backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
|
||||
created_at=datetime.now(UTC),
|
||||
app_version=settings.app_version,
|
||||
backup_type="full",
|
||||
database_url_host_anonymized=BackupService._anonymized_database_host(),
|
||||
database_name=BackupService._database_name(),
|
||||
storage_base_path=settings.storage_base_path,
|
||||
included_sections=["database", "storage"],
|
||||
file_count=file_count + 1,
|
||||
total_size_bytes=total_size_bytes + dump_size,
|
||||
checksum_sha256=checksum_sha256,
|
||||
created_by_user_id=actor.id,
|
||||
created_by_username=actor.username,
|
||||
)
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
BackupService._write_archive(
|
||||
archive_path=archive_path,
|
||||
manifest_path=manifest_path,
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
shutil.move(str(archive_path), target_path)
|
||||
|
||||
return BackupService._read_summary(target_path)
|
||||
|
||||
@staticmethod
|
||||
def validate_backup(filename: str) -> BackupValidationResponse:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
issues: list[str] = []
|
||||
manifest: BackupManifest | None = None
|
||||
checksum_valid = False
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = set(archive.namelist())
|
||||
if MANIFEST_NAME not in names:
|
||||
issues.append("manifest.json fehlt")
|
||||
if DATABASE_DUMP_NAME not in names:
|
||||
issues.append("database.dump fehlt")
|
||||
if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
|
||||
issues.append("storage/ fehlt")
|
||||
|
||||
if MANIFEST_NAME in names:
|
||||
try:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
except Exception:
|
||||
issues.append("manifest.json ist ungueltig")
|
||||
|
||||
if manifest is not None:
|
||||
checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
|
||||
if not checksum_valid:
|
||||
issues.append("Checksumme ist ungueltig")
|
||||
except zipfile.BadZipFile:
|
||||
issues.append("ZIP-Datei ist ungueltig")
|
||||
|
||||
valid = len(issues) == 0
|
||||
return BackupValidationResponse(
|
||||
filename=path.name,
|
||||
valid=valid,
|
||||
message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
|
||||
issues=issues,
|
||||
checksum_valid=checksum_valid,
|
||||
restore_supported=False,
|
||||
requires_cli_restore=True,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
|
||||
if confirm_text != BACKUP_CONFIRM_TEXT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Bestaetigungstext stimmt nicht ueberein",
|
||||
)
|
||||
|
||||
validation = BackupService.validate_backup(filename)
|
||||
if not validation.valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=RESTORE_DISABLED_MESSAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_backup(filename: str) -> None:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
path.unlink(missing_ok=False)
|
||||
|
||||
@staticmethod
|
||||
def resolve_backup_path(filename: str) -> Path:
|
||||
if not filename.endswith(BACKUP_FILENAME_SUFFIX):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
if Path(filename).name != filename or ".." in Path(filename).parts:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
|
||||
path = (BackupService.get_backup_dir() / filename).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
if backup_dir != path.parent or not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _run_pg_dump(dump_path: Path) -> None:
|
||||
pg_dump_url = BackupService._pg_dump_database_url()
|
||||
command = [
|
||||
"pg_dump",
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
f"--file={dump_path}",
|
||||
f"--dbname={pg_dump_url}",
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="pg_dump ist im Hermes-Container nicht verfuegbar",
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="PostgreSQL-Dump konnte nicht erstellt werden",
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
|
||||
source_dir = Path(settings.storage_base_path).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_count = 0
|
||||
total_size_bytes = 0
|
||||
|
||||
for source_path in sorted(source_dir.rglob("*")):
|
||||
if source_path == backup_dir or backup_dir in source_path.parents:
|
||||
continue
|
||||
relative_path = source_path.relative_to(source_dir)
|
||||
destination_path = target_dir / relative_path
|
||||
if source_path.is_dir():
|
||||
destination_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not source_path.is_file():
|
||||
continue
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination_path)
|
||||
file_count += 1
|
||||
total_size_bytes += source_path.stat().st_size
|
||||
|
||||
return file_count, total_size_bytes
|
||||
|
||||
@staticmethod
|
||||
def _write_archive(
|
||||
*,
|
||||
archive_path: Path,
|
||||
manifest_path: Path,
|
||||
dump_path: Path,
|
||||
storage_dir: Path,
|
||||
) -> None:
|
||||
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(f"{STORAGE_DIR_NAME}/", "")
|
||||
archive.write(manifest_path, MANIFEST_NAME)
|
||||
archive.write(dump_path, DATABASE_DUMP_NAME)
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
|
||||
|
||||
@staticmethod
|
||||
def _read_summary(path: Path) -> BackupSummary:
|
||||
default_summary = BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
validation_status="warning",
|
||||
validation_message="Manifest konnte nicht gelesen werden",
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
return BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
created_at=manifest.created_at,
|
||||
app_version=manifest.app_version,
|
||||
backup_type=manifest.backup_type,
|
||||
database_name=manifest.database_name,
|
||||
storage_base_path=manifest.storage_base_path,
|
||||
file_count=manifest.file_count,
|
||||
total_size_bytes=manifest.total_size_bytes,
|
||||
created_by_user_id=manifest.created_by_user_id,
|
||||
created_by_username=manifest.created_by_username,
|
||||
validation_status="valid",
|
||||
validation_message="Backup ist lesbar",
|
||||
)
|
||||
except Exception:
|
||||
return default_summary
|
||||
|
||||
@staticmethod
|
||||
def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, dump_path)
|
||||
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, file_path)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
|
||||
digest = hashlib.sha256()
|
||||
if DATABASE_DUMP_NAME not in archive.namelist():
|
||||
return False
|
||||
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
with archive.open(DATABASE_DUMP_NAME) as dump_file:
|
||||
BackupService._update_digest_from_stream(digest, dump_file)
|
||||
|
||||
for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
|
||||
digest.update(name.encode("utf-8"))
|
||||
with archive.open(name) as storage_file:
|
||||
BackupService._update_digest_from_stream(digest, storage_file)
|
||||
|
||||
return digest.hexdigest() == expected_checksum
|
||||
|
||||
@staticmethod
|
||||
def _anonymized_database_host() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
host = parsed.host or "unknown"
|
||||
digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _database_name() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
return parsed.database or "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _pg_dump_database_url() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
normalized = parsed.set(drivername="postgresql")
|
||||
return normalized.render_as_string(hide_password=False)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_file(digest, file_path: Path) -> None:
|
||||
with file_path.open("rb") as file_handle:
|
||||
BackupService._update_digest_from_stream(digest, file_handle)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_stream(digest, stream) -> None:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
415
backend/hermes/app/services/inventory_service.py
Normal file
415
backend/hermes/app/services/inventory_service.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.schemas.inventory import (
|
||||
InventoryCategoryPayload,
|
||||
InventoryItemCreate,
|
||||
InventoryItemUpdate,
|
||||
InventoryLocationPayload,
|
||||
InventoryStockAction,
|
||||
InventorySupplierPayload,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
|
||||
MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
|
||||
return slug or "eintrag"
|
||||
|
||||
|
||||
def _item_label(item: InventoryItem) -> str:
|
||||
return f"{item.sku} · {item.name}"
|
||||
|
||||
|
||||
def _audit_item_data(item: InventoryItem) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"sku": item.sku,
|
||||
"name": item.name,
|
||||
"category_id": item.category_id,
|
||||
"supplier_id": item.supplier_id,
|
||||
"location_id": item.location_id,
|
||||
"quantity_on_hand": item.quantity_on_hand,
|
||||
"quantity_reserved": item.quantity_reserved,
|
||||
"quantity_available": item.quantity_available,
|
||||
"reorder_level": item.reorder_level,
|
||||
"selling_price_cents": item.selling_price_cents,
|
||||
"currency": item.currency,
|
||||
"is_active": item.is_active,
|
||||
}
|
||||
|
||||
|
||||
class InventoryService:
|
||||
@staticmethod
|
||||
def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
|
||||
sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
|
||||
InventoryService._validate_sku_available(db, sku)
|
||||
item = InventoryItem(sku=sku)
|
||||
InventoryService._apply_item_payload(item, payload)
|
||||
try:
|
||||
db.add(item)
|
||||
db.flush()
|
||||
if item.quantity_on_hand > 0:
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="initial",
|
||||
quantity=item.quantity_on_hand,
|
||||
reason="Anfangsbestand",
|
||||
reference_type=None,
|
||||
reference_id=None,
|
||||
note=None,
|
||||
actor_user_id=actor.id,
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||
db.refresh(item)
|
||||
item = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.create",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=_item_label(item),
|
||||
actor=actor,
|
||||
request=request,
|
||||
after_data=_audit_item_data(item),
|
||||
)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
|
||||
before_data = _audit_item_data(item)
|
||||
sku = (payload.sku or "").upper() or item.sku
|
||||
InventoryService._validate_sku_available(db, sku, item_id=item.id)
|
||||
item.sku = sku
|
||||
InventoryService._apply_item_payload(item, payload)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||
db.refresh(item)
|
||||
updated = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.update",
|
||||
entity_type="inventory_items",
|
||||
entity_id=updated.id,
|
||||
entity_label=_item_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(updated),
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
|
||||
before_data = _audit_item_data(item)
|
||||
label = _item_label(item)
|
||||
if InventoryRepository.has_movements(db, item.id):
|
||||
item.is_active = False
|
||||
InventoryService._recalculate_available(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.deactivate",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(item),
|
||||
)
|
||||
return InventoryRepository.get_item(db, item.id) or item
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.delete",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_on_hand = payload.quantity
|
||||
if item.quantity_reserved > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||
InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_available:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_reserved += payload.quantity
|
||||
InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_reserved:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_reserved -= payload.quantity
|
||||
InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_on_hand -= payload.quantity
|
||||
item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
|
||||
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None or not item.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
if quantity > item.quantity_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
|
||||
)
|
||||
item.quantity_reserved += quantity
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="reservation",
|
||||
quantity=quantity,
|
||||
reason="estimate_reserved",
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def release_estimate_reservation(
|
||||
db: Session,
|
||||
estimate: RepairEstimate,
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
reason: str = "estimate_released",
|
||||
) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None:
|
||||
continue
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
item.quantity_reserved = max(0, item.quantity_reserved - quantity)
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="release",
|
||||
quantity=quantity,
|
||||
reason=reason,
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None:
|
||||
continue
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
|
||||
item.quantity_reserved -= quantity
|
||||
item.quantity_on_hand -= quantity
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="consumption",
|
||||
quantity=quantity,
|
||||
reason="estimate_consumed",
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||
return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||
before_data = {"id": category.id, "name": category.name, "slug": category.slug}
|
||||
category.name = payload.name
|
||||
category.slug = _slugify(payload.name)
|
||||
category.description = payload.description
|
||||
return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||
location = InventoryLocation(name=payload.name, description=payload.description)
|
||||
return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||
before_data = {"id": location.id, "name": location.name}
|
||||
location.name = payload.name
|
||||
location.description = payload.description
|
||||
return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||
supplier = InventorySupplier(**payload.model_dump())
|
||||
return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||
before_data = {"id": supplier.id, "name": supplier.name}
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(supplier, key, value)
|
||||
return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
|
||||
label = getattr(entity, "name", "")
|
||||
before_data = {"id": entity.id, "name": label}
|
||||
db.delete(entity)
|
||||
db.commit()
|
||||
write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
|
||||
for key, value in payload.model_dump(exclude={"sku"}).items():
|
||||
setattr(item, key, value)
|
||||
InventoryService._recalculate_available(item)
|
||||
|
||||
@staticmethod
|
||||
def _recalculate_available(item: InventoryItem) -> None:
|
||||
item.quantity_available = item.quantity_on_hand - item.quantity_reserved
|
||||
|
||||
@staticmethod
|
||||
def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
|
||||
existing = InventoryRepository.get_item_by_sku(db, sku)
|
||||
if existing is not None and existing.id != item_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
|
||||
|
||||
@staticmethod
|
||||
def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
|
||||
if payload.quantity <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
||||
|
||||
@staticmethod
|
||||
def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
|
||||
if quantity != quantity.to_integral_value():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
|
||||
)
|
||||
normalized = int(quantity)
|
||||
if normalized <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type=movement_type,
|
||||
quantity=payload.quantity,
|
||||
reason=payload.reason,
|
||||
reference_type=payload.reference_type,
|
||||
reference_id=payload.reference_id,
|
||||
note=payload.note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._recalculate_available(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
updated = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action=action,
|
||||
entity_type="inventory_items",
|
||||
entity_id=updated.id,
|
||||
entity_label=_item_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(updated),
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
|
||||
try:
|
||||
db.add(entity)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
|
||||
db.refresh(entity)
|
||||
label = getattr(entity, "name", "")
|
||||
write_audit_log(
|
||||
db,
|
||||
action=action,
|
||||
entity_type=entity.__tablename__,
|
||||
entity_id=entity.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data={"id": entity.id, "name": label},
|
||||
)
|
||||
return entity
|
||||
251
backend/hermes/app/services/knowledge_service.py
Normal file
251
backend/hermes/app/services/knowledge_service.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote
|
||||
from app.repositories.knowledge_repository import KnowledgeRepository
|
||||
from app.schemas.knowledge import (
|
||||
KnowledgeDeviceCreate,
|
||||
KnowledgeDeviceUpdate,
|
||||
KnowledgeDocumentCreate,
|
||||
KnowledgeDocumentUpdate,
|
||||
KnowledgeManufacturerCreate,
|
||||
KnowledgeManufacturerUpdate,
|
||||
KnowledgeNoteCreate,
|
||||
KnowledgeNoteUpdate,
|
||||
)
|
||||
from app.storage import get_storage_service
|
||||
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
normalized = normalized.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||
normalized = normalized.strip("-")
|
||||
return normalized or uuid.uuid4().hex[:10]
|
||||
|
||||
|
||||
def unique_slug(db: Session, base: str, exists) -> str:
|
||||
slug = slugify(base)
|
||||
candidate = slug
|
||||
index = 2
|
||||
while exists(db, candidate):
|
||||
candidate = f"{slug}-{index}"
|
||||
index += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_tags(value: str) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
tags: list[str] = []
|
||||
for item in value.split(","):
|
||||
tag = item.strip()
|
||||
key = tag.lower()
|
||||
if tag and key not in seen:
|
||||
seen.add(key)
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class KnowledgeService:
|
||||
@staticmethod
|
||||
def create_manufacturer(db: Session, payload: KnowledgeManufacturerCreate) -> KnowledgeManufacturer:
|
||||
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
|
||||
slug = unique_slug(db, payload.name, lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None)
|
||||
manufacturer = KnowledgeManufacturer(name=payload.name, slug=slug, website=str(payload.website or ""), notes=payload.notes)
|
||||
db.add(manufacturer)
|
||||
db.commit()
|
||||
db.refresh(manufacturer)
|
||||
return manufacturer
|
||||
|
||||
@staticmethod
|
||||
def update_manufacturer(db: Session, manufacturer: KnowledgeManufacturer, payload: KnowledgeManufacturerUpdate) -> KnowledgeManufacturer:
|
||||
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name, manufacturer.id):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
|
||||
manufacturer.name = payload.name
|
||||
manufacturer.slug = unique_slug(
|
||||
db,
|
||||
payload.name,
|
||||
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value, manufacturer.id) is not None,
|
||||
)
|
||||
manufacturer.website = str(payload.website or "")
|
||||
manufacturer.notes = payload.notes
|
||||
db.commit()
|
||||
db.refresh(manufacturer)
|
||||
return manufacturer
|
||||
|
||||
@staticmethod
|
||||
def create_device(db: Session, payload: KnowledgeDeviceCreate) -> KnowledgeDevice:
|
||||
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||
slug = slugify(payload.model_number or payload.name)
|
||||
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
|
||||
device = KnowledgeDevice(slug=slug, **payload.model_dump())
|
||||
db.add(device)
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_device(db, device.id) or device
|
||||
|
||||
@staticmethod
|
||||
def update_device(db: Session, device: KnowledgeDevice, payload: KnowledgeDeviceUpdate) -> KnowledgeDevice:
|
||||
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||
slug = slugify(payload.model_number or payload.name)
|
||||
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug, exclude_id=device.id):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(device, key, value)
|
||||
device.slug = slug
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_device(db, device.id) or device
|
||||
|
||||
@staticmethod
|
||||
def create_document(db: Session, payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
|
||||
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
|
||||
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
|
||||
document = KnowledgeDocument(slug=slug, external_url=str(payload.external_url or ""), **payload.model_dump(exclude={"external_url"}))
|
||||
db.add(document)
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_document(db, document.id) or document
|
||||
|
||||
@staticmethod
|
||||
async def upload_document(
|
||||
db: Session,
|
||||
*,
|
||||
file: UploadFile,
|
||||
payload: KnowledgeDocumentCreate,
|
||||
) -> KnowledgeDocument:
|
||||
KnowledgeService._validate_upload_links(db, payload.manufacturer_id, payload.device_id)
|
||||
storage_service = get_storage_service()
|
||||
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
||||
content = await file.read(max_bytes + 1)
|
||||
original_name = file.filename or ""
|
||||
mime_type = file.content_type
|
||||
try:
|
||||
metadata = storage_service.save_file(
|
||||
namespace=f"knowledge/documents/{payload.manufacturer_id}",
|
||||
content=content,
|
||||
original_filename=original_name,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
except StorageValidationError as exc:
|
||||
raise storage_validation_error(exc) from exc
|
||||
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
|
||||
document = KnowledgeDocument(
|
||||
slug=slug,
|
||||
external_url=str(payload.external_url or ""),
|
||||
file_name=metadata.original_filename,
|
||||
file_path=metadata.storage_key,
|
||||
mime_type=metadata.mime_type,
|
||||
file_size=metadata.size,
|
||||
checksum_sha256=metadata.checksum_sha256,
|
||||
**payload.model_dump(exclude={"external_url"}),
|
||||
)
|
||||
db.add(document)
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_document(db, document.id) or document
|
||||
|
||||
@staticmethod
|
||||
def update_document(db: Session, document: KnowledgeDocument, payload: KnowledgeDocumentUpdate) -> KnowledgeDocument:
|
||||
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
|
||||
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value, document.id) is not None)
|
||||
for key, value in payload.model_dump(exclude={"external_url"}).items():
|
||||
setattr(document, key, value)
|
||||
document.external_url = str(payload.external_url or "")
|
||||
document.slug = slug
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_document(db, document.id) or document
|
||||
|
||||
@staticmethod
|
||||
def create_note(db: Session, payload: KnowledgeNoteCreate) -> KnowledgeNote:
|
||||
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
|
||||
note = KnowledgeNote(**payload.model_dump())
|
||||
db.add(note)
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_note(db, note.id) or note
|
||||
|
||||
@staticmethod
|
||||
def open_document_file(document: KnowledgeDocument):
|
||||
if not document.file_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
|
||||
try:
|
||||
return get_storage_service().open_file(document.file_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 delete_document_file(document: KnowledgeDocument) -> None:
|
||||
if not document.file_path:
|
||||
return
|
||||
KnowledgeService.delete_storage_key(document.file_path)
|
||||
|
||||
@staticmethod
|
||||
def delete_storage_key(storage_key: str) -> None:
|
||||
try:
|
||||
get_storage_service().delete_file(storage_key)
|
||||
except StorageValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||
|
||||
@staticmethod
|
||||
def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote:
|
||||
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(note, key, value)
|
||||
db.commit()
|
||||
return KnowledgeRepository.get_note(db, note.id) or note
|
||||
|
||||
@staticmethod
|
||||
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
|
||||
if not manufacturer_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
|
||||
if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||
if device_id is not None:
|
||||
device = KnowledgeRepository.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
if device.manufacturer_id != manufacturer_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
|
||||
|
||||
@staticmethod
|
||||
def _validate_optional_links(db: Session, manufacturer_id: int | None, device_id: int | None) -> None:
|
||||
if manufacturer_id is not None and KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||
if device_id is not None:
|
||||
device = KnowledgeRepository.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
if manufacturer_id is not None and device.manufacturer_id != manufacturer_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
|
||||
|
||||
@staticmethod
|
||||
def _validate_upload_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
|
||||
if not manufacturer_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
|
||||
if device_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie ein Gerät aus.")
|
||||
|
||||
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
|
||||
if manufacturer is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Der ausgewählte Hersteller existiert nicht.")
|
||||
|
||||
device = KnowledgeRepository.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Das ausgewählte Gerät existiert nicht.")
|
||||
if device.manufacturer_id != manufacturer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Das ausgewählte Gerät gehört nicht zum ausgewählten Hersteller.",
|
||||
)
|
||||
518
backend/hermes/app/services/lexware_service.py
Normal file
518
backend/hermes/app/services/lexware_service.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request as UrlRequest
|
||||
from urllib.request import urlopen
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.lexware import LexwareSyncRecord
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareCustomerMapping,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareLineItemMapping,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.schemas.system_setting import SettingsSource
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.system_settings_service import parse_bool
|
||||
|
||||
|
||||
LEXWARE_KEYS = (
|
||||
"lexware.enabled",
|
||||
"lexware.api_base_url",
|
||||
"lexware.api_key",
|
||||
"lexware.organization_name",
|
||||
"lexware.default_tax_rate",
|
||||
"lexware.default_payment_terms_days",
|
||||
)
|
||||
|
||||
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
|
||||
DEFAULT_API_BASE_URL = "https://api.lexware.io"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LexwareRuntimeConfig:
|
||||
enabled: bool
|
||||
api_base_url: str
|
||||
api_key: str
|
||||
organization_name: str
|
||||
default_tax_rate: Decimal
|
||||
default_payment_terms_days: int
|
||||
source: SettingsSource
|
||||
|
||||
@property
|
||||
def api_key_is_set(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.enabled and bool(self.api_base_url and self.api_key)
|
||||
|
||||
|
||||
def _decimal(value: object, *, default: Decimal) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "").replace(",", "."))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _int(value: object, *, default: int) -> int:
|
||||
try:
|
||||
parsed = int(str(value or "").strip())
|
||||
except ValueError:
|
||||
return default
|
||||
return parsed if 0 <= parsed <= 365 else default
|
||||
|
||||
|
||||
def _euros(cents: int) -> Decimal:
|
||||
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _safe_error_message(error: Exception) -> str:
|
||||
if isinstance(error, HTTPError):
|
||||
if error.code in {401, 403}:
|
||||
return "Lexware hat den API-Key abgelehnt."
|
||||
if error.code == 404:
|
||||
return "Lexware-Endpunkt wurde nicht gefunden."
|
||||
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
|
||||
if isinstance(error, URLError):
|
||||
return "Lexware ist momentan nicht erreichbar."
|
||||
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
|
||||
|
||||
|
||||
class LexwareService:
|
||||
@staticmethod
|
||||
def get_settings(db: Session) -> LexwareSettingsResponse:
|
||||
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
|
||||
|
||||
@staticmethod
|
||||
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
|
||||
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
|
||||
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
|
||||
db_api_key = values.get("lexware.api_key") or ""
|
||||
db_has_config = db_enabled or db_base_url or db_api_key
|
||||
|
||||
if db_has_config:
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=db_enabled,
|
||||
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
|
||||
api_key=db_api_key,
|
||||
organization_name=values.get("lexware.organization_name", ""),
|
||||
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
|
||||
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
|
||||
source="database",
|
||||
)
|
||||
|
||||
if settings.lexware_enabled or settings.lexware_api_key:
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=settings.lexware_enabled,
|
||||
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
|
||||
api_key=settings.lexware_api_key or "",
|
||||
organization_name="",
|
||||
default_tax_rate=Decimal("19.00"),
|
||||
default_payment_terms_days=14,
|
||||
source="environment",
|
||||
)
|
||||
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=False,
|
||||
api_base_url=DEFAULT_API_BASE_URL,
|
||||
api_key="",
|
||||
organization_name="",
|
||||
default_tax_rate=Decimal("19.00"),
|
||||
default_payment_terms_days=14,
|
||||
source="missing",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
|
||||
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
|
||||
updates = {
|
||||
"lexware.enabled": "true" if payload.enabled else "false",
|
||||
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
|
||||
"lexware.api_key": api_key,
|
||||
"lexware.organization_name": payload.organization_name,
|
||||
"lexware.default_tax_rate": str(payload.default_tax_rate),
|
||||
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
|
||||
}
|
||||
for key, value in updates.items():
|
||||
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.settings.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"enabled": payload.enabled,
|
||||
"api_base_url": payload.api_base_url,
|
||||
"organization_name": payload.organization_name,
|
||||
"default_tax_rate": str(payload.default_tax_rate),
|
||||
"default_payment_terms_days": payload.default_payment_terms_days,
|
||||
"api_key_changed": bool(payload.api_key),
|
||||
},
|
||||
)
|
||||
return LexwareService.get_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
if not config.is_configured:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "lexware_not_configured", "source": config.source},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=False,
|
||||
message="Lexware ist nicht vollständig konfiguriert.",
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=config.organization_name,
|
||||
)
|
||||
|
||||
try:
|
||||
profile = LexwareService._get_profile(config)
|
||||
except Exception as exc:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=False,
|
||||
message=_safe_error_message(exc),
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=config.organization_name,
|
||||
)
|
||||
|
||||
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_success",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=True,
|
||||
message="Lexware-Verbindung erfolgreich geprüft.",
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=organization_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prepare_invoice(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
if estimate.status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
|
||||
|
||||
warnings: list[str] = []
|
||||
if not config.enabled:
|
||||
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
|
||||
if not config.api_key_is_set:
|
||||
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
|
||||
if not repair.customer_email:
|
||||
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not estimate.items:
|
||||
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
|
||||
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
export_status="prepared",
|
||||
accounting_note=estimate.accounting_note,
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
estimate.accounting_export_status = "prepared"
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.handoff",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
},
|
||||
)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_transferred(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
payload: AccountingTransferUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
record = LexwareService._latest_invoice_record(db, estimate.id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
|
||||
if record.export_status not in {"prepared", "transferred"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
|
||||
|
||||
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
|
||||
now = datetime.now(UTC)
|
||||
record.export_status = "transferred"
|
||||
record.status = "success"
|
||||
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
|
||||
record.transferred_at = now
|
||||
record.transferred_by_user_id = actor.id
|
||||
record.synced_at = now
|
||||
estimate.accounting_export_status = "transferred"
|
||||
estimate.accounting_note = record.accounting_note
|
||||
estimate.accounting_transferred_at = now
|
||||
estimate.accounting_transferred_by_user_id = actor.id
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.mark_transferred",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
"transferred_at": record.transferred_at,
|
||||
},
|
||||
)
|
||||
if note_changed:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.note_update",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
|
||||
)
|
||||
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
|
||||
warnings: list[str] = []
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
return customer_mapping, line_items
|
||||
|
||||
@staticmethod
|
||||
def _invoice_payload_summary(
|
||||
config: LexwareRuntimeConfig,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "invoice",
|
||||
"title": f"Rechnung zu Reparatur {repair.repair_number}",
|
||||
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
|
||||
"repair_number": repair.repair_number,
|
||||
"estimate_number": estimate.estimate_number,
|
||||
"currency": estimate.currency,
|
||||
"payment_terms_days": config.default_payment_terms_days,
|
||||
"subtotal": str(_euros(estimate.subtotal_cents)),
|
||||
"tax": str(_euros(estimate.tax_cents)),
|
||||
"total": str(_euros(estimate.total_cents)),
|
||||
"line_item_count": len(line_items),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _invoice_preparation_response(
|
||||
*,
|
||||
record: LexwareSyncRecord,
|
||||
payload_summary: dict,
|
||||
customer_mapping: LexwareCustomerMapping,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
estimate: RepairEstimate,
|
||||
config: LexwareRuntimeConfig,
|
||||
warnings: list[str],
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
return LexwareInvoicePreparationResponse(
|
||||
ready_for_export=not warnings,
|
||||
export_status=record.export_status,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_item_mapping=line_items,
|
||||
tax_mapping={
|
||||
"source": "repair_estimate",
|
||||
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
|
||||
"default_tax_rate": str(config.default_tax_rate),
|
||||
"tax_amount": str(_euros(estimate.tax_cents)),
|
||||
},
|
||||
warnings=warnings,
|
||||
sync_record_id=record.id,
|
||||
accounting_note=record.accounting_note or "",
|
||||
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
|
||||
transferred_by_user_id=record.transferred_by_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
return db.scalar(
|
||||
select(LexwareSyncRecord)
|
||||
.where(LexwareSyncRecord.entity_type == "repair_estimate")
|
||||
.where(LexwareSyncRecord.entity_id == estimate_id)
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
|
||||
return LexwareSettingsResponse(
|
||||
enabled=config.enabled,
|
||||
api_base_url=config.api_base_url,
|
||||
api_key_is_set=config.api_key_is_set,
|
||||
organization_name=config.organization_name,
|
||||
default_tax_rate=config.default_tax_rate,
|
||||
default_payment_terms_days=config.default_payment_terms_days,
|
||||
source=config.source,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_profile(config: LexwareRuntimeConfig) -> dict:
|
||||
request = UrlRequest(
|
||||
f"{config.api_base_url}/v1/profile",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(request, timeout=15) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
if not body:
|
||||
return {}
|
||||
data = json.loads(body)
|
||||
return data if isinstance(data, dict) else {}
|
||||
225
backend/hermes/app/services/repair_document_service.py
Normal file
225
backend/hermes/app/services/repair_document_service.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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")
|
||||
662
backend/hermes/app/services/repair_estimate_service.py
Normal file
662
backend/hermes/app/services/repair_estimate_service.py
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
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>"""
|
||||
297
backend/hermes/app/services/repair_notification_service.py
Normal file
297
backend/hermes/app/services/repair_notification_service.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
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.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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairNotificationTemplate:
|
||||
event_type: str
|
||||
status: RepairStatus | None
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
|
||||
|
||||
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_created",
|
||||
status="new",
|
||||
subject="Reparatur {repair_number} wurde erfasst",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="device_accepted",
|
||||
status="accepted",
|
||||
subject="Gerät zu Reparatur {repair_number} angenommen",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="diagnosis_started",
|
||||
status="diagnosis",
|
||||
subject="Diagnose für Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="estimate_created",
|
||||
status="estimate",
|
||||
subject="Kostenvoranschlag zu Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="waiting_for_customer",
|
||||
status="waiting_for_customer",
|
||||
subject="Freigabe für Reparatur {repair_number} erforderlich",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_started",
|
||||
status="repair",
|
||||
subject="Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="final_test",
|
||||
status="final_test",
|
||||
subject="Endprüfung für Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="ready_for_pickup",
|
||||
status="ready_for_pickup",
|
||||
subject="Reparatur {repair_number} ist abholbereit",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="shipped",
|
||||
status="shipped",
|
||||
subject="Reparatur {repair_number} wurde versendet",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="completed",
|
||||
status="completed",
|
||||
subject="Reparatur {repair_number} abgeschlossen",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
"status_label": STATUS_LABELS.get(repair.status, repair.status),
|
||||
"public_status_url": public_status_url or "wird später bereitgestellt",
|
||||
"company_name": "Olympus CRM",
|
||||
}
|
||||
return [
|
||||
RepairNotificationTemplateResponse(
|
||||
event_type=template.event_type,
|
||||
status=template.status,
|
||||
subject=template.subject.format(**placeholders),
|
||||
text=template.text.format(**placeholders),
|
||||
html=template.html.format(**placeholders) if template.html else None,
|
||||
)
|
||||
for template in TEMPLATES
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
|
||||
return RepairNotificationOverviewResponse(
|
||||
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},
|
||||
)
|
||||
169
backend/hermes/app/services/repair_public_link_service.py
Normal file
169
backend/hermes/app/services/repair_public_link_service.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
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,
|
||||
RepairPublicStatusHistoryItem,
|
||||
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
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def create_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@staticmethod
|
||||
def response_from_link(public_link: RepairPublicAccessToken | None) -> RepairPublicLinkResponse:
|
||||
if public_link is None:
|
||||
return RepairPublicLinkResponse(is_active=False)
|
||||
return RepairPublicLinkResponse(
|
||||
is_active=public_link.is_active,
|
||||
token_hint=public_link.token_hint,
|
||||
expires_at=public_link.expires_at,
|
||||
last_used_at=public_link.last_used_at,
|
||||
created_at=public_link.created_at,
|
||||
revoked_at=public_link.revoked_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get(db: Session, repair: Repair) -> RepairPublicLinkResponse:
|
||||
return RepairPublicLinkService.response_from_link(RepairRepository.get_active_public_link(db, repair.id))
|
||||
|
||||
@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,
|
||||
repair_id=repair.id,
|
||||
token_hash=RepairPublicLinkService.hash_token(token),
|
||||
token_hint=token[-6:],
|
||||
)
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"token_hint": public_link.token_hint},
|
||||
)
|
||||
return RepairPublicLinkCreateResponse(
|
||||
is_active=True,
|
||||
token=token,
|
||||
token_hint=public_link.token_hint,
|
||||
expires_at=public_link.expires_at,
|
||||
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),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def revoke(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkResponse:
|
||||
active_link = RepairRepository.get_active_public_link(db, repair.id)
|
||||
RepairRepository.revoke_public_links(db, repair_id=repair.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.public_link.revoke",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"token_hint": active_link.token_hint if active_link else None},
|
||||
)
|
||||
return RepairPublicLinkResponse(is_active=False, token_hint=active_link.token_hint if active_link else None, revoked_at=datetime.now(UTC))
|
||||
|
||||
@staticmethod
|
||||
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
|
||||
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
|
||||
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(
|
||||
repair_number=repair.repair_number,
|
||||
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
|
||||
device_manufacturer=repair.device_manufacturer,
|
||||
device_model=repair.device_model,
|
||||
status_history_public=[
|
||||
RepairPublicStatusHistoryItem(
|
||||
status=item.new_status,
|
||||
status_label=STATUS_LABELS.get(item.new_status, item.new_status),
|
||||
created_at=item.created_at,
|
||||
)
|
||||
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
|
||||
171
backend/hermes/app/services/repair_service.py
Normal file
171
backend/hermes/app/services/repair_service.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
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.user import User
|
||||
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:
|
||||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
class RepairService:
|
||||
@staticmethod
|
||||
def create(db: Session, payload: RepairCreate, *, actor: User | None, request: Request | None = None) -> Repair:
|
||||
RepairService._validate_customer(db, payload.customer_id)
|
||||
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
|
||||
try:
|
||||
repair = RepairRepository.create(db, payload, repair_number=repair_number)
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
|
||||
repair = RepairRepository.create(db, payload, repair_number=repair_number)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.create",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
after_data=repair,
|
||||
)
|
||||
return repair
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, repair: Repair, payload: RepairUpdate, *, actor: User, request: Request) -> Repair:
|
||||
RepairService._validate_customer(db, payload.customer_id)
|
||||
before_data = sanitize(repair)
|
||||
updated = RepairRepository.update(db, repair, payload)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.update",
|
||||
entity_type="repairs",
|
||||
entity_id=updated.id,
|
||||
entity_label=repair_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=updated,
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor: User, request: Request) -> Repair:
|
||||
before_data = sanitize(repair)
|
||||
updated = RepairRepository.update_status(db, repair, payload, actor_user_id=actor.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.status_update",
|
||||
entity_type="repairs",
|
||||
entity_id=updated.id,
|
||||
entity_label=repair_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
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
|
||||
def cancel(db: Session, repair: Repair, *, actor: User, request: Request) -> Repair:
|
||||
before_data = sanitize(repair)
|
||||
updated = RepairRepository.cancel(db, repair, actor_user_id=actor.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.cancel",
|
||||
entity_type="repairs",
|
||||
entity_id=updated.id,
|
||||
entity_label=repair_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=updated,
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def create_from_intake(db: Session, payload: RepairIntakePayload, *, request: Request | None = None) -> Repair:
|
||||
event = RepairRepository.create_intake_event(
|
||||
db,
|
||||
external_source="website",
|
||||
external_reference=payload.source_reference,
|
||||
payload=payload.model_dump(mode="json"),
|
||||
status="received",
|
||||
)
|
||||
try:
|
||||
repair_payload = RepairCreate(
|
||||
customer_id=None,
|
||||
contact_id=None,
|
||||
status="new",
|
||||
priority="normal",
|
||||
source="website",
|
||||
source_reference=payload.source_reference,
|
||||
customer_name=payload.name,
|
||||
customer_email=payload.email,
|
||||
customer_phone=payload.phone,
|
||||
device_manufacturer=payload.manufacturer,
|
||||
device_model=payload.model,
|
||||
device_serial_number=payload.serial_number,
|
||||
device_type=payload.device_type,
|
||||
accessories=payload.accessories,
|
||||
fault_description=payload.fault_description,
|
||||
previous_work=payload.previous_work,
|
||||
device_opened=payload.device_opened,
|
||||
intake_notes="Website Intake",
|
||||
)
|
||||
repair = RepairService.create(db, repair_payload, actor=None, request=request)
|
||||
RepairRepository.update_intake_event(db, event, status="processed", repair_id=repair.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.intake_processed",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
request=request,
|
||||
metadata={"external_source": "website", "external_reference": payload.source_reference},
|
||||
)
|
||||
return repair
|
||||
except Exception as exc:
|
||||
RepairRepository.update_intake_event(db, event, status="failed", error_message="Intake konnte nicht verarbeitet werden")
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.intake_failed",
|
||||
entity_type="repairs",
|
||||
request=request,
|
||||
metadata={"external_source": "website", "external_reference": payload.source_reference},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reparaturannahme konnte nicht verarbeitet werden",
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate_customer(db: Session, customer_id: int | None) -> None:
|
||||
if customer_id is None:
|
||||
return
|
||||
if CustomerRepository.get_by_id(db, customer_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kunde nicht gefunden")
|
||||
29
backend/hermes/app/services/repair_status_labels.py
Normal file
29
backend/hermes/app/services/repair_status_labels.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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",
|
||||
}
|
||||
316
backend/hermes/app/services/system_settings_service.py
Normal file
316
backend/hermes/app/services/system_settings_service.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
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}
|
||||
3
backend/hermes/app/storage/__init__.py
Normal file
3
backend/hermes/app/storage/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.storage.service import StorageService, get_storage_service
|
||||
|
||||
__all__ = ["StorageService", "get_storage_service"]
|
||||
34
backend/hermes/app/storage/base.py
Normal file
34
backend/hermes/app/storage/base.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from app.storage.schemas import FileMetadata, StoredFileMetadata
|
||||
|
||||
|
||||
class StorageProvider(ABC):
|
||||
@abstractmethod
|
||||
def save_file(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
content: bytes,
|
||||
original_filename: str,
|
||||
stored_filename: str,
|
||||
mime_type: str,
|
||||
) -> StoredFileMetadata:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def open_file(self, storage_key: str) -> Path:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_file_metadata(self, storage_key: str) -> FileMetadata:
|
||||
raise NotImplementedError
|
||||
14
backend/hermes/app/storage/exceptions.py
Normal file
14
backend/hermes/app/storage/exceptions.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class StorageError(Exception):
|
||||
"""Base exception for storage operations."""
|
||||
|
||||
|
||||
class StorageConfigurationError(StorageError):
|
||||
pass
|
||||
|
||||
|
||||
class StorageValidationError(StorageError):
|
||||
pass
|
||||
|
||||
|
||||
class StorageFileNotFoundError(StorageError):
|
||||
pass
|
||||
142
backend/hermes/app/storage/local.py
Normal file
142
backend/hermes/app/storage/local.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
from app.storage.base import StorageProvider
|
||||
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||
from app.storage.schemas import FileMetadata, StoredFileMetadata
|
||||
|
||||
|
||||
class LocalDiskStorageProvider(StorageProvider):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_path: str,
|
||||
legacy_base_paths: list[str] | None = None,
|
||||
) -> None:
|
||||
self.base_path = Path(base_path).expanduser().resolve()
|
||||
self.legacy_base_paths = [
|
||||
Path(path).expanduser().resolve()
|
||||
for path in legacy_base_paths or []
|
||||
if path
|
||||
]
|
||||
self._ensure_layout()
|
||||
|
||||
def save_file(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
content: bytes,
|
||||
original_filename: str,
|
||||
stored_filename: str,
|
||||
mime_type: str,
|
||||
) -> StoredFileMetadata:
|
||||
if Path(stored_filename).name != stored_filename or ".." in Path(stored_filename).parts:
|
||||
raise StorageValidationError("Ungültiger gespeicherter Dateiname")
|
||||
namespace_path = self._safe_namespace_path(namespace)
|
||||
namespace_path.mkdir(parents=True, exist_ok=True)
|
||||
absolute_path = self._safe_child(namespace_path / stored_filename)
|
||||
absolute_path.write_bytes(content)
|
||||
relative_path = absolute_path.relative_to(self.base_path).as_posix()
|
||||
checksum = self._checksum(absolute_path)
|
||||
return StoredFileMetadata(
|
||||
storage_key=relative_path,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
relative_path=relative_path,
|
||||
absolute_path=absolute_path,
|
||||
mime_type=mime_type,
|
||||
size=len(content),
|
||||
checksum_sha256=checksum,
|
||||
)
|
||||
|
||||
def open_file(self, storage_key: str) -> Path:
|
||||
path = self._resolve_storage_key(storage_key)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise StorageFileNotFoundError("Datei nicht gefunden")
|
||||
return path
|
||||
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
path = self._resolve_storage_key(storage_key)
|
||||
if not path.exists():
|
||||
return
|
||||
if not path.is_file():
|
||||
raise StorageValidationError("Storage-Key verweist nicht auf eine Datei")
|
||||
path.unlink()
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
try:
|
||||
path = self._resolve_storage_key(storage_key)
|
||||
except StorageValidationError:
|
||||
return False
|
||||
return path.exists() and path.is_file()
|
||||
|
||||
def get_file_metadata(self, storage_key: str) -> FileMetadata:
|
||||
path = self.open_file(storage_key)
|
||||
return FileMetadata(
|
||||
storage_key=storage_key,
|
||||
relative_path=self._relative_storage_key(path),
|
||||
absolute_path=path,
|
||||
size=path.stat().st_size,
|
||||
checksum_sha256=self._checksum(path),
|
||||
)
|
||||
|
||||
def _ensure_layout(self) -> None:
|
||||
for relative_path in [
|
||||
"knowledge/documents",
|
||||
"knowledge/thumbnails",
|
||||
"customers",
|
||||
"projects",
|
||||
"tickets",
|
||||
"repairs",
|
||||
"imports",
|
||||
"temp",
|
||||
]:
|
||||
(self.base_path / relative_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _safe_namespace_path(self, namespace: str) -> Path:
|
||||
if namespace.startswith("/") or ".." in Path(namespace).parts:
|
||||
raise StorageValidationError("Ungültiger Storage-Namespace")
|
||||
return self._safe_child(self.base_path / namespace)
|
||||
|
||||
def _safe_child(self, path: Path) -> Path:
|
||||
resolved = path.resolve()
|
||||
if self.base_path != resolved and self.base_path not in resolved.parents:
|
||||
raise StorageValidationError("Ungültiger Storage-Pfad")
|
||||
return resolved
|
||||
|
||||
def _resolve_storage_key(self, storage_key: str) -> Path:
|
||||
raw_path = Path(storage_key)
|
||||
candidate_paths: list[Path] = []
|
||||
|
||||
if raw_path.is_absolute():
|
||||
candidate_paths.append(raw_path)
|
||||
else:
|
||||
candidate_paths.append(self.base_path / raw_path)
|
||||
candidate_paths.extend(legacy_base / raw_path for legacy_base in self.legacy_base_paths)
|
||||
|
||||
for candidate in candidate_paths:
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if self._is_allowed_path(resolved):
|
||||
return resolved
|
||||
|
||||
raise StorageValidationError("Ungültiger Storage-Key")
|
||||
|
||||
def _is_allowed_path(self, path: Path) -> bool:
|
||||
roots = [self.base_path, *self.legacy_base_paths]
|
||||
return any(root == path or root in path.parents for root in roots)
|
||||
|
||||
def _relative_storage_key(self, path: Path) -> str:
|
||||
for root in [self.base_path, *self.legacy_base_paths]:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
return path.name
|
||||
|
||||
@staticmethod
|
||||
def _checksum(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
23
backend/hermes/app/storage/schemas.py
Normal file
23
backend/hermes/app/storage/schemas.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredFileMetadata:
|
||||
storage_key: str
|
||||
original_filename: str
|
||||
stored_filename: str
|
||||
relative_path: str
|
||||
absolute_path: Path
|
||||
mime_type: str
|
||||
size: int
|
||||
checksum_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileMetadata:
|
||||
storage_key: str
|
||||
relative_path: str
|
||||
absolute_path: Path
|
||||
size: int
|
||||
checksum_sha256: str
|
||||
103
backend/hermes/app/storage/service.py
Normal file
103
backend/hermes/app/storage/service.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from app.storage.base import StorageProvider
|
||||
from app.storage.exceptions import StorageConfigurationError, StorageValidationError
|
||||
from app.storage.local import LocalDiskStorageProvider
|
||||
from app.storage.schemas import FileMetadata, StoredFileMetadata
|
||||
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
}
|
||||
|
||||
|
||||
class StorageService:
|
||||
def __init__(self, provider: StorageProvider, *, max_upload_mb: int) -> None:
|
||||
self.provider = provider
|
||||
self.max_upload_mb = max(1, max_upload_mb)
|
||||
|
||||
def save_file(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
content: bytes,
|
||||
original_filename: str,
|
||||
mime_type: str | None = None,
|
||||
) -> StoredFileMetadata:
|
||||
self.validate_file_size(len(content))
|
||||
safe_name = self.safe_filename(original_filename)
|
||||
resolved_mime_type = mime_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
|
||||
self.validate_file_type(safe_name, resolved_mime_type)
|
||||
stored_filename = f"{uuid.uuid4().hex}-{safe_name}"
|
||||
return self.provider.save_file(
|
||||
namespace=namespace,
|
||||
content=content,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
mime_type=resolved_mime_type,
|
||||
)
|
||||
|
||||
def open_file(self, storage_key: str) -> Path:
|
||||
return self.provider.open_file(storage_key)
|
||||
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
self.provider.delete_file(storage_key)
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
return self.provider.file_exists(storage_key)
|
||||
|
||||
def get_file_metadata(self, storage_key: str) -> FileMetadata:
|
||||
return self.provider.get_file_metadata(storage_key)
|
||||
|
||||
@staticmethod
|
||||
def calculate_checksum(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def safe_filename(file_name: str) -> str:
|
||||
name = Path(file_name or "").name.strip()
|
||||
if not name:
|
||||
raise StorageValidationError("Dateiname fehlt")
|
||||
stem = Path(name).stem.strip().lower()
|
||||
stem = stem.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
|
||||
suffix = Path(name).suffix.lower()
|
||||
safe_stem = stem or uuid.uuid4().hex[:10]
|
||||
return f"{safe_stem}{suffix}" if suffix else safe_stem
|
||||
|
||||
def validate_file_type(self, file_name: str, mime_type: str) -> None:
|
||||
extension = Path(file_name).suffix.lower()
|
||||
if extension not in ALLOWED_EXTENSIONS:
|
||||
raise StorageValidationError("Dateityp ist nicht erlaubt")
|
||||
if mime_type not in ALLOWED_MIME_TYPES:
|
||||
raise StorageValidationError("MIME-Type ist nicht erlaubt")
|
||||
|
||||
def validate_file_size(self, size: int) -> None:
|
||||
if size <= 0:
|
||||
raise StorageValidationError("Upload-Datei ist leer")
|
||||
if size > self.max_upload_mb * 1024 * 1024:
|
||||
raise StorageValidationError("Upload-Datei ist zu groß")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_storage_service() -> StorageService:
|
||||
if settings.storage_provider != "local":
|
||||
raise StorageConfigurationError("Nur STORAGE_PROVIDER=local ist aktuell implementiert")
|
||||
|
||||
provider = LocalDiskStorageProvider(
|
||||
base_path=settings.storage_base_path,
|
||||
legacy_base_paths=[settings.knowledge_storage_path],
|
||||
)
|
||||
return StorageService(provider, max_upload_mb=settings.storage_max_upload_mb)
|
||||
|
|
@ -7,7 +7,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.1.0
|
||||
APP_VERSION: 0.9.1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||
|
|
@ -17,6 +17,17 @@ services:
|
|||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
|
||||
STORAGE_BASE_PATH: ${STORAGE_BASE_PATH:-/data/storage}
|
||||
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
|
||||
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.1.0
|
||||
APP_VERSION: 0.9.1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
|
|
@ -20,6 +20,27 @@ services:
|
|||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
|
||||
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
|
||||
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
|
||||
STORAGE_BASE_PATH: ${STORAGE_BASE_PATH:-/data/storage}
|
||||
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}
|
||||
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
|
||||
|
||||
expose:
|
||||
- "8000"
|
||||
|
|
|
|||
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
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)}`);
|
||||
}
|
||||
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
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`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
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`);
|
||||
}
|
||||
12
frontend/athena/app/api/backups/create/route.ts
Normal file
12
frontend/athena/app/api/backups/create/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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");
|
||||
}
|
||||
7
frontend/athena/app/api/backups/route.ts
Normal file
7
frontend/athena/app/api/backups/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/backups");
|
||||
}
|
||||
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
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`);
|
||||
}
|
||||
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/items");
|
||||
}
|
||||
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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}`);
|
||||
}
|
||||
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue