diff --git a/.DS_Store b/.DS_Store index 3ff066b..93d72f9 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index e6e06b3..155c54e 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ STORAGE_PROVIDER=local STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage +# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume. # Legacy-Fallback fuer bestehende Knowledge-Installationen. KNOWLEDGE_STORAGE_PATH=/data/knowledge @@ -28,3 +29,24 @@ 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= diff --git a/.gitignore b/.gitignore index 4a28a16..5bd760f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,13 @@ out/ # =========================== *.log +# =========================== +# Runtime storage +# =========================== +storage/ +/backups/ +*.dump + # =========================== # Coverage # =========================== diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 65f6edf..3bced00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,6 +166,58 @@ Hermes Athena ist die einzige API-Oberflaeche fuer den Browser. +## Lexware Office Integration + +Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation. + +Verantwortlichkeiten: + +- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege. +- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR. +- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt. + +Komponenten: + +- Hermes speichert Lexware-Konfiguration in `system_settings`. +- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`. +- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`. +- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`. +- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden. +- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt. + +Datenfluss: + +```text +Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office +Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL +``` + +Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert. + +Buchhaltungsworkflow: + +- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt. +- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen. +- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet. +- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet. + +Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren. + +Neue zentrale Endpunkte: + +- `GET /lexware/settings` +- `PUT /lexware/settings` +- `POST /lexware/test-connection` +- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice` + +RBAC: + +- `lexware.read` +- `lexware.manage` +- `lexware.export` + +Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen. + ## Dashboard Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`. @@ -219,6 +271,72 @@ Tabellen: - `repair_public_access_tokens` - `repair_notification_events` +## Lager / Ersatzteile + +Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen. + +Datenfluss: + +```text +Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL +``` + +Kernregeln: + +- Browser greifen nie direkt auf Hermes zu. +- Artikelnummern/SKUs sind eindeutig. +- Leere SKU bei Erstellung wird automatisch im Format `ET--000001` vergeben. +- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben. +- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert. +- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation. +- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht. +- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft. +- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird. +- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert. + +Tabellen: + +- `inventory_items` +- `inventory_categories` +- `inventory_locations` +- `inventory_suppliers` +- `inventory_stock_movements` + +Bestandsbewegungen: + +- `initial` +- `purchase` +- `adjustment` +- `reservation` +- `release` +- `consumption` +- `return` +- `correction` + +RBAC-Permissions: + +- `inventory.read` +- `inventory.create` +- `inventory.update` +- `inventory.delete` +- `inventory.stock.adjust` +- `inventory.stock.reserve` +- `inventory.stock.consume` +- `inventory.manage.masterdata` + +Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten. + +Reservierungslogik: + +- Entwurf: keine Reservierung. +- Senden: verknuepfte Lagerartikel werden reserviert. +- Freigabe: Reservierung bleibt bestehen. +- Ablehnung: Reservierung wird freigegeben. +- Storno: Reservierung wird freigegeben. +- Loeschung eines gesendeten KV: Reservierung wird freigegeben. + +Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. + Hermes-Endpunkte: - `GET /repairs` @@ -271,7 +389,7 @@ Die spaetere Website kann daraus einen Pfad wie `/status/` anbieten. Die ### Reparatur-Benachrichtigungen -`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen. +`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert. Vorbereitete Ereignisse: @@ -288,6 +406,158 @@ Vorbereitete Ereignisse: Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`. +Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt. + +Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant. + +Hermes-Endpunkt fuer manuellen Neuversand: + +- `POST /repairs/{id}/send-status-mail` + +Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf. + +### System Settings + +`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten: + +- `smtp.host` +- `smtp.port` +- `smtp.username` +- `smtp.password` +- `smtp.from_email` +- `smtp.from_name` +- `smtp.use_tls` +- `smtp.enabled` +- `public.repair_status_base_url` + +Hermes-Endpunkte: + +- `GET /system-settings/smtp` +- `PUT /system-settings/smtp` +- `POST /system-settings/smtp/test` +- `GET /system-settings/public-links` +- `PUT /system-settings/public-links` + +Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist. + +### Reparaturdokumente + +Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework. + +Datenmodell `repair_documents`: + +- `repair_id` +- `title` +- `document_type` +- `original_filename` +- `stored_filename` +- `storage_path` +- `mime_type` +- `size_bytes` +- `checksum_sha256` +- `visibility` +- `note` +- `uploaded_by_user_id` +- Zeitstempel + +Dokumenttypen: + +- `device_photo` +- `fault_photo` +- `measurement` +- `estimate` +- `repair_report` +- `shipping` +- `other` + +`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert. + +Storage: + +- Namespace: `repairs//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-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. @@ -707,6 +977,8 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl Neue Uploads werden im Namespace `knowledge/documents/` 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//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` @@ -788,9 +1060,29 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment: `deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`. -`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden. +`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`. -`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`. +`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck. + +### Backup-Modul ab v0.9.1 + +Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul: + +- Hermes-Service: `backend/hermes/app/services/backup_service.py` +- Hermes-API: `backend/hermes/app/api/backups.py` +- Hermes-Schemas: `backend/hermes/app/schemas/backup.py` +- Athena-Seite: `frontend/athena/app/backups/page.tsx` +- Athena-BFF: `/api/backups/...` + +Sicherheitsregeln: + +- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`. +- Der Backup-Ordner ist Teil des persistenten Storage-Volumes. +- Hermes anonymisiert den Datenbank-Host im Manifest. +- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt. +- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`. + +Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`. ### Knowledge-RBAC @@ -893,6 +1185,12 @@ docker-compose.yml `OLYMPUS_REPAIR_INTAKE_TOKEN` : Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen. +`PUBLIC_REPAIR_STATUS_BASE_URL` +: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, gibt Hermes weiterhin relative Links im Format `/status/` 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` diff --git a/README-DEV.md b/README-DEV.md index 0ad90a3..d309d74 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -34,8 +34,23 @@ 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 @@ -119,6 +134,46 @@ STORAGE_BASE_PATH=/data/storage Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen. +## Backup und Restore + +Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul. + +Ablage: + +- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups` +- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups` +- Backup-Dateien gehoeren nie ins Git + +Inhalt eines Backups: + +- `manifest.json` +- `database.dump` +- `storage/` + +Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit: + +- `GET /api/backups` +- `POST /api/backups/create` +- `GET /api/backups/[filename]/download` +- `POST /api/backups/[filename]/validate` +- `POST /api/backups/[filename]/restore` +- `DELETE /api/backups/[filename]/delete` + +Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein. + +Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt: + +1. Backup validieren. +2. Sicherheitsbestaetigung pruefen. +3. Restore ueber `scripts/restore.sh ` ausfuehren. +4. Ergebnis und Audit Logs kontrollieren. + +Empfehlung fuer den Betrieb: + +- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern. +- Backup-Dateien vor Offsite-Kopie verschluesseln. +- Restore nur in Wartungsfenstern ausfuehren. + ## Knowledge Workflow Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf: @@ -144,10 +199,15 @@ 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. @@ -159,14 +219,132 @@ Statuslink-Konzept: - RBAC-Permission: `repairs.public_link.manage`. - Tokens sind lang, zufaellig und werden nur gehasht gespeichert. - Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben. +- `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`. +- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback. - Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`. - Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen. +## Lexware Office Foundation + +Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet. + +Rollenverteilung: + +- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten. +- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR. + +Konfiguration: + +- Adminbereich: `/settings -> Lexware Office` +- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection` +- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection` +- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY` + +Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf. + +Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden: + +```text +POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice +``` + +v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag. + +Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden. + +Exportstatus: + +- `prepared` +- `transferred` +- `booked` +- `cancelled` + Benachrichtigungen: - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. -- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor. -- Aktuell wird kein SMTP-Versand aus Olympus erzwungen. +- `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//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-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--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: diff --git a/ROADMAP.md b/ROADMAP.md index 98f0b08..7c0efb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,13 +102,123 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort - Optionaler E-Mail-Versand mit sicherem Statuslink -## v0.8.3 - Kundenportal, geplant +## 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//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-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--000001` +- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit +- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch +- Preise intern in cents, UI-Eingabe in Euro +- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten +- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen +- Audit Logs und Activity Feed fuer Lageraktionen +- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]` +- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8 +- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion + +## v0.8.8 - Lagerintegration in Kostenvoranschläge + +- Lagerartikel als KV-Position ueber Suche auswaehlen +- Verkaufspreis aus `selling_price_cents` uebernehmen +- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen +- Reservierung beim Senden eines KV +- Freigabe behaelt Reservierung +- Ablehnung, Storno und Loeschung geben Reservierungen frei +- Bestandsbewegungen mit `reference_type=repair_estimate` +- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand +- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung +- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv +- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures + +## v0.8.9 - Lexware Office Integration Foundation + +- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert +- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten +- Lexware-Konfiguration unter `/settings -> Lexware Office` +- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel +- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY` +- API-Key wird nicht an Athena zurueckgegeben +- Server-seitiger Verbindungstest gegen `GET /v1/profile` +- Neue Tabelle `lexware_sync_records` +- Optionale Lexware-Rechnungsreferenzen an `repair_estimates` +- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs +- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe +- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export` +- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled` +- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware +- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen + +## v0.9.0 - Lexware Rechnungserstellung, geplant + +- Echte Rechnungserstellung in Lexware nach final geprueftem API-Mapping +- Kontaktabgleich und Kontaktanlage in Lexware produktionsreif ausbauen +- Exportstatus und Fehlerbehebung im Olympus UI erweitern +- Optionaler Download/Link zur Lexware-Rechnung + +## v0.9.1 - Backup und Restore + +- Backup-Modul mit Athena-BFF und Hermes-Service-Layer +- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/` +- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups` +- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore` +- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus +- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung +- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh` +- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad + +## v0.9.2 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden - Keine Vermischung mit internen Olympus-Benutzern -## v0.9.0 - Tickets, geplant +## v0.9.3 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell @@ -116,10 +226,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - RBAC-Permissions fuer Tickets - Audit Logs fuer Ticketaktionen -## v0.10.0 - Integrationen Paperless/Lexoffice, geplant +## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant - Paperless-ngx Connector fuer Wissensdokumente -- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten +- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen - Sichere Connector-Konfiguration ohne Browser-Secrets - Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen diff --git a/backend/.DS_Store b/backend/.DS_Store index 7ff34d4..90bfa15 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 33eff2d..cfaeb3b 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -19,3 +19,20 @@ 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= diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index fb89de0..3217c11 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -13,6 +13,9 @@ 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 diff --git a/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc b/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc index 795e2da..6833573 100644 Binary files a/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc and b/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc differ diff --git a/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py new file mode 100644 index 0000000..7091971 --- /dev/null +++ b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py @@ -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") diff --git a/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py new file mode 100644 index 0000000..58c82c7 --- /dev/null +++ b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py @@ -0,0 +1,67 @@ +"""add repair estimate revoke permission + +Revision ID: a7c3e9d4b821 +Revises: f4a9c2d7e118 +Create Date: 2026-07-05 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "a7c3e9d4b821" +down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +PERMISSION_NAME = "repair_estimates.revoke" + + +def upgrade() -> None: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES ( + :name, + 'Kostenvoranschläge zurücknehmen', + 'Freigegebene Kostenvoranschläge administrativ zurücknehmen', + 'repair_estimates' + ) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=PERMISSION_NAME) + ) + for role_name in ("administrator", "management"): + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=PERMISSION_NAME) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE name = :permission_name + ) + """ + ).bindparams(permission_name=PERMISSION_NAME) + ) + op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME)) diff --git a/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py b/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py new file mode 100644 index 0000000..bf6e3cf --- /dev/null +++ b/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py @@ -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") diff --git a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py new file mode 100644 index 0000000..bf98008 --- /dev/null +++ b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py @@ -0,0 +1,113 @@ +"""add lexware foundation + +Revision ID: c9d4e5f6a7b8 +Revises: a7c3e9d4b821 +Create Date: 2026-07-05 16:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c9d4e5f6a7b8" +down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +LEXWARE_PERMISSIONS = [ + ("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"), + ("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"), + ("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"), +] + + +def upgrade() -> None: + op.create_table( + "lexware_sync_records", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entity_type", sa.String(length=80), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=False), + sa.Column("lexware_resource_type", sa.String(length=80), nullable=False), + sa.Column("lexware_resource_id", sa.String(length=120), nullable=True), + sa.Column("status", sa.String(length=40), server_default="pending", nullable=False), + sa.Column("direction", sa.String(length=40), server_default="push", nullable=False), + sa.Column("payload_summary", sa.Text(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False) + + op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True)) + + for name, display_name, description, module in LEXWARE_PERMISSIONS: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES (:name, :display_name, :description, :module) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + role_permissions = { + "administrator": [item[0] for item in LEXWARE_PERMISSIONS], + "management": [item[0] for item in LEXWARE_PERMISSIONS], + "support": ["lexware.read"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'lexware' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'lexware'") + op.drop_column("repair_estimates", "lexware_synced_at") + op.drop_column("repair_estimates", "lexware_invoice_status") + op.drop_column("repair_estimates", "lexware_invoice_number") + op.drop_column("repair_estimates", "lexware_invoice_id") + op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records") + op.drop_table("lexware_sync_records") diff --git a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py new file mode 100644 index 0000000..0c7936b --- /dev/null +++ b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py @@ -0,0 +1,53 @@ +"""add accounting export status + +Revision ID: d2e3f4a5b6c7 +Revises: c9d4e5f6a7b8 +Create Date: 2026-07-05 17:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d2e3f4a5b6c7" +down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False)) + op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True)) + op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True)) + op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False) + + op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_repair_estimates_accounting_transferred_by_user_id", + "repair_estimates", + "users", + ["accounting_transferred_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey") + op.drop_column("repair_estimates", "accounting_transferred_by_user_id") + op.drop_column("repair_estimates", "accounting_transferred_at") + op.drop_column("repair_estimates", "accounting_note") + op.drop_column("repair_estimates", "accounting_export_status") + op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records") + op.drop_column("lexware_sync_records", "transferred_by_user_id") + op.drop_column("lexware_sync_records", "transferred_at") + op.drop_column("lexware_sync_records", "accounting_note") + op.drop_column("lexware_sync_records", "export_status") diff --git a/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py b/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py new file mode 100644 index 0000000..b8554ad --- /dev/null +++ b/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py @@ -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") diff --git a/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py new file mode 100644 index 0000000..e422b5a --- /dev/null +++ b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py @@ -0,0 +1,214 @@ +"""create inventory + +Revision ID: ef8a2d5c9017 +Revises: b4e8f1c7a205 +Create Date: 2026-07-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ef8a2d5c9017" +down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +INVENTORY_PERMISSIONS = [ + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), +] + + +def upgrade() -> None: + op.create_table( + "inventory_categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=180), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("slug"), + ) + op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True) + op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True) + + op.create_table( + "inventory_locations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True) + + op.create_table( + "inventory_suppliers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=180), nullable=False), + sa.Column("contact_name", sa.String(length=180), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=80), nullable=True), + sa.Column("website", sa.String(length=255), nullable=True), + sa.Column("customer_number", sa.String(length=120), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True) + + op.create_table( + "inventory_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("sku", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category_id", sa.Integer(), nullable=True), + sa.Column("manufacturer", sa.String(length=180), nullable=True), + sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("supplier_part_number", sa.String(length=180), nullable=True), + sa.Column("compatible_devices", sa.Text(), nullable=True), + sa.Column("location_id", sa.Integer(), nullable=True), + sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False), + sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False), + sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False), + sa.Column("purchase_price_cents", sa.Integer(), nullable=True), + sa.Column("selling_price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False), + sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sku", name="uq_inventory_items_sku"), + ) + op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False) + op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False) + op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False) + op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False) + op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False) + op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True) + op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False) + op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False) + + op.create_table( + "inventory_stock_movements", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("item_id", sa.Integer(), nullable=False), + sa.Column("movement_type", sa.String(length=40), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=255), server_default="", nullable=False), + sa.Column("reference_type", sa.String(length=80), nullable=True), + sa.Column("reference_id", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False) + + for name, display_name, description, module in INVENTORY_PERMISSIONS: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES (:name, :display_name, :description, :module) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + role_permissions = { + "administrator": [item[0] for item in INVENTORY_PERMISSIONS], + "management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + "technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"], + "support": ["inventory.read"], + "warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'inventory' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'inventory'") + op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements") + op.drop_table("inventory_stock_movements") + op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items") + op.drop_table("inventory_items") + op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers") + op.drop_table("inventory_suppliers") + op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations") + op.drop_table("inventory_locations") + op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories") + op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories") + op.drop_table("inventory_categories") diff --git a/backend/hermes/alembic/versions/f2b8d4e6a910_create_system_settings.py b/backend/hermes/alembic/versions/f2b8d4e6a910_create_system_settings.py new file mode 100644 index 0000000..c58c712 --- /dev/null +++ b/backend/hermes/alembic/versions/f2b8d4e6a910_create_system_settings.py @@ -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") diff --git a/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py new file mode 100644 index 0000000..27dac33 --- /dev/null +++ b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py @@ -0,0 +1,44 @@ +"""extend repair estimate items inventory + +Revision ID: f4a9c2d7e118 +Revises: ef8a2d5c9017 +Create Date: 2026-07-05 13:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f4a9c2d7e118" +down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True)) + op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False) + op.create_foreign_key( + "fk_repair_estimate_items_inventory_item_id", + "repair_estimate_items", + "inventory_items", + ["inventory_item_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey") + op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items") + op.drop_column("repair_estimate_items", "inventory_snapshot_part_number") + op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer") + op.drop_column("repair_estimate_items", "inventory_snapshot_sku") + op.drop_column("repair_estimate_items", "inventory_snapshot_name") + op.drop_column("repair_estimate_items", "inventory_item_id") diff --git a/backend/hermes/app/__pycache__/main.cpython-314.pyc b/backend/hermes/app/__pycache__/main.cpython-314.pyc index 1be2f4b..fe05fec 100644 Binary files a/backend/hermes/app/__pycache__/main.cpython-314.pyc and b/backend/hermes/app/__pycache__/main.cpython-314.pyc differ diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index efa3d8e..5704b10 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -58,6 +58,16 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: return "knowledge.read" in permissions if action.startswith("repairs."): return "repairs.read" in permissions + if action.startswith("repair_estimates."): + return "repair_estimates.read" in permissions + if action.startswith("inventory."): + return "inventory.read" in permissions + if action.startswith("lexware."): + return "lexware.read" in permissions + if action.startswith("accounting."): + return "lexware.read" in permissions + if action.startswith("backups."): + return "backup.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/backups.py b/backend/hermes/app/api/backups.py new file mode 100644 index 0000000..75e131e --- /dev/null +++ b/backend/hermes/app/api/backups.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.user import User +from app.schemas.api_response import ApiSuccess +from app.schemas.backup import BackupRestoreRequest +from app.services.audit_service import write_audit_log +from app.services.backup_service import BackupService + +router = APIRouter(prefix="/backups", tags=["Backups"]) + + +@router.get("", response_model=ApiSuccess) +def list_backups( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.read")), +): + return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen") + + +@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED) +def create_backup( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.create")), +): + backup = BackupService.create_backup(actor=current_user) + write_audit_log( + db, + action="backups.create", + entity_type="backup", + entity_label=backup.filename, + actor=current_user, + request=request, + metadata={ + "filename": backup.filename, + "size_bytes": backup.size_bytes, + "app_version": backup.app_version, + }, + ) + return ApiSuccess(data=backup, message="Backup erstellt") + + +@router.get("/{filename}/download") +def download_backup( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.download")), +): + path = BackupService.resolve_backup_path(filename) + write_audit_log( + db, + action="backups.download", + entity_type="backup", + entity_label=path.name, + actor=current_user, + request=request, + metadata={"filename": path.name, "size_bytes": path.stat().st_size}, + ) + return FileResponse(path=path, media_type="application/zip", filename=path.name) + + +@router.delete("/{filename}", response_model=ApiSuccess) +def delete_backup( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.delete")), +): + path = BackupService.resolve_backup_path(filename) + size_bytes = path.stat().st_size + BackupService.delete_backup(filename) + write_audit_log( + db, + action="backups.delete", + entity_type="backup", + entity_label=path.name, + actor=current_user, + request=request, + metadata={"filename": path.name, "size_bytes": size_bytes}, + ) + return ApiSuccess(message="Backup geloescht") + + +@router.post("/{filename}/restore/validate", response_model=ApiSuccess) +def validate_backup_restore( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.restore")), +): + validation = BackupService.validate_backup(filename) + write_audit_log( + db, + action="backups.validate", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues}, + ) + return ApiSuccess(data=validation, message=validation.message) + + +@router.post("/{filename}/restore", response_model=ApiSuccess) +def restore_backup( + filename: str, + payload: BackupRestoreRequest, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.restore")), +): + write_audit_log( + db, + action="backups.restore_started", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + try: + validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text) + except Exception: + write_audit_log( + db, + action="backups.restore_failed", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + raise + + write_audit_log( + db, + action="backups.restore_completed", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + return ApiSuccess(data=validation, message="Restore abgeschlossen") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index a70dc6a..02d2752 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -4,14 +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__) @@ -32,6 +39,8 @@ def get_dashboard_summary( users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + inventory: list[MetricCard] = [] + system_status: list[SystemStatusItem] = [] if "customers.read" in permissions: customers = [ @@ -64,8 +73,118 @@ def get_dashboard_summary( MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")), MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")), MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")), + MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)), + MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)), + MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)), + MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)), ] + if "repair_estimates.read" in permissions: + repairs.extend([ + MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)), + MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)), + MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)), + MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)), + MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)), + ]) + + if "lexware.read" in permissions: + repairs.extend([ + MetricCard( + label="Vorbereitete Rechnungen", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "prepared") + ) or 0, + ), + MetricCard( + label="An Buchhaltung übergeben", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "transferred") + ) or 0, + ), + MetricCard( + label="Noch nicht übertragen", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "prepared") + ) or 0, + ), + ]) + + if "inventory.read" in permissions: + inventory = [ + MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), + MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)), + MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)), + MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)), + MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)), + MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)), + MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))), + ] + + if "system_settings.manage" in permissions: + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + public_links = SystemSettingsService.get_public_links_settings(db) + latest_testmail = db.scalar( + select(AuditLog) + .where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"])) + .order_by(AuditLog.created_at.desc(), AuditLog.id.desc()) + .limit(1) + ) + latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db) + system_status = [ + SystemStatusItem( + label="SMTP-Konfiguration", + value={ + "database": "Admin-Konfiguration", + "environment": "Umgebung", + "missing": "Nicht konfiguriert", + }[smtp_config.source], + status="ok" if smtp_config.is_configured else "warning", + ), + SystemStatusItem( + label="Public Status Base URL", + value=public_links.repair_status_base_url or "Nicht konfiguriert", + status="ok" if public_links.repair_status_base_url else "warning", + ), + SystemStatusItem( + label="Letzte SMTP-Testmail", + value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail", + status="info" if latest_testmail else "warning", + ), + SystemStatusItem( + label="Letzte fehlgeschlagene Statusmail", + value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler", + status="warning" if latest_failed_status_mail else "ok", + ), + ] + + if "backup.read" in permissions: + backup_stats = BackupService.get_backup_stats() + repairs.append(MetricCard(label="Backups", value=backup_stats.total_count)) + system_status.extend([ + SystemStatusItem( + label="Letztes Backup", + value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup", + status="ok" if backup_stats.latest_backup_at else "warning", + ), + SystemStatusItem( + label="Backup-Speicher", + value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes", + status="ok" if backup_stats.total_count else "warning", + ), + SystemStatusItem( + label="Hermes-Version", + value=settings.app_version, + status="info", + ), + ]) + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) return DashboardSummary( @@ -74,6 +193,8 @@ def get_dashboard_summary( users=users, roles=roles, repairs=repairs, + inventory=inventory, + system_status=system_status, activities=EmptyWidget( title="Letzte Aktivitäten", message="Noch keine Aktivitäten vorhanden.", diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py new file mode 100644 index 0000000..8fd3466 --- /dev/null +++ b/backend/hermes/app/api/inventory.py @@ -0,0 +1,319 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryCategoryResponse, + InventoryItemCreate, + InventoryItemListResponse, + InventoryItemResponse, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryLocationResponse, + InventoryStockAction, + InventoryStockMovementResponse, + InventorySupplierPayload, + InventorySupplierResponse, +) +from app.services.inventory_service import InventoryService + +router = APIRouter(prefix="/inventory", tags=["Inventory"]) + + +def get_item_or_404(db: Session, item_id: int) -> InventoryItem: + item = InventoryRepository.get_item(db, item_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") + return item + + +def get_category_or_404(db: Session, category_id: int) -> InventoryCategory: + category = InventoryRepository.get_category(db, category_id) + if category is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden") + return category + + +def get_location_or_404(db: Session, location_id: int) -> InventoryLocation: + location = InventoryRepository.get_location(db, location_id) + if location is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden") + return location + + +def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier: + supplier = InventoryRepository.get_supplier(db, supplier_id) + if supplier is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden") + return supplier + + +@router.get("/items", response_model=InventoryItemListResponse) +def list_items( + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + items, total = InventoryRepository.list_items( + db, + q=q, + category_id=category_id, + supplier_id=supplier_id, + location_id=location_id, + low_stock=low_stock, + active=active, + limit=limit, + offset=offset, + ) + return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.get("/items/search", response_model=list[InventoryItemResponse]) +def search_items( + q: str | None = None, + category: int | None = None, + manufacturer: str | None = None, + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit) + + +@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) +def create_item( + payload: InventoryItemCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.create")), +): + return InventoryService.create_item(db, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}", response_model=InventoryItemResponse) +def get_item( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return get_item_or_404(db, item_id) + + +@router.put("/items/{item_id}", response_model=InventoryItemResponse) +def update_item( + item_id: int, + payload: InventoryItemUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.update")), +): + item = get_item_or_404(db, item_id) + return InventoryService.update_item(db, item, payload, actor=current_user, request=request) + + +@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None) +def delete_item( + item_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.delete")), +): + item = get_item_or_404(db, item_id) + return InventoryService.delete_item(db, item, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse) +def adjust_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.adjust")), +): + item = get_item_or_404(db, item_id) + return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse) +def reserve_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse) +def release_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.release_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse) +def consume_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.consume")), +): + item = get_item_or_404(db, item_id) + return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse]) +def list_movements( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + get_item_or_404(db, item_id) + return InventoryRepository.list_movements(db, item_id) + + +@router.get("/categories", response_model=list[InventoryCategoryResponse]) +def list_categories( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_categories(db) + + +@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED) +def create_category( + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_category(db, payload, actor=current_user, request=request) + + +@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse) +def update_category( + category_id: int, + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + return InventoryService.update_category(db, category, payload, actor=current_user, request=request) + + +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category( + category_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request) + return None + + +@router.get("/locations", response_model=list[InventoryLocationResponse]) +def list_locations( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_locations(db) + + +@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED) +def create_location( + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_location(db, payload, actor=current_user, request=request) + + +@router.put("/locations/{location_id}", response_model=InventoryLocationResponse) +def update_location( + location_id: int, + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + return InventoryService.update_location(db, location, payload, actor=current_user, request=request) + + +@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_location( + location_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request) + return None + + +@router.get("/suppliers", response_model=list[InventorySupplierResponse]) +def list_suppliers( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_suppliers(db) + + +@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED) +def create_supplier( + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_supplier(db, payload, actor=current_user, request=request) + + +@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse) +def update_supplier( + supplier_id: int, + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request) + + +@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_supplier( + supplier_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request) + return None diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py new file mode 100644 index 0000000..03b20c0 --- /dev/null +++ b/backend/hermes/app/api/lexware.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.repair import Repair +from app.models.repair_estimate import RepairEstimate +from app.models.user import User +from app.repositories.repair_estimate_repository import RepairEstimateRepository +from app.repositories.repair_repository import RepairRepository +from app.schemas.lexware import ( + AccountingTransferUpdate, + LexwareInvoicePreparationResponse, + LexwareSettingsResponse, + LexwareSettingsUpdate, + LexwareTestConnectionResponse, +) +from app.services.lexware_service import LexwareService + +router = APIRouter(tags=["Lexware"]) + + +def get_repair_or_404(db: Session, repair_id: int) -> Repair: + repair = RepairRepository.get_by_id(db, repair_id) + if repair is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden") + return repair + + +def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate: + estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id) + if estimate is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden") + return estimate + + +@router.get("/lexware/settings", response_model=LexwareSettingsResponse) +def get_lexware_settings( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("lexware.read")), +): + return LexwareService.get_settings(db) + + +@router.put("/lexware/settings", response_model=LexwareSettingsResponse) +def update_lexware_settings( + payload: LexwareSettingsUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("lexware.manage")), +): + return LexwareService.update_settings(db, payload, actor=current_user, request=request) + + +@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse) +def test_lexware_connection( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("lexware.manage")), +): + return LexwareService.test_connection(db, actor=current_user, request=request) + + +@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse) +def prepare_lexware_invoice( + repair_id: int, + estimate_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("lexware.export")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request) + + +@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse) +def mark_accounting_transferred( + repair_id: int, + estimate_id: int, + payload: AccountingTransferUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("lexware.export")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request) diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py new file mode 100644 index 0000000..260754c --- /dev/null +++ b/backend/hermes/app/api/repair_estimates.py @@ -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) diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py index ffdc53b..ffa8f2a 100644 --- a/backend/hermes/app/api/repairs.py +++ b/backend/hermes/app/api/repairs.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +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 @@ -11,8 +12,13 @@ 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, @@ -25,6 +31,7 @@ from app.schemas.repair import ( RepairUpdate, ) from app.services.repair_notification_service import RepairNotificationService +from app.services.repair_document_service import RepairDocumentService from app.services.repair_public_link_service import RepairPublicLinkService from app.services.repair_service import RepairService @@ -129,6 +136,117 @@ def get_repair_history( return RepairRepository.get_history(db, repair_id) +@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse]) +def list_repair_documents( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + return RepairRepository.list_documents(db, repair_id) + + +@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_repair_document( + repair_id: int, + request: Request, + file: UploadFile = File(...), + title: str = Form(..., min_length=1, max_length=255), + document_type: RepairDocumentType = Form("other"), + visibility: RepairDocumentVisibility = Form("internal"), + note: str | None = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + normalized_title = title.strip() + normalized_note = note.strip() if note else None + if not normalized_title: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich") + return await RepairDocumentService.upload_document( + db, + db_repair, + file=file, + title=normalized_title, + document_type=document_type, + visibility=visibility, + note=normalized_note, + actor=current_user, + request=request, + ) + + +@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def get_repair_document( + repair_id: int, + document_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return document + + +@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def update_repair_document( + repair_id: int, + document_id: int, + payload: RepairDocumentUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/documents/{document_id}/download") +def download_repair_document( + repair_id: int, + document_id: int, + disposition: str = Query(default="inline", pattern="^(inline|attachment)$"), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + file_path = RepairDocumentService.open_document_file(document) + return FileResponse( + file_path, + media_type=document.mime_type or "application/octet-stream", + headers={ + "Content-Disposition": RepairDocumentService.content_disposition( + document, + mode="attachment" if disposition == "attachment" else "inline", + ), + }, + ) + + +@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_repair_document( + repair_id: int, + document_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request) + return None + + @router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse) def get_public_link( repair_id: int, @@ -171,6 +289,17 @@ def get_repair_notifications( return RepairNotificationService.overview(db, db_repair) +@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse) +def send_repair_status_mail( + repair_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True) + + @router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED) def create_repair_intake( payload: RepairIntakePayload, diff --git a/backend/hermes/app/api/system_settings.py b/backend/hermes/app/api/system_settings.py new file mode 100644 index 0000000..0c3170f --- /dev/null +++ b/backend/hermes/app/api/system_settings.py @@ -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) diff --git a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc index c828751..c789c9b 100644 Binary files a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc and b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc differ diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index ba364cf..2a3d97f 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): secret_key: str app_name: str = "Hermes API" - app_version: str = "0.1.0" + app_version: str = "0.9.1" access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" log_level: str = "INFO" @@ -24,6 +24,17 @@ class Settings(BaseSettings): knowledge_storage_path: str = "/data/knowledge" knowledge_max_upload_mb: int = 50 olympus_repair_intake_token: str | None = None + public_repair_status_base_url: str | None = None + smtp_host: str | None = None + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + smtp_from_email: str | None = None + smtp_from_name: str = "Funktechnik Schubert" + smtp_use_tls: bool = True + lexware_enabled: bool = False + lexware_api_base_url: str = "https://api.lexware.io" + lexware_api_key: str | None = None model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/db/__pycache__/database.cpython-314.pyc b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc index 79708cf..a25929a 100644 Binary files a/backend/hermes/app/db/__pycache__/database.cpython-314.pyc and b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc differ diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index caf7949..c860b24 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -25,6 +25,11 @@ import app.models.customer import app.models.knowledge import app.models.audit import app.models.user +import app.models.inventory +import app.models.repair +import app.models.repair_estimate +import app.models.system_setting +import app.models.lexware def get_db(): diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index fbcc986..04c84ca 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -11,12 +11,17 @@ 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 @@ -29,19 +34,24 @@ configure_logging() app = FastAPI( title="Hermes API", - version="0.1.0", + version="0.9.1", description="Backend von Olympus", ) app.include_router(auth_router) app.include_router(audit_router) +app.include_router(backups_router) app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) app.include_router(customers_router) +app.include_router(inventory_router) app.include_router(knowledge_router) app.include_router(repairs_router) +app.include_router(repair_estimates_router) app.include_router(dashboard_router) +app.include_router(system_settings_router) +app.include_router(lexware_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/__pycache__/user.cpython-314.pyc b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc index 5a9e9b9..d25fb9d 100644 Binary files a/backend/hermes/app/models/__pycache__/user.cpython-314.pyc and b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc differ diff --git a/backend/hermes/app/models/inventory.py b/backend/hermes/app/models/inventory.py new file mode 100644 index 0000000..ecfdfc9 --- /dev/null +++ b/backend/hermes/app/models/inventory.py @@ -0,0 +1,114 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class InventoryCategory(Base): + __tablename__ = "inventory_categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160), unique=True, index=True) + slug: Mapped[str] = mapped_column(String(180), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="category") + + +class InventoryLocation(Base): + __tablename__ = "inventory_locations" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="location") + + +class InventorySupplier(Base): + __tablename__ = "inventory_suppliers" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(180), unique=True, index=True) + contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(80), nullable=True) + website: Mapped[str | None] = mapped_column(String(255), nullable=True) + customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier") + + +class InventoryItem(Base): + __tablename__ = "inventory_items" + __table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),) + + id: Mapped[int] = mapped_column(primary_key=True) + sku: Mapped[str] = mapped_column(String(40), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True) + manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True) + supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True) + location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True) + quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True) + reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.") + purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR") + tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined") + supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined") + location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined") + movements: Mapped[list["InventoryStockMovement"]] = relationship( + back_populates="item", + cascade="all, delete-orphan", + order_by="InventoryStockMovement.created_at.desc()", + ) + + +class InventoryStockMovement(Base): + __tablename__ = "inventory_stock_movements" + + id: Mapped[int] = mapped_column(primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True) + movement_type: Mapped[str] = mapped_column(String(40), index=True) + quantity: Mapped[int] = mapped_column(Integer) + reason: Mapped[str] = mapped_column(String(255), default="", server_default="") + reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + item: Mapped[InventoryItem] = relationship(back_populates="movements") + actor = relationship( + "User", + foreign_keys="InventoryStockMovement.actor_user_id", + lazy="joined", + ) + + @property + def actor_username(self) -> str: + return self.actor.username if self.actor is not None else "" diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py new file mode 100644 index 0000000..febb874 --- /dev/null +++ b/backend/hermes/app/models/lexware.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.database import Base + + +class LexwareSyncRecord(Base): + __tablename__ = "lexware_sync_records" + + id: Mapped[int] = mapped_column(primary_key=True) + entity_type: Mapped[str] = mapped_column(String(80), index=True) + entity_id: Mapped[int] = mapped_column(Integer, index=True) + lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True) + lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True) + direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True) + export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True) + accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True) + payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index 5eae60b..6b32497 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func +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 @@ -41,6 +41,7 @@ class Repair(Base): history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin") public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan") notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan") + documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan") class RepairStatusHistory(Base): @@ -55,7 +56,11 @@ class RepairStatusHistory(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) repair: Mapped[Repair] = relationship(back_populates="history") - actor = relationship("User", lazy="joined") + actor = relationship( + "User", + foreign_keys="RepairStatusHistory.actor_user_id", + lazy="joined", + ) @property def actor_username(self) -> str: @@ -89,10 +94,37 @@ class RepairDocument(Base): id: Mapped[int] = mapped_column(primary_key=True) repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) file_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True) title: Mapped[str] = mapped_column(String(255)) document_type: Mapped[str] = mapped_column(String(80), index=True) + original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="") + mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="") + size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True) + visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + repair: Mapped[Repair] = relationship(back_populates="documents") + uploaded_by = relationship( + "User", + foreign_keys="RepairDocument.uploaded_by_user_id", + lazy="joined", + ) + + @property + def uploaded_by_username(self) -> str: + return self.uploaded_by.username if self.uploaded_by is not None else "" + + @property + def uploaded_by_display_name(self) -> str: + if self.uploaded_by is None: + return "" + display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip() + return display_name or self.uploaded_by.username class RepairPublicAccessToken(Base): @@ -120,7 +152,9 @@ class RepairNotificationEvent(Base): channel: Mapped[str] = mapped_column(String(40), index=True) recipient: Mapped[str] = mapped_column(String(255), default="", server_default="") subject: Mapped[str] = mapped_column(String(255)) + template: Mapped[str] = mapped_column(Text, default="", server_default="") status: Mapped[str] = mapped_column(String(40), index=True) + success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py new file mode 100644 index 0000000..9cdda96 --- /dev/null +++ b/backend/hermes/app/models/repair_estimate.py @@ -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", + ) diff --git a/backend/hermes/app/models/system_setting.py b/backend/hermes/app/models/system_setting.py new file mode 100644 index 0000000..e3350a3 --- /dev/null +++ b/backend/hermes/app/models/system_setting.py @@ -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(), + ) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 2cde2c8..6661bff 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -69,6 +69,7 @@ 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"), @@ -84,6 +85,28 @@ STANDARD_PERMISSIONS = [ ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), ("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"), + ("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"), + ("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"), + ("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"), + ("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"), + ("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"), + ("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"), + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), + ("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"), + ("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"), + ("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"), + ("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"), + ("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"), + ("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"), + ("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"), + ("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"), ] ROLE_PERMISSION_NAMES = { @@ -101,6 +124,24 @@ ROLE_PERMISSION_NAMES = { "repairs.update", "repairs.status.update", "repairs.public_link.manage", + "repair_estimates.read", + "repair_estimates.create", + "repair_estimates.update", + "repair_estimates.send", + "repair_estimates.revoke", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", + "lexware.read", + "lexware.manage", + "lexware.export", + "backup.read", + "backup.create", + "backup.download", }, "sales": { "dashboard.read", @@ -124,6 +165,13 @@ ROLE_PERMISSION_NAMES = { "repairs.read", "repairs.update", "repairs.status.update", + "repair_estimates.read", + "repair_estimates.create", + "repair_estimates.update", + "repair_estimates.send", + "inventory.read", + "inventory.stock.reserve", + "inventory.stock.consume", }, "support": { "dashboard.read", @@ -140,9 +188,20 @@ ROLE_PERMISSION_NAMES = { "repairs.status.update", "repairs.intake", "repairs.public_link.manage", + "repair_estimates.read", + "repair_estimates.send", + "inventory.read", + "lexware.read", }, "warehouse": { "dashboard.read", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", }, "guest": { "dashboard.read", diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py new file mode 100644 index 0000000..19c2db2 --- /dev/null +++ b/backend/hermes/app/repositories/inventory_repository.py @@ -0,0 +1,229 @@ +from datetime import UTC, datetime + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.inventory import ( + InventoryCategory, + InventoryItem, + InventoryLocation, + InventoryStockMovement, + InventorySupplier, +) + + +class InventoryRepository: + @staticmethod + def item_query() -> Select[tuple[InventoryItem]]: + return select(InventoryItem).options( + selectinload(InventoryItem.category), + selectinload(InventoryItem.supplier), + selectinload(InventoryItem.location), + ) + + @staticmethod + def list_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[InventoryItem], int]: + query = InventoryRepository.item_query() + if q: + pattern = f"%{q.strip()}%" + query = query.where(or_( + InventoryItem.sku.ilike(pattern), + InventoryItem.name.ilike(pattern), + InventoryItem.manufacturer.ilike(pattern), + InventoryItem.manufacturer_part_number.ilike(pattern), + InventoryItem.supplier_part_number.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if supplier_id is not None: + query = query.where(InventoryItem.supplier_id == supplier_id) + if location_id is not None: + query = query.where(InventoryItem.location_id == location_id) + if low_stock is not None: + if low_stock: + query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + else: + query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level) + if active is not None: + query = query.where(InventoryItem.is_active == active) + + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + items = list( + db.scalars( + query + .order_by(InventoryItem.name.asc(), InventoryItem.id.asc()) + .offset(offset) + .limit(limit) + ) + ) + return items, total + + @staticmethod + def search_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + manufacturer: str | None = None, + limit: int = 20, + ) -> list[InventoryItem]: + query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True)) + if q: + pattern = f"%{q.strip()}%" + query = query.where(or_( + InventoryItem.sku.ilike(pattern), + InventoryItem.name.ilike(pattern), + InventoryItem.manufacturer.ilike(pattern), + InventoryItem.manufacturer_part_number.ilike(pattern), + InventoryItem.supplier_part_number.ilike(pattern), + InventoryItem.compatible_devices.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if manufacturer: + query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%")) + return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit))) + + @staticmethod + def get_item(db: Session, item_id: int) -> InventoryItem | None: + return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id)) + + @staticmethod + def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None: + return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku)) + + @staticmethod + def get_next_sku(db: Session, year: int | None = None) -> str: + current_year = year or datetime.now(UTC).year + prefix = f"ET-{current_year}-" + latest = db.scalar( + select(InventoryItem.sku) + .where(InventoryItem.sku.like(f"{prefix}%")) + .order_by(InventoryItem.sku.desc()) + .limit(1) + ) + next_number = 1 + if latest: + next_number = int(latest.split("-")[-1]) + 1 + return f"{prefix}{next_number:06d}" + + @staticmethod + def list_categories(db: Session) -> list[InventoryCategory]: + return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc()))) + + @staticmethod + def get_category(db: Session, category_id: int) -> InventoryCategory | None: + return db.get(InventoryCategory, category_id) + + @staticmethod + def list_locations(db: Session) -> list[InventoryLocation]: + return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc()))) + + @staticmethod + def get_location(db: Session, location_id: int) -> InventoryLocation | None: + return db.get(InventoryLocation, location_id) + + @staticmethod + def list_suppliers(db: Session) -> list[InventorySupplier]: + return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc()))) + + @staticmethod + def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None: + return db.get(InventorySupplier, supplier_id) + + @staticmethod + def has_movements(db: Session, item_id: int) -> bool: + return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1))) + + @staticmethod + def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .where(InventoryStockMovement.item_id == item_id) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + ) + ) + + @staticmethod + def create_movement( + db: Session, + *, + item_id: int, + movement_type: str, + quantity: int, + reason: str, + reference_type: str | None, + reference_id: int | None, + note: str | None, + actor_user_id: int | None, + ) -> InventoryStockMovement: + movement = InventoryStockMovement( + item_id=item_id, + movement_type=movement_type, + quantity=quantity, + reason=reason, + reference_type=reference_type, + reference_id=reference_id, + note=note, + actor_user_id=actor_user_id, + ) + db.add(movement) + return movement + + @staticmethod + def count_active_items(db: Session) -> int: + return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0 + + @staticmethod + def count_low_stock_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + ) or 0 + + @staticmethod + def total_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.purchase_price_cents.is_not(None)) + ) or 0 + + @staticmethod + def count_reserved_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_reserved > 0) + ) or 0 + + @staticmethod + def reserved_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.selling_price_cents.is_not(None)) + ) or 0 + + @staticmethod + def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + .limit(limit) + ) + ) diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py new file mode 100644 index 0000000..db20a7c --- /dev/null +++ b/backend/hermes/app/repositories/repair_estimate_repository.py @@ -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 diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 6ec0e2d..a32e175 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -3,8 +3,8 @@ from datetime import UTC, datetime from sqlalchemy import Select, func, or_, select from sqlalchemy.orm import Session, selectinload -from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory -from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate +from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory +from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate class RepairRepository: @@ -221,6 +221,7 @@ class RepairRepository: select(RepairPublicAccessToken) .where(RepairPublicAccessToken.repair_id == repair_id) .where(RepairPublicAccessToken.is_active.is_(True)) + .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc()) .limit(1) @@ -234,6 +235,7 @@ class RepairRepository: .options(selectinload(RepairPublicAccessToken.repair)) .where(RepairPublicAccessToken.token_hash == token_hash) .where(RepairPublicAccessToken.is_active.is_(True)) + .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .limit(1) ) @@ -299,7 +301,9 @@ class RepairRepository: channel: str, recipient: str, subject: str, + template: str, status: str, + success: bool, error_message: str | None = None, sent_at: datetime | None = None, ) -> RepairNotificationEvent: @@ -309,7 +313,9 @@ class RepairRepository: channel=channel, recipient=recipient, subject=subject, + template=template, status=status, + success=success, error_message=error_message, sent_at=sent_at, ) @@ -318,6 +324,115 @@ class RepairRepository: db.refresh(event) return event + @staticmethod + def count_status_mails_sent_today(db: Session) -> int: + today = datetime.now(UTC).date() + return db.scalar( + select(func.count(RepairNotificationEvent.id)) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(True)) + .where(func.date(RepairNotificationEvent.created_at) == today) + ) or 0 + + @staticmethod + def count_failed_status_mails(db: Session) -> int: + return db.scalar( + select(func.count(RepairNotificationEvent.id)) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(False)) + .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) + ) or 0 + + @staticmethod + def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None: + return db.scalar( + select(RepairNotificationEvent) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(False)) + .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) + .order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc()) + .limit(1) + ) + + @staticmethod + def count_open_repairs_without_customer_email(db: Session) -> int: + return db.scalar( + select(func.count(Repair.id)) + .where(~Repair.status.in_(["completed", "cancelled"])) + .where((Repair.customer_email == "") | Repair.customer_email.is_(None)) + ) or 0 + + @staticmethod + def list_documents(db: Session, repair_id: int) -> list[RepairDocument]: + return list( + db.scalars( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc()) + ) + ) + + @staticmethod + def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None: + return db.scalar( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .where(RepairDocument.id == document_id) + ) + + @staticmethod + def create_document( + db: Session, + *, + repair_id: int, + title: str, + document_type: str, + original_filename: str, + stored_filename: str, + storage_path: str, + mime_type: str, + size_bytes: int, + checksum_sha256: str, + visibility: str, + note: str | None, + uploaded_by_user_id: int | None, + ) -> RepairDocument: + document = RepairDocument( + repair_id=repair_id, + title=title, + document_type=document_type, + original_filename=original_filename, + stored_filename=stored_filename, + storage_path=storage_path, + mime_type=mime_type, + size_bytes=size_bytes, + checksum_sha256=checksum_sha256, + visibility=visibility, + note=note, + uploaded_by_user_id=uploaded_by_user_id, + ) + db.add(document) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document + + @staticmethod + def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument: + for key, value in payload.model_dump().items(): + setattr(document, key, value) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document + + @staticmethod + def delete_document(db: Session, document: RepairDocument) -> None: + db.delete(document) + db.commit() + + @staticmethod + def count_documents(db: Session) -> int: + return db.scalar(select(func.count(RepairDocument.id))) or 0 + @staticmethod def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: data = payload.model_dump() diff --git a/backend/hermes/app/repositories/system_settings_repository.py b/backend/hermes/app/repositories/system_settings_repository.py new file mode 100644 index 0000000..fc8adf1 --- /dev/null +++ b/backend/hermes/app/repositories/system_settings_repository.py @@ -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 diff --git a/backend/hermes/app/schemas/backup.py b/backend/hermes/app/schemas/backup.py new file mode 100644 index 0000000..661d1e9 --- /dev/null +++ b/backend/hermes/app/schemas/backup.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class BackupManifest(BaseModel): + backup_id: str + created_at: datetime + app_version: str + backup_type: str = "full" + database_url_host_anonymized: str + database_name: str + storage_base_path: str + included_sections: list[str] = Field(default_factory=list) + file_count: int = 0 + total_size_bytes: int = 0 + checksum_sha256: str + created_by_user_id: int | None = None + created_by_username: str = "" + + +class BackupSummary(BaseModel): + filename: str + size_bytes: int + created_at: datetime | None = None + app_version: str = "" + backup_type: str = "full" + database_name: str = "" + storage_base_path: str = "" + file_count: int = 0 + total_size_bytes: int = 0 + created_by_user_id: int | None = None + created_by_username: str = "" + validation_status: str = "valid" + validation_message: str = "" + + +class BackupListResponse(BaseModel): + items: list[BackupSummary] = Field(default_factory=list) + total_count: int = 0 + total_size_bytes: int = 0 + latest_backup_at: datetime | None = None + + +class BackupValidationResponse(BaseModel): + filename: str + valid: bool + message: str + issues: list[str] = Field(default_factory=list) + checksum_valid: bool = False + restore_supported: bool = False + requires_cli_restore: bool = True + manifest: BackupManifest | None = None + + +class BackupRestoreRequest(BaseModel): + confirm_text: str diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index ae89527..8eae677 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -13,12 +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 diff --git a/backend/hermes/app/schemas/inventory.py b/backend/hermes/app/schemas/inventory.py new file mode 100644 index 0000000..093fb55 --- /dev/null +++ b/backend/hermes/app/schemas/inventory.py @@ -0,0 +1,231 @@ +from datetime import datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +InventoryMovementType = Literal[ + "initial", + "purchase", + "adjustment", + "reservation", + "release", + "consumption", + "return", + "correction", +] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def normalize_optional_text(value: object) -> str | None: + text = normalize_text(value) + return text or None + + +class InventoryCategoryPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryLocationPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventorySupplierPayload(BaseModel): + name: str = Field(min_length=1, max_length=180) + contact_name: str | None = None + email: str | None = None + phone: str | None = None + website: str | None = None + customer_number: str | None = None + notes: str | None = None + + @field_validator("*", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_optional_text(value) + + +class InventoryItemPayload(BaseModel): + sku: str | None = Field(default=None, max_length=40) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + category_id: int | None = None + manufacturer: str | None = None + manufacturer_part_number: str | None = None + supplier_id: int | None = None + supplier_part_number: str | None = None + compatible_devices: str | None = None + location_id: int | None = None + quantity_on_hand: int = Field(default=0, ge=0) + quantity_reserved: int = Field(default=0, ge=0) + reorder_level: int = Field(default=0, ge=0) + unit: str = Field(default="Stk.", max_length=40) + purchase_price_cents: int | None = Field(default=None, ge=0) + selling_price_cents: int | None = Field(default=None, ge=0) + currency: str = Field(default="EUR", max_length=3) + tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) + notes: str | None = None + is_active: bool = True + + @field_validator( + "sku", + "name", + "description", + "manufacturer", + "manufacturer_part_number", + "supplier_part_number", + "compatible_devices", + "unit", + "currency", + "notes", + mode="before", + ) + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() or "EUR" + + @field_validator("tax_rate_percent", mode="before") + @classmethod + def normalize_tax_rate(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + + @model_validator(mode="after") + def validate_reserved_quantity(self): + if self.quantity_reserved > self.quantity_on_hand: + raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten") + return self + + +class InventoryItemCreate(InventoryItemPayload): + pass + + +class InventoryItemUpdate(InventoryItemPayload): + pass + + +class InventoryStockAction(BaseModel): + quantity: int = Field(ge=0) + reason: str = Field(min_length=1, max_length=255) + note: str | None = None + reference_type: str | None = Field(default=None, max_length=80) + reference_id: int | None = None + + @field_validator("reason", "note", "reference_type", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryCategoryResponse(InventoryCategoryPayload): + id: int + slug: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryLocationResponse(InventoryLocationPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventorySupplierResponse(InventorySupplierPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemResponse(BaseModel): + id: int + sku: str + name: str + description: str | None + category_id: int | None + manufacturer: str | None + manufacturer_part_number: str | None + supplier_id: int | None + supplier_part_number: str | None + compatible_devices: str | None + location_id: int | None + quantity_on_hand: int + quantity_reserved: int + quantity_available: int + reorder_level: int + unit: str + purchase_price_cents: int | None + selling_price_cents: int | None + currency: str + tax_rate_percent: Decimal + notes: str | None + is_active: bool + created_at: datetime + updated_at: datetime + category: InventoryCategoryResponse | None + supplier: InventorySupplierResponse | None + location: InventoryLocationResponse | None + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemListResponse(BaseModel): + items: list[InventoryItemResponse] + total: int + limit: int + offset: int + + +class InventoryStockMovementResponse(BaseModel): + id: int + item_id: int + movement_type: str + quantity: int + reason: str + reference_type: str | None + reference_id: int | None + note: str | None + actor_user_id: int | None + actor_username: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py new file mode 100644 index 0000000..45cd103 --- /dev/null +++ b/backend/hermes/app/schemas/lexware.py @@ -0,0 +1,96 @@ +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.schemas.system_setting import SettingsSource, normalize_text + + +LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"] +LexwareSyncDirection = Literal["push", "pull"] +AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"] + + +class LexwareSettingsResponse(BaseModel): + enabled: bool = False + api_base_url: str = "https://api.lexware.io" + api_key_is_set: bool = False + organization_name: str = "" + default_tax_rate: Decimal = Decimal("19.00") + default_payment_terms_days: int = 14 + source: SettingsSource + + +class LexwareSettingsUpdate(BaseModel): + enabled: bool = False + api_base_url: str = Field(default="https://api.lexware.io", max_length=500) + api_key: str | None = Field(default=None, max_length=2000) + organization_name: str = Field(default="", max_length=255) + default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100")) + default_payment_terms_days: int = Field(default=14, ge=0, le=365) + + @field_validator("api_base_url", "api_key", "organization_name", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str: + return normalize_text(value) + + @field_validator("api_base_url") + @classmethod + def normalize_base_url(cls, value: str) -> str: + return (value or "https://api.lexware.io").rstrip("/") + + @model_validator(mode="after") + def validate_enabled_configuration(self): + if self.enabled and not self.api_base_url: + raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist") + return self + + +class LexwareTestConnectionResponse(BaseModel): + success: bool + message: str + source: SettingsSource + api_base_url: str + organization_name: str = "" + + +class LexwareCustomerMapping(BaseModel): + name: str + email: str + phone: str + search_strategy: str + create_payload: dict + + +class LexwareLineItemMapping(BaseModel): + title: str + description: str | None + quantity: Decimal + unit: str + unit_price: Decimal + tax_rate: Decimal + total: Decimal + + +class LexwareInvoicePreparationResponse(BaseModel): + ready_for_export: bool + export_status: AccountingExportStatus + payload_summary: dict + customer_mapping: LexwareCustomerMapping + line_item_mapping: list[LexwareLineItemMapping] + tax_mapping: dict + warnings: list[str] + sync_record_id: int + accounting_note: str = "" + transferred_at: str | None = None + transferred_by_user_id: int | None = None + + +class AccountingTransferUpdate(BaseModel): + accounting_note: str | None = Field(default=None, max_length=2000) + + @field_validator("accounting_note", mode="before") + @classmethod + def normalize_note(cls, value: object) -> str | None: + text = normalize_text(value) + return text or None diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py index f086730..90e0653 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -3,6 +3,8 @@ 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", @@ -19,6 +21,16 @@ RepairStatus = Literal[ ] RepairPriority = Literal["low", "normal", "high", "urgent"] RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"] +RepairDocumentType = Literal[ + "device_photo", + "fault_photo", + "measurement", + "estimate", + "repair_report", + "shipping", + "other", +] +RepairDocumentVisibility = Literal["internal", "customer"] def normalize_text(value: object) -> str: @@ -192,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel): device_model: str status_history_public: list[RepairPublicStatusHistoryItem] updated_at: datetime + estimate: PublicEstimateResponse | None = None class RepairNotificationTemplateResponse(BaseModel): @@ -209,7 +222,9 @@ class RepairNotificationEventResponse(BaseModel): channel: str recipient: str subject: str + template: str status: str + success: bool error_message: str | None created_at: datetime sent_at: datetime | None @@ -220,3 +235,39 @@ class RepairNotificationEventResponse(BaseModel): class RepairNotificationOverviewResponse(BaseModel): templates: list[RepairNotificationTemplateResponse] events: list[RepairNotificationEventResponse] + + +class RepairDocumentBase(BaseModel): + title: str = Field(min_length=1, max_length=255) + document_type: RepairDocumentType = "other" + visibility: RepairDocumentVisibility = "internal" + note: str | None = None + + @field_validator("title", "note", mode="before") + @classmethod + def normalize_document_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class RepairDocumentUpdate(RepairDocumentBase): + pass + + +class RepairDocumentResponse(RepairDocumentBase): + id: int + repair_id: int + original_filename: str + stored_filename: str + storage_path: str + mime_type: str + size_bytes: int + checksum_sha256: str + uploaded_by_user_id: int | None + uploaded_by_username: str = "" + uploaded_by_display_name: str = "" + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py new file mode 100644 index 0000000..b1b68e5 --- /dev/null +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -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) diff --git a/backend/hermes/app/schemas/system_setting.py b/backend/hermes/app/schemas/system_setting.py new file mode 100644 index 0000000..fa4b266 --- /dev/null +++ b/backend/hermes/app/schemas/system_setting.py @@ -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("/") diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 7044759..14110e0 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository logger = logging.getLogger(__name__) -SENSITIVE_KEYS = {"password", "password_hash", "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: @@ -165,6 +165,63 @@ def action_title(action: str) -> str: "repairs.intake_failed": "Reparatur-Intake fehlgeschlagen", "repairs.public_link.create": "Reparatur-Statuslink erstellt", "repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert", + "repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt", + "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", + "repairs.status_mail.sent": "Statusmail versendet", + "repairs.status_mail.failed": "Statusmail fehlgeschlagen", + "repairs.documents.upload": "Reparaturdokument hochgeladen", + "repairs.documents.update": "Reparaturdokument geändert", + "repairs.documents.delete": "Reparaturdokument gelöscht", + "repair_estimates.create": "Kostenvoranschlag erstellt", + "repair_estimates.update": "Kostenvoranschlag geändert", + "repair_estimates.send": "Kostenvoranschlag gesendet", + "repair_estimates.approve": "Kostenvoranschlag freigegeben", + "repair_estimates.decline": "Kostenvoranschlag abgelehnt", + "repair_estimates.question": "Rückfrage zum Kostenvoranschlag", + "repair_estimates.cancel": "Kostenvoranschlag storniert", + "repair_estimates.revoke": "Kostenvoranschlag zurückgenommen", + "repair_estimates.delete": "Kostenvoranschlag gelöscht", + "inventory.items.create": "Lagerartikel erstellt", + "inventory.items.update": "Lagerartikel geändert", + "inventory.items.deactivate": "Lagerartikel deaktiviert", + "inventory.items.delete": "Lagerartikel gelöscht", + "inventory.stock.adjust": "Bestand angepasst", + "inventory.stock.reserve": "Bestand reserviert", + "inventory.stock.release": "Reservierung aufgehoben", + "inventory.stock.consume": "Bestand verbraucht", + "inventory.estimate.reserve": "KV reserviert Lagerbestand", + "inventory.estimate.release": "KV-Reservierung aufgehoben", + "inventory.estimate.consume": "Reservierung verbraucht", + "inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben", + "inventory.stock.low": "Lagerbestand knapp", + "inventory.categories.create": "Lagerkategorie erstellt", + "inventory.categories.update": "Lagerkategorie geändert", + "inventory.categories.delete": "Lagerkategorie gelöscht", + "inventory.locations.create": "Lagerort erstellt", + "inventory.locations.update": "Lagerort geändert", + "inventory.locations.delete": "Lagerort gelöscht", + "inventory.suppliers.create": "Lieferant erstellt", + "inventory.suppliers.update": "Lieferant geändert", + "inventory.suppliers.delete": "Lieferant gelöscht", + "system_settings.smtp.update": "SMTP-Konfiguration geändert", + "system_settings.smtp.test_sent": "SMTP-Testmail versendet", + "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", + "system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert", + "lexware.settings.update": "Lexware Einstellungen geändert", + "lexware.connection.test_success": "Lexware Verbindungstest erfolgreich", + "lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen", + "lexware.invoice.prepare": "Lexware Rechnung vorbereitet", + "lexware.invoice.export_failed": "Lexware Export fehlgeschlagen", + "accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben", + "accounting.invoice.mark_transferred": "Rechnung als übertragen markiert", + "accounting.invoice.note_update": "Buchhaltungsnotiz geändert", + "backups.create": "Backup erstellt", + "backups.download": "Backup heruntergeladen", + "backups.delete": "Backup gelöscht", + "backups.validate": "Backup validiert", + "backups.restore_started": "Restore gestartet", + "backups.restore_failed": "Restore fehlgeschlagen", + "backups.restore_completed": "Restore abgeschlossen", } return labels.get(action, action) diff --git a/backend/hermes/app/services/backup_service.py b/backend/hermes/app/services/backup_service.py new file mode 100644 index 0000000..b84ffe2 --- /dev/null +++ b/backend/hermes/app/services/backup_service.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile + +from fastapi import HTTPException, status +from sqlalchemy.engine import make_url + +from app.core.config import settings +from app.models.user import User +from app.schemas.backup import ( + BackupListResponse, + BackupManifest, + BackupSummary, + BackupValidationResponse, +) + +BACKUP_FILENAME_PREFIX = "olympus-backup-" +BACKUP_FILENAME_SUFFIX = ".zip" +DATABASE_DUMP_NAME = "database.dump" +MANIFEST_NAME = "manifest.json" +STORAGE_DIR_NAME = "storage" +BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO" +RESTORE_DISABLED_MESSAGE = ( + "Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. " + "Bitte Restore ueber CLI-Script ausfuehren." +) + + +@dataclass(frozen=True) +class BackupStats: + total_count: int + total_size_bytes: int + latest_backup_at: datetime | None + + +class BackupService: + @staticmethod + def get_backup_dir() -> Path: + backup_dir = (Path(settings.storage_base_path) / "backups").resolve() + backup_dir.mkdir(parents=True, exist_ok=True) + return backup_dir + + @staticmethod + def list_backups() -> BackupListResponse: + items = [ + BackupService._read_summary(path) + for path in sorted( + BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + ] + latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None) + return BackupListResponse( + items=items, + total_count=len(items), + total_size_bytes=sum(item.size_bytes for item in items), + latest_backup_at=latest_backup_at, + ) + + @staticmethod + def get_backup_stats() -> BackupStats: + backups = BackupService.list_backups() + return BackupStats( + total_count=backups.total_count, + total_size_bytes=backups.total_size_bytes, + latest_backup_at=backups.latest_backup_at, + ) + + @staticmethod + def create_backup(*, actor: User) -> BackupSummary: + backup_dir = BackupService.get_backup_dir() + timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}" + target_path = backup_dir / filename + + with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name: + temp_dir = Path(temp_dir_name) + dump_path = temp_dir / DATABASE_DUMP_NAME + storage_temp_dir = temp_dir / STORAGE_DIR_NAME + manifest_path = temp_dir / MANIFEST_NAME + archive_path = temp_dir / filename + + BackupService._run_pg_dump(dump_path) + file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir) + dump_size = dump_path.stat().st_size + checksum_sha256 = BackupService._calculate_archive_checksum( + dump_path=dump_path, + storage_dir=storage_temp_dir, + ) + + manifest = BackupManifest( + backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24], + created_at=datetime.now(UTC), + app_version=settings.app_version, + backup_type="full", + database_url_host_anonymized=BackupService._anonymized_database_host(), + database_name=BackupService._database_name(), + storage_base_path=settings.storage_base_path, + included_sections=["database", "storage"], + file_count=file_count + 1, + total_size_bytes=total_size_bytes + dump_size, + checksum_sha256=checksum_sha256, + created_by_user_id=actor.id, + created_by_username=actor.username, + ) + manifest_path.write_text( + json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True), + encoding="utf-8", + ) + + BackupService._write_archive( + archive_path=archive_path, + manifest_path=manifest_path, + dump_path=dump_path, + storage_dir=storage_temp_dir, + ) + shutil.move(str(archive_path), target_path) + + return BackupService._read_summary(target_path) + + @staticmethod + def validate_backup(filename: str) -> BackupValidationResponse: + path = BackupService.resolve_backup_path(filename) + issues: list[str] = [] + manifest: BackupManifest | None = None + checksum_valid = False + + try: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if MANIFEST_NAME not in names: + issues.append("manifest.json fehlt") + if DATABASE_DUMP_NAME not in names: + issues.append("database.dump fehlt") + if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names): + issues.append("storage/ fehlt") + + if MANIFEST_NAME in names: + try: + with archive.open(MANIFEST_NAME) as manifest_file: + manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8")) + except Exception: + issues.append("manifest.json ist ungueltig") + + if manifest is not None: + checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256) + if not checksum_valid: + issues.append("Checksumme ist ungueltig") + except zipfile.BadZipFile: + issues.append("ZIP-Datei ist ungueltig") + + valid = len(issues) == 0 + return BackupValidationResponse( + filename=path.name, + valid=valid, + message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen", + issues=issues, + checksum_valid=checksum_valid, + restore_supported=False, + requires_cli_restore=True, + manifest=manifest, + ) + + @staticmethod + def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse: + if confirm_text != BACKUP_CONFIRM_TEXT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Bestaetigungstext stimmt nicht ueberein", + ) + + validation = BackupService.validate_backup(filename) + if not validation.valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Backup ist ungueltig und kann nicht wiederhergestellt werden", + ) + + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail=RESTORE_DISABLED_MESSAGE, + ) + + @staticmethod + def delete_backup(filename: str) -> None: + path = BackupService.resolve_backup_path(filename) + path.unlink(missing_ok=False) + + @staticmethod + def resolve_backup_path(filename: str) -> Path: + if not filename.endswith(BACKUP_FILENAME_SUFFIX): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + if Path(filename).name != filename or ".." in Path(filename).parts: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + + path = (BackupService.get_backup_dir() / filename).resolve() + backup_dir = BackupService.get_backup_dir() + if backup_dir != path.parent or not path.exists() or not path.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + return path + + @staticmethod + def _run_pg_dump(dump_path: Path) -> None: + pg_dump_url = BackupService._pg_dump_database_url() + command = [ + "pg_dump", + "--format=custom", + "--no-owner", + "--no-privileges", + f"--file={dump_path}", + f"--dbname={pg_dump_url}", + ] + + try: + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="pg_dump ist im Hermes-Container nicht verfuegbar", + ) from exc + except subprocess.CalledProcessError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="PostgreSQL-Dump konnte nicht erstellt werden", + ) from exc + + @staticmethod + def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]: + source_dir = Path(settings.storage_base_path).resolve() + backup_dir = BackupService.get_backup_dir() + source_dir.mkdir(parents=True, exist_ok=True) + target_dir.mkdir(parents=True, exist_ok=True) + file_count = 0 + total_size_bytes = 0 + + for source_path in sorted(source_dir.rglob("*")): + if source_path == backup_dir or backup_dir in source_path.parents: + continue + relative_path = source_path.relative_to(source_dir) + destination_path = target_dir / relative_path + if source_path.is_dir(): + destination_path.mkdir(parents=True, exist_ok=True) + continue + if not source_path.is_file(): + continue + destination_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination_path) + file_count += 1 + total_size_bytes += source_path.stat().st_size + + return file_count, total_size_bytes + + @staticmethod + def _write_archive( + *, + archive_path: Path, + manifest_path: Path, + dump_path: Path, + storage_dir: Path, + ) -> None: + with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(f"{STORAGE_DIR_NAME}/", "") + archive.write(manifest_path, MANIFEST_NAME) + archive.write(dump_path, DATABASE_DUMP_NAME) + for file_path in sorted(storage_dir.rglob("*")): + if file_path.is_dir(): + continue + archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix()) + + @staticmethod + def _read_summary(path: Path) -> BackupSummary: + default_summary = BackupSummary( + filename=path.name, + size_bytes=path.stat().st_size, + validation_status="warning", + validation_message="Manifest konnte nicht gelesen werden", + ) + try: + with zipfile.ZipFile(path) as archive: + with archive.open(MANIFEST_NAME) as manifest_file: + manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8")) + return BackupSummary( + filename=path.name, + size_bytes=path.stat().st_size, + created_at=manifest.created_at, + app_version=manifest.app_version, + backup_type=manifest.backup_type, + database_name=manifest.database_name, + storage_base_path=manifest.storage_base_path, + file_count=manifest.file_count, + total_size_bytes=manifest.total_size_bytes, + created_by_user_id=manifest.created_by_user_id, + created_by_username=manifest.created_by_username, + validation_status="valid", + validation_message="Backup ist lesbar", + ) + except Exception: + return default_summary + + @staticmethod + def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str: + digest = hashlib.sha256() + digest.update(DATABASE_DUMP_NAME.encode("utf-8")) + BackupService._update_digest_from_file(digest, dump_path) + + for file_path in sorted(storage_dir.rglob("*")): + if file_path.is_dir(): + continue + digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8")) + BackupService._update_digest_from_file(digest, file_path) + + return digest.hexdigest() + + @staticmethod + def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool: + digest = hashlib.sha256() + if DATABASE_DUMP_NAME not in archive.namelist(): + return False + + digest.update(DATABASE_DUMP_NAME.encode("utf-8")) + with archive.open(DATABASE_DUMP_NAME) as dump_file: + BackupService._update_digest_from_stream(digest, dump_file) + + for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")): + digest.update(name.encode("utf-8")) + with archive.open(name) as storage_file: + BackupService._update_digest_from_stream(digest, storage_file) + + return digest.hexdigest() == expected_checksum + + @staticmethod + def _anonymized_database_host() -> str: + parsed = make_url(settings.database_url) + host = parsed.host or "unknown" + digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12] + return f"sha256:{digest}" + + @staticmethod + def _database_name() -> str: + parsed = make_url(settings.database_url) + return parsed.database or "unknown" + + @staticmethod + def _pg_dump_database_url() -> str: + parsed = make_url(settings.database_url) + normalized = parsed.set(drivername="postgresql") + return normalized.render_as_string(hide_password=False) + + @staticmethod + def _update_digest_from_file(digest, file_path: Path) -> None: + with file_path.open("rb") as file_handle: + BackupService._update_digest_from_stream(digest, file_handle) + + @staticmethod + def _update_digest_from_stream(digest, stream) -> None: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py new file mode 100644 index 0000000..49afd72 --- /dev/null +++ b/backend/hermes/app/services/inventory_service.py @@ -0,0 +1,415 @@ +import re +import unicodedata +from decimal import Decimal +from typing import TypeVar + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.repair_estimate import RepairEstimate +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryItemCreate, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryStockAction, + InventorySupplierPayload, +) +from app.services.audit_service import write_audit_log + +MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier) + + +def _slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower() + return slug or "eintrag" + + +def _item_label(item: InventoryItem) -> str: + return f"{item.sku} · {item.name}" + + +def _audit_item_data(item: InventoryItem) -> dict: + return { + "id": item.id, + "sku": item.sku, + "name": item.name, + "category_id": item.category_id, + "supplier_id": item.supplier_id, + "location_id": item.location_id, + "quantity_on_hand": item.quantity_on_hand, + "quantity_reserved": item.quantity_reserved, + "quantity_available": item.quantity_available, + "reorder_level": item.reorder_level, + "selling_price_cents": item.selling_price_cents, + "currency": item.currency, + "is_active": item.is_active, + } + + +class InventoryService: + @staticmethod + def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem: + sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db) + InventoryService._validate_sku_available(db, sku) + item = InventoryItem(sku=sku) + InventoryService._apply_item_payload(item, payload) + try: + db.add(item) + db.flush() + if item.quantity_on_hand > 0: + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="initial", + quantity=item.quantity_on_hand, + reason="Anfangsbestand", + reference_type=None, + reference_id=None, + note=None, + actor_user_id=actor.id, + ) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + item = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.create", + entity_type="inventory_items", + entity_id=item.id, + entity_label=_item_label(item), + actor=actor, + request=request, + after_data=_audit_item_data(item), + ) + return item + + @staticmethod + def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + sku = (payload.sku or "").upper() or item.sku + InventoryService._validate_sku_available(db, sku, item_id=item.id) + item.sku = sku + InventoryService._apply_item_payload(item, payload) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.update", + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None: + before_data = _audit_item_data(item) + label = _item_label(item) + if InventoryRepository.has_movements(db, item.id): + item.is_active = False + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + write_audit_log( + db, + action="inventory.items.deactivate", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(item), + ) + return InventoryRepository.get_item(db, item.id) or item + + db.delete(item) + db.commit() + write_audit_log( + db, + action="inventory.items.delete", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + ) + return None + + @staticmethod + def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + item.quantity_on_hand = payload.quantity + if item.quantity_reserved > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten") + InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request) + + @staticmethod + def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_available: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand") + before_data = _audit_item_data(item) + item.quantity_reserved += payload.quantity + InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request) + + @staticmethod + def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_reserved: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich") + before_data = _audit_item_data(item) + item.quantity_reserved -= payload.quantity + InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request) + + @staticmethod + def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand") + before_data = _audit_item_data(item) + item.quantity_on_hand -= payload.quantity + item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity) + InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request) + + @staticmethod + def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None or not item.is_active: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv") + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_available: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}", + ) + item.quantity_reserved += quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="reservation", + quantity=quantity, + reason="estimate_reserved", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def release_estimate_reservation( + db: Session, + estimate: RepairEstimate, + *, + actor_user_id: int | None, + reason: str = "estimate_released", + ) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + item.quantity_reserved = max(0, item.quantity_reserved - quantity) + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="release", + quantity=quantity, + reason=reason, + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_reserved or quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus") + item.quantity_reserved -= quantity + item.quantity_on_hand -= quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="consumption", + quantity=quantity, + reason="estimate_consumed", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description) + return InventoryService._save_master(db, category, "inventory.categories.create", actor, request) + + @staticmethod + def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + before_data = {"id": category.id, "name": category.name, "slug": category.slug} + category.name = payload.name + category.slug = _slugify(payload.name) + category.description = payload.description + return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data) + + @staticmethod + def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + location = InventoryLocation(name=payload.name, description=payload.description) + return InventoryService._save_master(db, location, "inventory.locations.create", actor, request) + + @staticmethod + def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + before_data = {"id": location.id, "name": location.name} + location.name = payload.name + location.description = payload.description + return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data) + + @staticmethod + def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + supplier = InventorySupplier(**payload.model_dump()) + return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request) + + @staticmethod + def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + before_data = {"id": supplier.id, "name": supplier.name} + for key, value in payload.model_dump().items(): + setattr(supplier, key, value) + return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data) + + @staticmethod + def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None: + label = getattr(entity, "name", "") + before_data = {"id": entity.id, "name": label} + db.delete(entity) + db.commit() + write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data) + + @staticmethod + def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None: + for key, value in payload.model_dump(exclude={"sku"}).items(): + setattr(item, key, value) + InventoryService._recalculate_available(item) + + @staticmethod + def _recalculate_available(item: InventoryItem) -> None: + item.quantity_available = item.quantity_on_hand - item.quantity_reserved + + @staticmethod + def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None: + existing = InventoryRepository.get_item_by_sku(db, sku) + if existing is not None and existing.id != item_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") + + @staticmethod + def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None: + if payload.quantity <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein") + + @staticmethod + def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int: + if quantity != quantity.to_integral_value(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Lagerposition {position} muss eine ganze Menge verwenden", + ) + normalized = int(quantity) + if normalized <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0") + return normalized + + @staticmethod + def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None: + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type=movement_type, + quantity=payload.quantity, + reason=payload.reason, + reference_type=payload.reference_type, + reference_id=payload.reference_id, + note=payload.note, + actor_user_id=actor_user_id, + ) + + @staticmethod + def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem: + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action=action, + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel: + try: + db.add(entity) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None + db.refresh(entity) + label = getattr(entity, "name", "") + write_audit_log( + db, + action=action, + entity_type=entity.__tablename__, + entity_id=entity.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data={"id": entity.id, "name": label}, + ) + return entity diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py new file mode 100644 index 0000000..61ffe38 --- /dev/null +++ b/backend/hermes/app/services/lexware_service.py @@ -0,0 +1,518 @@ +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal, ROUND_HALF_UP +from urllib.error import HTTPError, URLError +from urllib.request import Request as UrlRequest +from urllib.request import urlopen + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.config import settings +from app.models.lexware import LexwareSyncRecord +from app.models.repair import Repair +from app.models.repair_estimate import RepairEstimate +from app.models.user import User +from app.repositories.system_settings_repository import SystemSettingsRepository +from app.schemas.lexware import ( + AccountingTransferUpdate, + LexwareCustomerMapping, + LexwareInvoicePreparationResponse, + LexwareLineItemMapping, + LexwareSettingsResponse, + LexwareSettingsUpdate, + LexwareTestConnectionResponse, +) +from app.schemas.system_setting import SettingsSource +from app.services.audit_service import write_audit_log +from app.services.system_settings_service import parse_bool + + +LEXWARE_KEYS = ( + "lexware.enabled", + "lexware.api_base_url", + "lexware.api_key", + "lexware.organization_name", + "lexware.default_tax_rate", + "lexware.default_payment_terms_days", +) + +LEXWARE_SECRET_KEYS = {"lexware.api_key"} +DEFAULT_API_BASE_URL = "https://api.lexware.io" + + +@dataclass(frozen=True) +class LexwareRuntimeConfig: + enabled: bool + api_base_url: str + api_key: str + organization_name: str + default_tax_rate: Decimal + default_payment_terms_days: int + source: SettingsSource + + @property + def api_key_is_set(self) -> bool: + return bool(self.api_key) + + @property + def is_configured(self) -> bool: + return self.enabled and bool(self.api_base_url and self.api_key) + + +def _decimal(value: object, *, default: Decimal) -> Decimal: + try: + return Decimal(str(value or "").replace(",", ".")) + except Exception: + return default + + +def _int(value: object, *, default: int) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + return default + return parsed if 0 <= parsed <= 365 else default + + +def _euros(cents: int) -> Decimal: + return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def _safe_error_message(error: Exception) -> str: + if isinstance(error, HTTPError): + if error.code in {401, 403}: + return "Lexware hat den API-Key abgelehnt." + if error.code == 404: + return "Lexware-Endpunkt wurde nicht gefunden." + return "Lexware hat die Anfrage nicht erfolgreich beantwortet." + if isinstance(error, URLError): + return "Lexware ist momentan nicht erreichbar." + return "Lexware-Verbindungstest konnte nicht abgeschlossen werden." + + +class LexwareService: + @staticmethod + def get_settings(db: Session) -> LexwareSettingsResponse: + return LexwareService.settings_response(LexwareService.get_runtime_config(db)) + + @staticmethod + def get_runtime_config(db: Session) -> LexwareRuntimeConfig: + values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()} + db_enabled = parse_bool(values.get("lexware.enabled"), default=False) + db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/") + db_api_key = values.get("lexware.api_key") or "" + db_has_config = db_enabled or db_base_url or db_api_key + + if db_has_config: + return LexwareRuntimeConfig( + enabled=db_enabled, + api_base_url=db_base_url or DEFAULT_API_BASE_URL, + api_key=db_api_key, + organization_name=values.get("lexware.organization_name", ""), + default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")), + default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14), + source="database", + ) + + if settings.lexware_enabled or settings.lexware_api_key: + return LexwareRuntimeConfig( + enabled=settings.lexware_enabled, + api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"), + api_key=settings.lexware_api_key or "", + organization_name="", + default_tax_rate=Decimal("19.00"), + default_payment_terms_days=14, + source="environment", + ) + + return LexwareRuntimeConfig( + enabled=False, + api_base_url=DEFAULT_API_BASE_URL, + api_key="", + organization_name="", + default_tax_rate=Decimal("19.00"), + default_payment_terms_days=14, + source="missing", + ) + + @staticmethod + def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse: + current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()} + api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "") + updates = { + "lexware.enabled": "true" if payload.enabled else "false", + "lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL, + "lexware.api_key": api_key, + "lexware.organization_name": payload.organization_name, + "lexware.default_tax_rate": str(payload.default_tax_rate), + "lexware.default_payment_terms_days": str(payload.default_payment_terms_days), + } + for key, value in updates.items(): + SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS) + db.commit() + write_audit_log( + db, + action="lexware.settings.update", + entity_type="system_settings", + entity_label="Lexware Office", + actor=actor, + request=request, + metadata={ + "enabled": payload.enabled, + "api_base_url": payload.api_base_url, + "organization_name": payload.organization_name, + "default_tax_rate": str(payload.default_tax_rate), + "default_payment_terms_days": payload.default_payment_terms_days, + "api_key_changed": bool(payload.api_key), + }, + ) + return LexwareService.get_settings(db) + + @staticmethod + def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse: + config = LexwareService.get_runtime_config(db) + if not config.is_configured: + write_audit_log( + db, + action="lexware.connection.test_failed", + entity_type="system_settings", + entity_label="Lexware Office", + actor=actor, + request=request, + metadata={"reason": "lexware_not_configured", "source": config.source}, + ) + return LexwareTestConnectionResponse( + success=False, + message="Lexware ist nicht vollständig konfiguriert.", + source=config.source, + api_base_url=config.api_base_url, + organization_name=config.organization_name, + ) + + try: + profile = LexwareService._get_profile(config) + except Exception as exc: + write_audit_log( + db, + action="lexware.connection.test_failed", + entity_type="system_settings", + entity_label="Lexware Office", + actor=actor, + request=request, + metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url}, + ) + return LexwareTestConnectionResponse( + success=False, + message=_safe_error_message(exc), + source=config.source, + api_base_url=config.api_base_url, + organization_name=config.organization_name, + ) + + organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "") + write_audit_log( + db, + action="lexware.connection.test_success", + entity_type="system_settings", + entity_label="Lexware Office", + actor=actor, + request=request, + metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name}, + ) + return LexwareTestConnectionResponse( + success=True, + message="Lexware-Verbindung erfolgreich geprüft.", + source=config.source, + api_base_url=config.api_base_url, + organization_name=organization_name, + ) + + @staticmethod + def prepare_invoice( + db: Session, + repair: Repair, + estimate: RepairEstimate, + *, + actor: User, + request: Request, + ) -> LexwareInvoicePreparationResponse: + config = LexwareService.get_runtime_config(db) + if estimate.status != "approved": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden") + + warnings: list[str] = [] + if not config.enabled: + warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.") + if not config.api_key_is_set: + warnings.append("Lexware API-Key ist noch nicht gesetzt.") + if not repair.customer_email: + warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.") + if not estimate.items: + warnings.append("Der Kostenvoranschlag enthält keine Positionen.") + + customer_payload = { + "roles": {"customer": {}}, + "company": {"name": repair.customer_name}, + "emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {}, + "phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {}, + } + customer_mapping = LexwareCustomerMapping( + name=repair.customer_name, + email=repair.customer_email, + phone=repair.customer_phone, + search_strategy="email" if repair.customer_email else "name", + create_payload=customer_payload, + ) + line_items = [ + LexwareLineItemMapping( + title=item.title, + description=item.description, + quantity=item.quantity, + unit=item.unit, + unit_price=_euros(item.unit_price_cents), + tax_rate=estimate.tax_rate_percent, + total=_euros(item.total_cents), + ) + for item in estimate.items + ] + payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items) + record = LexwareSyncRecord( + entity_type="repair_estimate", + entity_id=estimate.id, + lexware_resource_type="invoice", + status="pending" if not warnings else "skipped", + direction="push", + export_status="prepared", + accounting_note=estimate.accounting_note, + payload_summary=json.dumps(payload_summary, ensure_ascii=True), + error_message="; ".join(warnings) if warnings else None, + ) + estimate.accounting_export_status = "prepared" + db.add(record) + db.commit() + db.refresh(record) + db.refresh(estimate) + write_audit_log( + db, + action="accounting.invoice.handoff", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=f"{estimate.estimate_number} · {estimate.title}", + actor=actor, + request=request, + metadata={ + "repair_id": repair.id, + "repair_number": repair.repair_number, + "ready_for_export": not warnings, + "sync_record_id": record.id, + "export_status": record.export_status, + }, + ) + return LexwareService._invoice_preparation_response( + record=record, + payload_summary=payload_summary, + customer_mapping=customer_mapping, + line_items=line_items, + estimate=estimate, + config=config, + warnings=warnings, + ) + + @staticmethod + def mark_transferred( + db: Session, + repair: Repair, + estimate: RepairEstimate, + payload: AccountingTransferUpdate, + *, + actor: User, + request: Request, + ) -> LexwareInvoicePreparationResponse: + record = LexwareService._latest_invoice_record(db, estimate.id) + if record is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden") + if record.export_status not in {"prepared", "transferred"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden") + + note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "") + now = datetime.now(UTC) + record.export_status = "transferred" + record.status = "success" + record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note + record.transferred_at = now + record.transferred_by_user_id = actor.id + record.synced_at = now + estimate.accounting_export_status = "transferred" + estimate.accounting_note = record.accounting_note + estimate.accounting_transferred_at = now + estimate.accounting_transferred_by_user_id = actor.id + db.commit() + db.refresh(record) + db.refresh(estimate) + + write_audit_log( + db, + action="accounting.invoice.mark_transferred", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=f"{estimate.estimate_number} · {estimate.title}", + actor=actor, + request=request, + metadata={ + "repair_id": repair.id, + "repair_number": repair.repair_number, + "sync_record_id": record.id, + "export_status": record.export_status, + "transferred_at": record.transferred_at, + }, + ) + if note_changed: + write_audit_log( + db, + action="accounting.invoice.note_update", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=f"{estimate.estimate_number} · {estimate.title}", + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id}, + ) + + config = LexwareService.get_runtime_config(db) + customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate) + warnings: list[str] = [] + payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items) + return LexwareService._invoice_preparation_response( + record=record, + payload_summary=payload_summary, + customer_mapping=customer_mapping, + line_items=line_items, + estimate=estimate, + config=config, + warnings=warnings, + ) + + @staticmethod + def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]: + customer_payload = { + "roles": {"customer": {}}, + "company": {"name": repair.customer_name}, + "emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {}, + "phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {}, + } + customer_mapping = LexwareCustomerMapping( + name=repair.customer_name, + email=repair.customer_email, + phone=repair.customer_phone, + search_strategy="email" if repair.customer_email else "name", + create_payload=customer_payload, + ) + line_items = [ + LexwareLineItemMapping( + title=item.title, + description=item.description, + quantity=item.quantity, + unit=item.unit, + unit_price=_euros(item.unit_price_cents), + tax_rate=estimate.tax_rate_percent, + total=_euros(item.total_cents), + ) + for item in estimate.items + ] + return customer_mapping, line_items + + @staticmethod + def _invoice_payload_summary( + config: LexwareRuntimeConfig, + repair: Repair, + estimate: RepairEstimate, + line_items: list[LexwareLineItemMapping], + ) -> dict: + return { + "type": "invoice", + "title": f"Rechnung zu Reparatur {repair.repair_number}", + "introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.", + "repair_number": repair.repair_number, + "estimate_number": estimate.estimate_number, + "currency": estimate.currency, + "payment_terms_days": config.default_payment_terms_days, + "subtotal": str(_euros(estimate.subtotal_cents)), + "tax": str(_euros(estimate.tax_cents)), + "total": str(_euros(estimate.total_cents)), + "line_item_count": len(line_items), + } + + @staticmethod + def _invoice_preparation_response( + *, + record: LexwareSyncRecord, + payload_summary: dict, + customer_mapping: LexwareCustomerMapping, + line_items: list[LexwareLineItemMapping], + estimate: RepairEstimate, + config: LexwareRuntimeConfig, + warnings: list[str], + ) -> LexwareInvoicePreparationResponse: + return LexwareInvoicePreparationResponse( + ready_for_export=not warnings, + export_status=record.export_status, + payload_summary=payload_summary, + customer_mapping=customer_mapping, + line_item_mapping=line_items, + tax_mapping={ + "source": "repair_estimate", + "tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate), + "default_tax_rate": str(config.default_tax_rate), + "tax_amount": str(_euros(estimate.tax_cents)), + }, + warnings=warnings, + sync_record_id=record.id, + accounting_note=record.accounting_note or "", + transferred_at=record.transferred_at.isoformat() if record.transferred_at else None, + transferred_by_user_id=record.transferred_by_user_id, + ) + + @staticmethod + def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None: + from sqlalchemy import select + + return db.scalar( + select(LexwareSyncRecord) + .where(LexwareSyncRecord.entity_type == "repair_estimate") + .where(LexwareSyncRecord.entity_id == estimate_id) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc()) + .limit(1) + ) + + @staticmethod + def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse: + return LexwareSettingsResponse( + enabled=config.enabled, + api_base_url=config.api_base_url, + api_key_is_set=config.api_key_is_set, + organization_name=config.organization_name, + default_tax_rate=config.default_tax_rate, + default_payment_terms_days=config.default_payment_terms_days, + source=config.source, + ) + + @staticmethod + def _get_profile(config: LexwareRuntimeConfig) -> dict: + request = UrlRequest( + f"{config.api_base_url}/v1/profile", + headers={ + "Authorization": f"Bearer {config.api_key}", + "Accept": "application/json", + }, + method="GET", + ) + with urlopen(request, timeout=15) as response: + body = response.read().decode("utf-8") + if not body: + return {} + data = json.loads(body) + return data if isinstance(data, dict) else {} diff --git a/backend/hermes/app/services/repair_document_service.py b/backend/hermes/app/services/repair_document_service.py new file mode 100644 index 0000000..b0e9f2c --- /dev/null +++ b/backend/hermes/app/services/repair_document_service.py @@ -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") diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py new file mode 100644 index 0000000..f096b84 --- /dev/null +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -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""" + + + +
Funktechnik Schubert
+

Hallo {escape(repair.customer_name)},

+

zu Ihrer Reparatur {escape(repair.repair_number)} liegt ein Kostenvoranschlag vor.

+

Gerät: {escape((repair.device_manufacturer + " " + repair.device_model).strip())}
+Kostenvoranschlag: {escape(estimate.estimate_number)}
+Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}

+

Kostenvoranschlag ansehen

+
""" + + @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""" + + + +
Funktechnik Schubert
+

Hallo {escape(repair.customer_name)},

+

Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.

+

Reparatur: {escape(repair.repair_number)}
+Kostenvoranschlag: {escape(estimate.estimate_number)}

+

Status ansehen

+
""" diff --git a/backend/hermes/app/services/repair_notification_service.py b/backend/hermes/app/services/repair_notification_service.py index 92020b4..2e48bd4 100644 --- a/backend/hermes/app/services/repair_notification_service.py +++ b/backend/hermes/app/services/repair_notification_service.py @@ -1,26 +1,18 @@ 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 +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 - - -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", -} +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) @@ -96,13 +88,83 @@ TEMPLATES: tuple[RepairNotificationTemplate, ...] = ( ) +def _device_label(repair: Repair) -> str: + return f"{repair.device_manufacturer} {repair.device_model}".strip() + + +def _repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: + return ( + f"Hallo {repair.customer_name},\n\n" + "der Status Ihrer Reparatur wurde aktualisiert.\n\n" + f"Reparaturnummer:\n{repair.repair_number}\n\n" + f"Gerät:\n{_device_label(repair)}\n\n" + f"Neuer Status:\n{status_label}\n\n" + f"Status online ansehen:\n{public_status_url}\n\n" + "Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n" + "Funktechnik Schubert" + ) + + +def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: + return f""" + + + + + + +
+ + + + + + + + + + +
+
Olympus CRM
+
Funktechnik Schubert
+
+

Hallo {escape(repair.customer_name)},

+

der Status Ihrer Reparatur wurde aktualisiert.

+ + + + + + + + + + + + + +
Reparaturnummer{escape(repair.repair_number)}
Gerät{escape(_device_label(repair))}
Neuer Status{escape(status_label)}
+

+ Reparaturstatus ansehen +

+

Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.

+
Funktechnik Schubert
+
+ +""" + + 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": f"{repair.device_manufacturer} {repair.device_model}".strip(), + "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", @@ -124,3 +186,112 @@ class RepairNotificationService: templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url), events=RepairRepository.list_notification_events(db, repair.id), ) + + @staticmethod + def send_status_mail( + db: Session, + repair: Repair, + *, + actor: User | None, + request: Request | None, + manual: bool = False, + ) -> RepairNotificationEvent: + recipient = repair.customer_email.strip() + subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert" + status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status) + template = "repair_status_update" + + if not recipient: + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient="", + subject=subject, + template=template, + status="skipped", + success=False, + error_message="Keine Kunden-E-Mail hinterlegt", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient") + return event + + link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create" + public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action) + public_status_url = public_link.public_status_path + + text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url) + html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url) + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + + if not smtp_config.is_configured: + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="skipped", + success=False, + error_message="SMTP nicht konfiguriert", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured") + return event + + try: + SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html) + except Exception: + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="failed", + success=False, + error_message="Statusmail konnte nicht versendet werden", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed") + return event + + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="sent", + success=True, + sent_at=datetime.now(UTC), + ) + write_audit_log( + db, + action="repairs.status_mail.sent", + entity_type="repairs", + entity_id=repair.id, + entity_label=_repair_label(repair), + actor=actor, + request=request, + metadata={"notification_event_id": event.id}, + ) + return event + + @staticmethod + def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None: + write_audit_log( + db, + action="repairs.status_mail.failed", + entity_type="repairs", + entity_id=repair.id, + entity_label=_repair_label(repair), + actor=actor, + request=request, + metadata={"reason": reason}, + ) diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index 5697582..c75e86c 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -11,6 +11,7 @@ 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, @@ -18,18 +19,32 @@ from app.schemas.repair import ( RepairPublicStatusResponse, ) from app.services.audit_service import write_audit_log -from app.services.repair_notification_service import STATUS_LABELS -from app.services.repair_service import repair_label +from app.services.repair_status_labels import STATUS_LABELS +from app.services.system_settings_service import SystemSettingsService -def public_status_path(token: str) -> str: - return f"/status/{token}" +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: - return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() + 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: @@ -54,6 +69,23 @@ class RepairPublicLinkService: @staticmethod def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse: + return RepairPublicLinkService.create_with_audit( + db, + repair, + actor=actor, + request=request, + audit_action="repairs.public_link.create", + ) + + @staticmethod + def create_with_audit( + db: Session, + repair: Repair, + *, + actor: User | None, + request: Request | None, + audit_action: str, + ) -> RepairPublicLinkCreateResponse: token = RepairPublicLinkService.create_token() public_link = RepairRepository.create_public_link( db, @@ -63,7 +95,7 @@ class RepairPublicLinkService: ) write_audit_log( db, - action="repairs.public_link.create", + action=audit_action, entity_type="repairs", entity_id=repair.id, entity_label=repair_label(repair), @@ -79,7 +111,7 @@ class RepairPublicLinkService: last_used_at=public_link.last_used_at, created_at=public_link.created_at, revoked_at=public_link.revoked_at, - public_status_path=public_status_path(token), + public_status_path=public_status_path(db, token), ) @staticmethod @@ -100,14 +132,13 @@ class RepairPublicLinkService: @staticmethod def public_status(db: Session, token: str) -> RepairPublicStatusResponse: - public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token)) - if public_link is None or public_link.repair is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") - - RepairRepository.mark_public_link_used(db, public_link) + public_link = RepairPublicLinkService.get_public_link_or_404(db, token) repair = public_link.repair history = RepairRepository.get_history_public(db, repair.id) - return RepairPublicStatusResponse( + 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, @@ -121,4 +152,18 @@ class RepairPublicLinkService: for item in history ], updated_at=repair.updated_at, + estimate=RepairEstimateService.public_response(estimate), ) + RepairRepository.mark_public_link_used(db, public_link) + return response + + @staticmethod + def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken: + normalized_token = normalize_token(token) + if not normalized_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") + + public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token)) + if public_link is None or public_link.repair is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") + return public_link diff --git a/backend/hermes/app/services/repair_service.py b/backend/hermes/app/services/repair_service.py index d84c9f4..5806831 100644 --- a/backend/hermes/app/services/repair_service.py +++ b/backend/hermes/app/services/repair_service.py @@ -11,6 +11,7 @@ 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: @@ -74,6 +75,19 @@ class RepairService: after_data=updated, metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status}, ) + try: + RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request) + except Exception: + write_audit_log( + db, + action="repairs.status_mail.failed", + entity_type="repairs", + entity_id=updated.id, + entity_label=repair_label(updated), + actor=actor, + request=request, + metadata={"reason": "unexpected_notification_error"}, + ) return updated @staticmethod diff --git a/backend/hermes/app/services/repair_status_labels.py b/backend/hermes/app/services/repair_status_labels.py new file mode 100644 index 0000000..cfd98c8 --- /dev/null +++ b/backend/hermes/app/services/repair_status_labels.py @@ -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", +} diff --git a/backend/hermes/app/services/system_settings_service.py b/backend/hermes/app/services/system_settings_service.py new file mode 100644 index 0000000..e0fa53f --- /dev/null +++ b/backend/hermes/app/services/system_settings_service.py @@ -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=( + "

Hallo,

" + "

dies ist eine Testmail aus Olympus CRM. " + "Die SMTP-Konfiguration ist grundsätzlich erreichbar.

" + "

Olympus CRM

" + ), + ) + 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} diff --git a/backend/hermes/app/storage/local.py b/backend/hermes/app/storage/local.py index 9267a12..aaab81c 100644 --- a/backend/hermes/app/storage/local.py +++ b/backend/hermes/app/storage/local.py @@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider): "customers", "projects", "tickets", + "repairs", "imports", "temp", ]: diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index a2215df..4fb2749 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -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:-} @@ -22,6 +22,9 @@ services: STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50} KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} + LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false} + LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io} + LEXWARE_API_KEY: ${LEXWARE_API_KEY:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/backend/hermes/dockerfile b/backend/hermes/dockerfile index 71f483b..11c1906 100644 --- a/backend/hermes/dockerfile +++ b/backend/hermes/dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 36aa4e5..3c8ddf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} @@ -26,6 +26,18 @@ services: KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} + # Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang. + PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false} + LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io} + LEXWARE_API_KEY: ${LEXWARE_API_KEY:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/frontend/athena/app/api/backups/[filename]/delete/route.ts b/frontend/athena/app/api/backups/[filename]/delete/route.ts new file mode 100644 index 0000000..0273b73 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/delete/route.ts @@ -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)}`); +} diff --git a/frontend/athena/app/api/backups/[filename]/download/route.ts b/frontend/athena/app/api/backups/[filename]/download/route.ts new file mode 100644 index 0000000..e5827f9 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/download/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/backups/[filename]/restore/route.ts b/frontend/athena/app/api/backups/[filename]/restore/route.ts new file mode 100644 index 0000000..3b204b4 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/restore/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/backups/[filename]/validate/route.ts b/frontend/athena/app/api/backups/[filename]/validate/route.ts new file mode 100644 index 0000000..36037b3 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/validate/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/backups/create/route.ts b/frontend/athena/app/api/backups/create/route.ts new file mode 100644 index 0000000..3928270 --- /dev/null +++ b/frontend/athena/app/api/backups/create/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/backups/route.ts b/frontend/athena/app/api/backups/route.ts new file mode 100644 index 0000000..ccf2978 --- /dev/null +++ b/frontend/athena/app/api/backups/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/inventory/categories/[id]/route.ts b/frontend/athena/app/api/inventory/categories/[id]/route.ts new file mode 100644 index 0000000..4c1288a --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/inventory/categories/route.ts b/frontend/athena/app/api/inventory/categories/route.ts new file mode 100644 index 0000000..568b58d --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/movements/route.ts b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts new file mode 100644 index 0000000..c3eade1 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/route.ts b/frontend/athena/app/api/inventory/items/[id]/route.ts new file mode 100644 index 0000000..0cbc57b --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts new file mode 100644 index 0000000..ccc0243 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts new file mode 100644 index 0000000..8ac6939 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts new file mode 100644 index 0000000..94c1db7 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts new file mode 100644 index 0000000..f7c5752 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/inventory/items/route.ts b/frontend/athena/app/api/inventory/items/route.ts new file mode 100644 index 0000000..1000e7f --- /dev/null +++ b/frontend/athena/app/api/inventory/items/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/inventory/items/search/route.ts b/frontend/athena/app/api/inventory/items/search/route.ts new file mode 100644 index 0000000..7ce787d --- /dev/null +++ b/frontend/athena/app/api/inventory/items/search/route.ts @@ -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}`); +} diff --git a/frontend/athena/app/api/inventory/locations/[id]/route.ts b/frontend/athena/app/api/inventory/locations/[id]/route.ts new file mode 100644 index 0000000..6823a7e --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/inventory/locations/route.ts b/frontend/athena/app/api/inventory/locations/route.ts new file mode 100644 index 0000000..08efc26 --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/inventory/suppliers/[id]/route.ts b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts new file mode 100644 index 0000000..c296251 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/inventory/suppliers/route.ts b/frontend/athena/app/api/inventory/suppliers/route.ts new file mode 100644 index 0000000..6c9f421 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/route.ts @@ -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/suppliers"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/suppliers"); +} diff --git a/frontend/athena/app/api/lexware/settings/route.ts b/frontend/athena/app/api/lexware/settings/route.ts new file mode 100644 index 0000000..3eb117a --- /dev/null +++ b/frontend/athena/app/api/lexware/settings/route.ts @@ -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, "/lexware/settings"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/settings"); +} diff --git a/frontend/athena/app/api/lexware/test-connection/route.ts b/frontend/athena/app/api/lexware/test-connection/route.ts new file mode 100644 index 0000000..521df64 --- /dev/null +++ b/frontend/athena/app/api/lexware/test-connection/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/test-connection"); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts new file mode 100644 index 0000000..b36d87f --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + documentId: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesStreamRequest( + request, + `/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`, + ); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts new file mode 100644 index 0000000..734fa67 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + documentId: string; + }>; +}; + +async function proxyDocumentRequest(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyDocumentRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/route.ts b/frontend/athena/app/api/repairs/[id]/documents/route.ts new file mode 100644 index 0000000..1db71c3 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents`); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts new file mode 100644 index 0000000..90cd43d --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents/upload`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts new file mode 100644 index 0000000..c95e919 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts @@ -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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts new file mode 100644 index 0000000..97a785f --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts @@ -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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts new file mode 100644 index 0000000..6dd1af5 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts new file mode 100644 index 0000000..9e3f9b7 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts @@ -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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts new file mode 100644 index 0000000..140c741 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts @@ -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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts new file mode 100644 index 0000000..bdb0937 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts @@ -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; estimateId: string }>; +}; + +async function proxyEstimateRequest(request: NextRequest, { params }: Params) { + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyEstimateRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyEstimateRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyEstimateRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts new file mode 100644 index 0000000..f7f13a6 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts @@ -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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/route.ts new file mode 100644 index 0000000..7469ee5 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates`); +} + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates`); +} diff --git a/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts b/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts new file mode 100644 index 0000000..143b2d1 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/send-status-mail`); +} diff --git a/frontend/athena/app/api/system-settings/public-links/route.ts b/frontend/athena/app/api/system-settings/public-links/route.ts new file mode 100644 index 0000000..a004455 --- /dev/null +++ b/frontend/athena/app/api/system-settings/public-links/route.ts @@ -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, "/system-settings/public-links"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/public-links"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/route.ts b/frontend/athena/app/api/system-settings/smtp/route.ts new file mode 100644 index 0000000..deed42c --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/route.ts @@ -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, "/system-settings/smtp"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/smtp"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/test/route.ts b/frontend/athena/app/api/system-settings/smtp/test/route.ts new file mode 100644 index 0000000..2075228 --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/test/route.ts @@ -0,0 +1,14 @@ +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, "/system-settings/smtp/test"); +} diff --git a/frontend/athena/app/backups/page.tsx b/frontend/athena/app/backups/page.tsx new file mode 100644 index 0000000..3c70d97 --- /dev/null +++ b/frontend/athena/app/backups/page.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import SummaryCard from "@/components/common/SummaryCard"; +import { useToast } from "@/components/common/ToastProvider"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { ApiSuccess } from "@/types/audit"; +import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup"; +import type { CurrentUser } from "@/types/rbac"; + +const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"; + +function getErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? fallback; + } + return fallback; +} + +function formatBytes(bytes: number) { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +} + +function formatDate(value: string | null) { + if (!value) { + return "Unbekannt"; + } + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export default function BackupsPage() { + const { showToast } = useToast(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [validationByFile, setValidationByFile] = useState>({}); + const [validatingFile, setValidatingFile] = useState(""); + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreConfirm, setRestoreConfirm] = useState(""); + const [restoring, setRestoring] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + + const loadBackups = useCallback(async () => { + setError(""); + try { + const response = await api.get>("/backups"); + setData(response.data.data); + } catch (err) { + setError(getErrorMessage(err, "Backups konnten nicht geladen werden.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadBackups(); + }); + }, [loadBackups]); + + useEffect(() => { + queueMicrotask(async () => { + try { + const response = await api.get("/me"); + setCurrentUser(response.data); + } catch { + setCurrentUser(null); + } + }); + }, []); + + const cards = useMemo(() => { + if (!data) { + return []; + } + return [ + { label: "Backups", value: data.total_count }, + { label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) }, + { label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" }, + ]; + }, [data]); + + const canCreate = hasPermission(currentUser, "backup.create"); + const canDownload = hasPermission(currentUser, "backup.download"); + const canDelete = hasPermission(currentUser, "backup.delete"); + const canRestore = hasPermission(currentUser, "backup.restore"); + + async function createBackup() { + setCreating(true); + try { + await api.post>("/backups/create"); + await loadBackups(); + showToast({ type: "success", title: "Backup erstellt" }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht erstellt werden", + description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."), + }); + } finally { + setCreating(false); + } + } + + async function validateBackup(filename: string) { + setValidatingFile(filename); + try { + const response = await api.post>(`/backups/${encodeURIComponent(filename)}/validate`); + setValidationByFile((current) => ({ ...current, [filename]: response.data.data })); + showToast({ + type: response.data.data.valid ? "success" : "error", + title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen", + description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "), + }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht validiert werden", + description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."), + }); + } finally { + setValidatingFile(""); + } + } + + async function deleteBackup() { + if (!deleteTarget) { + return; + } + setDeleting(true); + try { + await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`); + setDeleteTarget(null); + await loadBackups(); + showToast({ type: "success", title: "Backup geloescht" }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht geloescht werden", + description: getErrorMessage(err, "Bitte versuche es erneut."), + }); + } finally { + setDeleting(false); + } + } + + async function restoreBackup() { + if (!restoreTarget) { + return; + } + setRestoring(true); + try { + await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, { + confirm_text: restoreConfirm, + }); + showToast({ type: "success", title: "Restore abgeschlossen" }); + } catch (err) { + showToast({ + type: "info", + title: "CLI-Restore erforderlich", + description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."), + }); + } finally { + setRestoring(false); + } + } + + if (loading) { + return
Backups werden geladen...
; + } + + if (error || !data) { + return
{error || "Keine Backups verfuegbar"}
; + } + + return ( +
+
+
+

Backups

+

PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {cards.map((card) => ( + + ))} +
+ +
+
+

Verfuegbare Backup-Dateien

+
+ {data.items.length === 0 ? ( +
Noch keine Backup-Dateien vorhanden.
+ ) : ( +
+ {data.items.map((backup) => { + const validation = validationByFile[backup.filename]; + return ( +
+
+
+
+

{backup.filename}

+ + {backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"} + +
+
+
+

Erstellt

+

{formatDate(backup.created_at)}

+
+
+

Groesse

+

{formatBytes(backup.size_bytes)}

+
+
+

Version

+

{backup.app_version || "Unbekannt"}

+
+
+

Ersteller

+

{backup.created_by_username || "System"}

+
+
+
+
+

Datenbank

+

{backup.database_name || "Unbekannt"}

+
+
+

Dateien

+

{backup.file_count}

+
+
+

Storage-Pfad

+

{backup.storage_base_path || "-"}

+
+
+ {validation && ( +
+
+ {validation.valid ? : } +
+

{validation.message}

+

Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}

+ {!validation.valid && validation.issues.length > 0 && ( +

{validation.issues.join(" | ")}

+ )} + {validation.requires_cli_restore && ( +

Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.

+ )} +
+
+
+ )} +
+
+ {canDownload && ( + + + Download + + )} + {canRestore && ( + <> + + + + )} + {canDelete && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + { + if (!open) { + setDeleteTarget(null); + } + }} + onConfirm={() => void deleteBackup()} + > + {deleteTarget && ( +
+

{deleteTarget.filename}

+

{formatBytes(deleteTarget.size_bytes)}

+
+ )} +
+ + { + if (!open) { + setRestoreTarget(null); + setRestoreConfirm(""); + } + }} + onConfirm={() => void restoreBackup()} + > +
+
+
+ +

Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.

+
+
+
+

Bestaetigungstext

+ setRestoreConfirm(event.target.value)} + placeholder={RESTORE_CONFIRM_TEXT} + /> +
+ {restoreTarget && ( +

+ Ziel-Backup: {restoreTarget.filename} +

+ )} +
+
+
+ ); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index ddfb4c0..4f8cb10 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -62,7 +62,7 @@ export default function DashboardPage() { return
{error || "Keine Dashboarddaten verfügbar"}
; } - const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles]; + const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles]; return (
@@ -108,6 +108,23 @@ export default function DashboardPage() { )} + {summary.system_status.length > 0 && ( +
+

Systemstatus

+
+ {summary.system_status.map((item) => ( +
+
+

{item.label}

+ +
+

{formatSystemValue(item.value)}

+
+ ))} +
+
+ )} +
; +}; + +const movementLabels: Record = { + initial: "Anfangsbestand", + purchase: "Einkauf", + adjustment: "Anpassung", + reservation: "Reservierung", + release: "Freigabe", + consumption: "Verbrauch", + return: "Rückgabe", + correction: "Korrektur", +}; + +export default function InventoryItemDetailPage({ params }: PageProps) { + const { id } = use(params); + const [item, setItem] = useState(null); + const [movements, setMovements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const loadItem = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [itemResponse, movementResponse] = await Promise.all([ + api.get(`/inventory/items/${id}`), + api.get(`/inventory/items/${id}/movements`), + ]); + setItem(itemResponse.data); + setMovements(movementResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + queueMicrotask(() => { + void loadItem(); + }); + }, [loadItem]); + + if (loading) { + return
Artikel wird geladen...
; + } + + if (error || !item) { + return
{error || "Artikel nicht gefunden"}
; + } + + return ( +
+
+
+

{item.sku}

+

{item.name}

+

{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}

+
+ +
+ +
+
+

Stammdaten

+
+ + + + + + + + +
+
+ +
+

Bestand

+
+ + + + +
+
+
+ +
+
+

Preise

+
+ + + +
+
+ +
+

Bewegungen

+ {movements.length === 0 ? ( +

Noch keine Bewegungen vorhanden.

+ ) : ( +
+ {movements.map((movement) => ( +
+
+

{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}

+ +
+

{movement.reason}{movement.note ? ` · ${movement.note}` : ""}

+ {movement.actor_username &&

Gebucht von {movement.actor_username}

} +
+ ))} +
+ )} +
+
+
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/athena/app/inventory/page.tsx b/frontend/athena/app/inventory/page.tsx new file mode 100644 index 0000000..ce588c3 --- /dev/null +++ b/frontend/athena/app/inventory/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import EmptyState from "@/components/common/EmptyState"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import InventoryItemDialog from "@/components/inventory/InventoryItemDialog"; +import MasterDataSection from "@/components/inventory/MasterDataSection"; +import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog"; +import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemListResponse, + InventoryItemPayload, + InventoryLocation, + InventoryStockPayload, + InventorySupplier, +} from "@/types/inventory"; +import type { CurrentUser } from "@/types/rbac"; + +const pageSize = 10; + +export default function InventoryPage() { + const { showToast } = useToast(); + const [items, setItems] = useState([]); + const [categories, setCategories] = useState([]); + const [locations, setLocations] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [stockError, setStockError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [pending, setPending] = useState(false); + const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items"); + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [locationFilter, setLocationFilter] = useState("all"); + const [supplierFilter, setSupplierFilter] = useState("all"); + const [stockFilter, setStockFilter] = useState<"all" | "low">("all"); + const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true"); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [formOpen, setFormOpen] = useState(false); + const [stockOpen, setStockOpen] = useState(false); + const [stockAction, setStockAction] = useState("adjust"); + const [selectedItem, setSelectedItem] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + params.set("limit", String(pageSize)); + params.set("offset", String((page - 1) * pageSize)); + if (search.trim()) params.set("q", search.trim()); + if (categoryFilter !== "all") params.set("category_id", categoryFilter); + if (locationFilter !== "all") params.set("location_id", locationFilter); + if (supplierFilter !== "all") params.set("supplier_id", supplierFilter); + if (stockFilter === "low") params.set("low_stock", "true"); + if (activeFilter !== "all") params.set("active", activeFilter); + const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([ + api.get(`/inventory/items?${params.toString()}`), + api.get("/inventory/categories"), + api.get("/inventory/locations"), + api.get("/inventory/suppliers"), + api.get("/me"), + ]); + setItems(itemResponse.data.items); + setTotal(itemResponse.data.total); + setCategories(categoryResponse.data); + setLocations(locationResponse.data); + setSuppliers(supplierResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]); + + useEffect(() => { + queueMicrotask(() => { + void loadData(); + }); + }, [loadData]); + + const canCreate = hasPermission(currentUser, "inventory.create"); + const canUpdate = hasPermission(currentUser, "inventory.update"); + const canDelete = hasPermission(currentUser, "inventory.delete"); + const canAdjust = hasPermission(currentUser, "inventory.stock.adjust"); + const canReserve = hasPermission(currentUser, "inventory.stock.reserve"); + const canConsume = hasPermission(currentUser, "inventory.stock.consume"); + const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata"); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + + const sortedItems = useMemo(() => [...items].sort((first, second) => { + const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + if (firstValue === secondValue) return 0; + const result = firstValue > secondValue ? 1 : -1; + return sortDirection === "asc" ? result : -result; + }), [items, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "sku", label: "SKU", sortable: true, render: (item) => {item.sku} }, + { key: "name", label: "Name", sortable: true, render: (item) => item.name }, + { key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" }, + { key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" }, + { key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand }, + { key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved }, + { key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => {item.quantity_available} }, + { key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level }, + { key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" }, + { key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) }, + { key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + {canUpdate && } + {canAdjust && } + {canReserve && } + {canConsume && } + {canDelete && } +
+ ), + }, + ]; + + function openStock(item: InventoryItem, action: StockAction) { + setSelectedItem(item); + setStockAction(action); + setStockError(""); + setStockOpen(true); + } + + function handleSort(key: string) { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + } + + async function saveItem(payload: InventoryItemPayload) { + setPending(true); + setFormError(""); + try { + if (selectedItem) { + await api.put(`/inventory/items/${selectedItem.id}`, payload); + showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku }); + } else { + const response = await api.post("/inventory/items", payload); + showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku }); + } + setFormOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function saveStock(payload: InventoryStockPayload) { + if (!selectedItem) return; + setPending(true); + setStockError(""); + try { + await api.post(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload); + showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku }); + setStockOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setStockError(message); + showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + setDeleteError(""); + try { + await api.delete(`/inventory/items/${deleteItem.id}`); + showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku }); + setDeleteItem(null); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message }); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Lager / Ersatzteile

+

{total} Artikel im aktuellen Filter

+
+ {canCreate && } +
+ +
+ {(["items", "categories", "locations", "suppliers"] as const).map((value) => ( + + ))} +
+ + {tab === "items" ? ( + <> +
+ { setSearch(value); setPage(1); }} placeholder="Artikel suchen" /> + + + + + +
+ + {!loading && !error && total === 0 && !search ? ( + setFormOpen(true)}>Artikel anlegen : undefined} + /> + ) : ( + item.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Keine Artikel gefunden." + emptyDescription="Passe Suche oder Filter an." + onSort={handleSort} + /> + )} + +
+ Seite {currentPage} von {pageCount} +
+ + +
+
+ + ) : ( +
+ {tab === "categories" && void loadData()} />} + {tab === "locations" && void loadData()} />} + {tab === "suppliers" && void loadData()} />} +
+ )} + + + + !open && setDeleteItem(null)} + onConfirm={confirmDelete} + > +
+

{deleteItem?.sku} · {deleteItem?.name}

+ {deleteError &&

{deleteError}

} +
+
+
+ ); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 4bcd88f..9c4cec3 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react"; +import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wrench, XCircle } from "lucide-react"; import DetailSection from "@/components/common/DetailSection"; import { useToast } from "@/components/common/ToastProvider"; +import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection"; +import RepairEstimatesSection from "@/components/repairs/RepairEstimatesSection"; import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; @@ -137,11 +139,15 @@ export default function RepairDetailPage({ params }: Params) { setStatusError(""); try { const response = await api.put(`/repairs/${repair.id}/status`, { status, note }); - const historyResponse = await api.get(`/repairs/${repair.id}/history`); - const notificationResponse = await api.get(`/repairs/${repair.id}/notifications`); + const [historyResponse, notificationResponse, publicLinkResponse] = await Promise.all([ + api.get(`/repairs/${repair.id}/history`), + api.get(`/repairs/${repair.id}/notifications`), + canManagePublicLink ? api.get(`/repairs/${repair.id}/public-link`) : Promise.resolve(null), + ]); setRepair(response.data); setHistory(historyResponse.data); setNotifications(notificationResponse.data); + if (publicLinkResponse) setPublicLink(publicLinkResponse.data); setStatusOpen(false); showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); } catch (err) { @@ -164,6 +170,13 @@ export default function RepairDetailPage({ params }: Params) { const canUpdate = hasPermission(currentUser, "repairs.update"); const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage"); + const canReadEstimates = hasPermission(currentUser, "repair_estimates.read"); + const canCreateEstimates = hasPermission(currentUser, "repair_estimates.create"); + const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update"); + const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete"); + const canSendEstimates = hasPermission(currentUser, "repair_estimates.send"); + const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke"); + const canLexwareExport = hasPermission(currentUser, "lexware.export"); async function createPublicLink() { if (!repair || !canManagePublicLink) return; @@ -197,6 +210,27 @@ export default function RepairDetailPage({ params }: Params) { } } + async function sendStatusMail() { + if (!repair || !canUpdate) return; + setCommunicationPending(true); + try { + await api.post(`/repairs/${repair.id}/send-status-mail`); + const [notificationResponse, publicLinkResponse] = await Promise.all([ + api.get(`/repairs/${repair.id}/notifications`), + canManagePublicLink ? api.get(`/repairs/${repair.id}/public-link`) : Promise.resolve(null), + ]); + setNotifications(notificationResponse.data); + if (publicLinkResponse) setPublicLink(publicLinkResponse.data); + setLastCreatedStatusPath(""); + showToast({ type: "success", title: "Statusmail verarbeitet", description: "Der Versandversuch wurde dokumentiert." }); + } catch (err) { + const message = getErrorMessage(err); + showToast({ type: "error", title: "Statusmail konnte nicht verarbeitet werden", description: message }); + } finally { + setCommunicationPending(false); + } + } + async function copyPublicLink() { if (!lastCreatedStatusPath) { showToast({ type: "error", title: "Kein Klartextlink verfügbar", description: "Der sichere Link wird nur direkt nach der Erstellung angezeigt." }); @@ -307,6 +341,9 @@ export default function RepairDetailPage({ params }: Params) { + {(() => { + const lastMail = notifications?.events[0] ?? null; + return (
@@ -322,6 +359,12 @@ export default function RepairDetailPage({ params }: Params) { + + + + + + {lastCreatedStatusPath && (
@@ -329,13 +372,17 @@ export default function RepairDetailPage({ params }: Params) {

{lastCreatedStatusPath}

)} - {canManagePublicLink ? ( -
- +
+ {canUpdate && } + {canManagePublicLink && ( + <> + -
- ) : ( + + )} +
+ {!canManagePublicLink && (

Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.

)}
@@ -343,7 +390,26 @@ export default function RepairDetailPage({ params }: Params) {
- Vorbereitete Benachrichtigungen + Benachrichtigungen +
+
+

Versandhistorie

+ {notifications?.events.length ? ( +
+ {notifications.events.slice(0, 5).map((event) => ( +
+
+

{event.subject}

+ {event.success ? "Erfolgreich" : event.status} +
+

{formatDate(event.created_at)} · {event.recipient || "Kein Empfänger"}

+ {event.error_message &&

{event.error_message}

} +
+ ))} +
+ ) : ( +

Noch kein Versandversuch dokumentiert.

+ )}
{notifications?.templates.map((template) => ( @@ -360,11 +426,31 @@ export default function RepairDetailPage({ params }: Params) {
-

Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.

+

Statusmails werden beim Statuswechsel automatisch verarbeitet. Interne Notizen werden nicht öffentlich ausgegeben.

+ ); + })()} + + + + + + + + diff --git a/frontend/athena/app/settings/page.tsx b/frontend/athena/app/settings/page.tsx new file mode 100644 index 0000000..f5e0059 --- /dev/null +++ b/frontend/athena/app/settings/page.tsx @@ -0,0 +1,512 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react"; + +import { useToast } from "@/components/common/ToastProvider"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import type { + LexwareSettings, + LexwareSettingsPayload, + LexwareTestConnectionResponse, +} from "@/types/lexware"; +import type { + PublicLinksSettings, + SettingsSource, + SmtpSettings, + SmtpSettingsPayload, + SmtpTestResponse, +} from "@/types/system-settings"; + +type SettingsTab = "smtp" | "public-links" | "lexware"; + +const sourceLabels: Record = { + database: "Admin-Konfiguration", + environment: "Umgebung", + missing: "Nicht konfiguriert", +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? fallback; + } + return fallback; +} + +function emptySmtpSettings(): SmtpSettings { + return { + host: "", + port: 587, + username: "", + password_is_set: false, + from_email: "", + from_name: "Funktechnik Schubert", + use_tls: true, + enabled: false, + source: "missing", + }; +} + +function emptyLexwareSettings(): LexwareSettings { + return { + enabled: false, + api_base_url: "https://api.lexware.io", + api_key_is_set: false, + organization_name: "", + default_tax_rate: "19.00", + default_payment_terms_days: 14, + source: "missing", + }; +} + +export default function SettingsPage() { + const { showToast } = useToast(); + const [activeTab, setActiveTab] = useState("smtp"); + const [smtp, setSmtp] = useState(emptySmtpSettings()); + const [newPassword, setNewPassword] = useState(""); + const [testRecipient, setTestRecipient] = useState(""); + const [publicLinks, setPublicLinks] = useState({ + repair_status_base_url: "", + source: "missing", + }); + const [lexware, setLexware] = useState(emptyLexwareSettings()); + const [newLexwareApiKey, setNewLexwareApiKey] = useState(""); + const [loading, setLoading] = useState(true); + const [savingSmtp, setSavingSmtp] = useState(false); + const [sendingTest, setSendingTest] = useState(false); + const [savingPublicLinks, setSavingPublicLinks] = useState(false); + const [savingLexware, setSavingLexware] = useState(false); + const [testingLexware, setTestingLexware] = useState(false); + const [error, setError] = useState(""); + + const loadSettings = useCallback(async () => { + setError(""); + setLoading(true); + try { + const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([ + api.get("/system-settings/smtp"), + api.get("/system-settings/public-links"), + api.get("/lexware/settings"), + ]); + setSmtp(smtpResponse.data); + setPublicLinks(publicLinksResponse.data); + setLexware(lexwareResponse.data); + } catch (err) { + setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadSettings(); + }); + }, [loadSettings]); + + async function saveSmtp() { + setSavingSmtp(true); + try { + const payload: SmtpSettingsPayload = { + host: smtp.host, + port: smtp.port, + username: smtp.username, + from_email: smtp.from_email, + from_name: smtp.from_name, + use_tls: smtp.use_tls, + enabled: smtp.enabled, + }; + + if (newPassword) { + payload.password = newPassword; + } + + const response = await api.put("/system-settings/smtp", payload); + setSmtp(response.data); + setNewPassword(""); + showToast({ type: "success", title: "SMTP-Konfiguration gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "SMTP konnte nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Eingaben."), + }); + } finally { + setSavingSmtp(false); + } + } + + async function sendTestMail() { + setSendingTest(true); + try { + const response = await api.post("/system-settings/smtp/test", { + recipient: testRecipient, + }); + showToast({ + type: response.data.success ? "success" : "error", + title: response.data.success ? "Testmail versendet" : "Testmail fehlgeschlagen", + description: response.data.message, + }); + } catch (err) { + showToast({ + type: "error", + title: "Testmail fehlgeschlagen", + description: getErrorMessage(err, "Die Testmail konnte nicht versendet werden."), + }); + } finally { + setSendingTest(false); + } + } + + async function savePublicLinks() { + setSavingPublicLinks(true); + try { + const response = await api.put("/system-settings/public-links", { + repair_status_base_url: publicLinks.repair_status_base_url, + }); + setPublicLinks(response.data); + showToast({ type: "success", title: "Öffentliche Links gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "Öffentliche Links konnten nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Basis-URL."), + }); + } finally { + setSavingPublicLinks(false); + } + } + + async function saveLexware() { + setSavingLexware(true); + try { + const payload: LexwareSettingsPayload = { + enabled: lexware.enabled, + api_base_url: lexware.api_base_url, + organization_name: lexware.organization_name, + default_tax_rate: lexware.default_tax_rate, + default_payment_terms_days: lexware.default_payment_terms_days, + }; + + if (newLexwareApiKey) { + payload.api_key = newLexwareApiKey; + } + + const response = await api.put("/lexware/settings", payload); + setLexware(response.data); + setNewLexwareApiKey(""); + showToast({ type: "success", title: "Lexware-Konfiguration gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "Lexware konnte nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Lexware-Einstellungen."), + }); + } finally { + setSavingLexware(false); + } + } + + async function testLexwareConnection() { + setTestingLexware(true); + try { + const response = await api.post("/lexware/test-connection"); + showToast({ + type: response.data.success ? "success" : "error", + title: response.data.success ? "Lexware-Verbindung erfolgreich" : "Lexware-Verbindung fehlgeschlagen", + description: response.data.message, + }); + } catch (err) { + showToast({ + type: "error", + title: "Lexware-Verbindung fehlgeschlagen", + description: getErrorMessage(err, "Die Verbindung konnte nicht geprüft werden."), + }); + } finally { + setTestingLexware(false); + } + } + + if (loading) { + return
Einstellungen werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+
+

Einstellungen

+

SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten

+
+
+ setActiveTab("smtp")}> + SMTP + + setActiveTab("public-links")}> + Öffentliche Links + + setActiveTab("lexware")}> + Lexware Office + +
+
+ + {activeTab === "smtp" ? ( +
+
+
+
+ +

SMTP

+
+

+ Quelle: {sourceLabels[smtp.source]} + {smtp.password_is_set ? " · Passwort ist gesetzt" : ""} +

+
+ +
+ +
+ + + + + + setSmtp((current) => ({ ...current, host: event.target.value }))} /> + + + + setSmtp((current) => ({ ...current, port: Number(event.target.value) || 587 }))} + /> + + + + setSmtp((current) => ({ ...current, username: event.target.value }))} /> + + + + setNewPassword(event.target.value)} + /> + + + + setSmtp((current) => ({ ...current, from_email: event.target.value }))} + /> + + + + setSmtp((current) => ({ ...current, from_name: event.target.value }))} /> + +
+ +
+ Für Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS/STARTTLS aktiv, + Benutzername vollständige Mailadresse, Passwort = app-spezifisches Passwort. +
+ +
+
+ +

Testmail senden

+
+
+ setTestRecipient(event.target.value)} + /> + +
+
+
+ ) : activeTab === "public-links" ? ( +
+
+
+
+ +

Öffentliche Links

+
+

Quelle: {sourceLabels[publicLinks.source]}

+
+ +
+ + + setPublicLinks((current) => ({ ...current, repair_status_base_url: event.target.value }))} + /> + + +
+ +

+ Statusmails erzeugen weiterhin sichere Einmal-Token in Hermes. Die Basis-URL bestimmt nur, + auf welche öffentliche Website-Route der Link zeigt. +

+
+
+ ) : ( +
+
+
+
+ +

Lexware Office

+
+

+ Quelle: {sourceLabels[lexware.source]} + {lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""} +

+
+
+ + +
+
+ +
+ + + + setLexware((current) => ({ ...current, organization_name: event.target.value }))} /> + + + + setLexware((current) => ({ ...current, api_base_url: event.target.value }))} /> + + + + setNewLexwareApiKey(event.target.value)} + /> + + + + setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))} + /> + + + + setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))} + /> + +
+ +
+ +

+ Olympus bereitet Werkstatt- und KV-Daten vor. Lexware Office bleibt führend für Buchhaltung, + Rechnungen, Steuer und DATEV/EÜR. Der API-Key wird nicht angezeigt und nicht an den Browser zurückgegeben. +

+
+
+ )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index c04c193..02d54e2 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { BookOpen, ClipboardList, FileText, + HardDriveDownload, LayoutDashboard, Package, Settings, @@ -66,6 +67,7 @@ const menu = [ icon: Package, name: "Lager", href: "/inventory", + permission: "inventory.read", }, { icon: ShoppingCart, @@ -77,10 +79,17 @@ const menu = [ name: "Dokumente", href: "/documents", }, + { + icon: HardDriveDownload, + name: "Backups", + href: "/backups", + permission: "backup.read", + }, { icon: Settings, name: "Einstellungen", href: "/settings", + permission: "system_settings.manage", }, ] satisfies Array<{ icon: typeof LayoutDashboard; @@ -133,7 +142,7 @@ export default function Sidebar() {
- Olympus CRM v0.1 + Olympus CRM v0.9.1
); diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx index a3e1b37..f2fa6da 100644 --- a/frontend/athena/components/common/ConfirmDialog.tsx +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -18,6 +18,8 @@ type Props = { description: string; confirmLabel?: string; pending?: boolean; + pendingLabel?: string; + confirmDisabled?: boolean; children?: ReactNode; onOpenChange: (open: boolean) => void; onConfirm: () => void; @@ -29,6 +31,8 @@ export default function ConfirmDialog({ description, confirmLabel = "Löschen", pending = false, + pendingLabel = "Wird gelöscht...", + confirmDisabled = false, children, onOpenChange, onConfirm, @@ -56,9 +60,9 @@ export default function ConfirmDialog({ type="button" variant="destructive" onClick={onConfirm} - disabled={pending} + disabled={pending || confirmDisabled} > - {pending ? "Wird gelöscht..." : confirmLabel} + {pending ? pendingLabel : confirmLabel} diff --git a/frontend/athena/components/inventory/InventoryItemDialog.tsx b/frontend/athena/components/inventory/InventoryItemDialog.tsx new file mode 100644 index 0000000..76776a2 --- /dev/null +++ b/frontend/athena/components/inventory/InventoryItemDialog.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemPayload, + InventoryLocation, + InventorySupplier, +} from "@/types/inventory"; +import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils"; + +type Props = { + open: boolean; + item: InventoryItem | null; + categories: InventoryCategory[]; + locations: InventoryLocation[]; + suppliers: InventorySupplier[]; + pending: boolean; + serverError: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: InventoryItemPayload) => void; +}; + +function payloadFromItem(item: InventoryItem | null): InventoryItemPayload { + if (!item) { + return emptyItemPayload; + } + return { + sku: item.sku, + name: item.name, + description: item.description, + category_id: item.category_id, + manufacturer: item.manufacturer, + manufacturer_part_number: item.manufacturer_part_number, + supplier_id: item.supplier_id, + supplier_part_number: item.supplier_part_number, + compatible_devices: item.compatible_devices, + location_id: item.location_id, + quantity_on_hand: item.quantity_on_hand, + quantity_reserved: item.quantity_reserved, + reorder_level: item.reorder_level, + unit: item.unit, + purchase_price_cents: item.purchase_price_cents, + selling_price_cents: item.selling_price_cents, + currency: item.currency, + tax_rate_percent: item.tax_rate_percent, + notes: item.notes, + is_active: item.is_active, + }; +} + +export default function InventoryItemDialog({ + open, + item, + categories, + locations, + suppliers, + pending, + serverError, + onOpenChange, + onSubmit, +}: Props) { + const initialPayload = payloadFromItem(item); + const [payload, setPayload] = useState(initialPayload); + const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents)); + const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents)); + const [clientError, setClientError] = useState(""); + + function update(key: K, value: InventoryItemPayload[K]) { + setPayload((current) => ({ ...current, [key]: value })); + } + + function submit() { + const purchaseCents = euroToCents(purchasePrice); + const sellingCents = euroToCents(sellingPrice); + if (!payload.name.trim()) { + setClientError("Bitte einen Artikelnamen angeben."); + return; + } + if (payload.quantity_reserved > payload.quantity_on_hand) { + setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten."); + return; + } + if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) { + setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50."); + return; + } + setClientError(""); + onSubmit({ + ...payload, + sku: payload.sku?.trim() || null, + name: payload.name.trim(), + purchase_price_cents: purchaseCents, + selling_price_cents: sellingCents, + description: payload.description?.trim() || null, + manufacturer: payload.manufacturer?.trim() || null, + manufacturer_part_number: payload.manufacturer_part_number?.trim() || null, + supplier_part_number: payload.supplier_part_number?.trim() || null, + compatible_devices: payload.compatible_devices?.trim() || null, + notes: payload.notes?.trim() || null, + }); + } + + return ( + + + + {item ? "Artikel bearbeiten" : "Artikel anlegen"} + Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten. + + +
+
+

Stammdaten

+
+ + +