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

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 aaab81c..9267a12 100644 --- a/backend/hermes/app/storage/local.py +++ b/backend/hermes/app/storage/local.py @@ -87,7 +87,6 @@ class LocalDiskStorageProvider(StorageProvider): "customers", "projects", "tickets", - "repairs", "imports", "temp", ]: diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 4fb2749..a2215df 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.9.1 + APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} @@ -22,9 +22,6 @@ services: STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50} KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} - LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false} - LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io} - LEXWARE_API_KEY: ${LEXWARE_API_KEY:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/backend/hermes/dockerfile b/backend/hermes/dockerfile index 11c1906..71f483b 100644 --- a/backend/hermes/dockerfile +++ b/backend/hermes/dockerfile @@ -2,10 +2,6 @@ FROM python:3.13-slim WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends postgresql-client \ - && rm -rf /var/lib/apt/lists/* - COPY . . RUN pip install uv diff --git a/docker-compose.yml b/docker-compose.yml index 3c8ddf9..36aa4e5 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.9.1 + APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} @@ -26,18 +26,6 @@ services: KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} - # Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang. - PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-} - SMTP_HOST: ${SMTP_HOST:-} - SMTP_PORT: ${SMTP_PORT:-587} - SMTP_USERNAME: ${SMTP_USERNAME:-} - SMTP_PASSWORD: ${SMTP_PASSWORD:-} - SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} - SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert} - SMTP_USE_TLS: ${SMTP_USE_TLS:-true} - LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false} - LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io} - LEXWARE_API_KEY: ${LEXWARE_API_KEY:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/frontend/athena/app/api/backups/[filename]/delete/route.ts b/frontend/athena/app/api/backups/[filename]/delete/route.ts deleted file mode 100644 index 0273b73..0000000 --- a/frontend/athena/app/api/backups/[filename]/delete/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - filename: string; - }>; -}; - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - if (originError) { - return originError; - } - const { filename } = await context.params; - return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`); -} diff --git a/frontend/athena/app/api/backups/[filename]/download/route.ts b/frontend/athena/app/api/backups/[filename]/download/route.ts deleted file mode 100644 index e5827f9..0000000 --- a/frontend/athena/app/api/backups/[filename]/download/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; - -type Params = { - params: Promise<{ - filename: string; - }>; -}; - -export async function GET(request: NextRequest, context: Params) { - const { filename } = await context.params; - return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`); -} diff --git a/frontend/athena/app/api/backups/[filename]/restore/route.ts b/frontend/athena/app/api/backups/[filename]/restore/route.ts deleted file mode 100644 index 3b204b4..0000000 --- a/frontend/athena/app/api/backups/[filename]/restore/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - filename: string; - }>; -}; - -export async function POST(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - if (originError) { - return originError; - } - const { filename } = await context.params; - return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`); -} diff --git a/frontend/athena/app/api/backups/[filename]/validate/route.ts b/frontend/athena/app/api/backups/[filename]/validate/route.ts deleted file mode 100644 index 36037b3..0000000 --- a/frontend/athena/app/api/backups/[filename]/validate/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - filename: string; - }>; -}; - -export async function POST(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - if (originError) { - return originError; - } - const { filename } = await context.params; - return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`); -} diff --git a/frontend/athena/app/api/backups/create/route.ts b/frontend/athena/app/api/backups/create/route.ts deleted file mode 100644 index 3928270..0000000 --- a/frontend/athena/app/api/backups/create/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - if (originError) { - return originError; - } - return proxyHermesRequest(request, "/backups/create"); -} diff --git a/frontend/athena/app/api/backups/route.ts b/frontend/athena/app/api/backups/route.ts deleted file mode 100644 index ccf2978..0000000 --- a/frontend/athena/app/api/backups/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/backups"); -} diff --git a/frontend/athena/app/api/inventory/categories/[id]/route.ts b/frontend/athena/app/api/inventory/categories/[id]/route.ts deleted file mode 100644 index 4c1288a..0000000 --- a/frontend/athena/app/api/inventory/categories/[id]/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -async function proxyCategoryRequest(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/inventory/categories/${id}`); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyCategoryRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyCategoryRequest(request, context); -} diff --git a/frontend/athena/app/api/inventory/categories/route.ts b/frontend/athena/app/api/inventory/categories/route.ts deleted file mode 100644 index 568b58d..0000000 --- a/frontend/athena/app/api/inventory/categories/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/inventory/categories"); -} - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/inventory/categories"); -} diff --git a/frontend/athena/app/api/inventory/items/[id]/movements/route.ts b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts deleted file mode 100644 index c3eade1..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/movements/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function GET(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}/movements`); -} diff --git a/frontend/athena/app/api/inventory/items/[id]/route.ts b/frontend/athena/app/api/inventory/items/[id]/route.ts deleted file mode 100644 index 0cbc57b..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -async function proxyItemRequest(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}`); -} - -export async function GET(request: NextRequest, context: Params) { - return proxyItemRequest(request, context); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyItemRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyItemRequest(request, context); -} 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 deleted file mode 100644 index ccc0243..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`); -} 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 deleted file mode 100644 index 8ac6939..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`); -} 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 deleted file mode 100644 index 94c1db7..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`); -} 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 deleted file mode 100644 index f7c5752..0000000 --- a/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`); -} diff --git a/frontend/athena/app/api/inventory/items/route.ts b/frontend/athena/app/api/inventory/items/route.ts deleted file mode 100644 index 1000e7f..0000000 --- a/frontend/athena/app/api/inventory/items/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`); -} - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/inventory/items"); -} diff --git a/frontend/athena/app/api/inventory/items/search/route.ts b/frontend/athena/app/api/inventory/items/search/route.ts deleted file mode 100644 index 7ce787d..0000000 --- a/frontend/athena/app/api/inventory/items/search/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`); -} diff --git a/frontend/athena/app/api/inventory/locations/[id]/route.ts b/frontend/athena/app/api/inventory/locations/[id]/route.ts deleted file mode 100644 index 6823a7e..0000000 --- a/frontend/athena/app/api/inventory/locations/[id]/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -async function proxyLocationRequest(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/inventory/locations/${id}`); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyLocationRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyLocationRequest(request, context); -} diff --git a/frontend/athena/app/api/inventory/locations/route.ts b/frontend/athena/app/api/inventory/locations/route.ts deleted file mode 100644 index 08efc26..0000000 --- a/frontend/athena/app/api/inventory/locations/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/inventory/locations"); -} - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/inventory/locations"); -} diff --git a/frontend/athena/app/api/inventory/suppliers/[id]/route.ts b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts deleted file mode 100644 index c296251..0000000 --- a/frontend/athena/app/api/inventory/suppliers/[id]/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -async function proxySupplierRequest(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/inventory/suppliers/${id}`); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxySupplierRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxySupplierRequest(request, context); -} diff --git a/frontend/athena/app/api/inventory/suppliers/route.ts b/frontend/athena/app/api/inventory/suppliers/route.ts deleted file mode 100644 index 6c9f421..0000000 --- a/frontend/athena/app/api/inventory/suppliers/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/inventory/suppliers"); -} - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/inventory/suppliers"); -} diff --git a/frontend/athena/app/api/lexware/settings/route.ts b/frontend/athena/app/api/lexware/settings/route.ts deleted file mode 100644 index 3eb117a..0000000 --- a/frontend/athena/app/api/lexware/settings/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/lexware/settings"); -} - -export async function PUT(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/lexware/settings"); -} diff --git a/frontend/athena/app/api/lexware/test-connection/route.ts b/frontend/athena/app/api/lexware/test-connection/route.ts deleted file mode 100644 index 521df64..0000000 --- a/frontend/athena/app/api/lexware/test-connection/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/lexware/test-connection"); -} 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 deleted file mode 100644 index b36d87f..0000000 --- a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; - -type Params = { - params: Promise<{ - id: string; - documentId: string; - }>; -}; - -export async function GET(request: NextRequest, { params }: Params) { - const { id, documentId } = await params; - return proxyHermesStreamRequest( - request, - `/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`, - ); -} diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts deleted file mode 100644 index 734fa67..0000000 --- a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - id: string; - documentId: string; - }>; -}; - -async function proxyDocumentRequest(request: NextRequest, { params }: Params) { - const { id, documentId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`); -} - -export async function GET(request: NextRequest, context: Params) { - return proxyDocumentRequest(request, context); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyDocumentRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyDocumentRequest(request, context); -} diff --git a/frontend/athena/app/api/repairs/[id]/documents/route.ts b/frontend/athena/app/api/repairs/[id]/documents/route.ts deleted file mode 100644 index 1db71c3..0000000 --- a/frontend/athena/app/api/repairs/[id]/documents/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; - -type Params = { - params: Promise<{ - id: string; - }>; -}; - -export async function GET(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/repairs/${id}/documents`); -} diff --git a/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts deleted file mode 100644 index 90cd43d..0000000 --- a/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - id: string; - }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/repairs/${id}/documents/upload`); -} 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 deleted file mode 100644 index c95e919..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`); -} 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 deleted file mode 100644 index 97a785f..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`); -} 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 deleted file mode 100644 index 6dd1af5..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function GET(request: NextRequest, { params }: Params) { - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`); -} 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 deleted file mode 100644 index 9e3f9b7..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`); -} 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 deleted file mode 100644 index 140c741..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`); -} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts deleted file mode 100644 index bdb0937..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -async function proxyEstimateRequest(request: NextRequest, { params }: Params) { - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`); -} - -export async function GET(request: NextRequest, context: Params) { - return proxyEstimateRequest(request, context); -} - -export async function PUT(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyEstimateRequest(request, context); -} - -export async function DELETE(request: NextRequest, context: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyEstimateRequest(request, context); -} 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 deleted file mode 100644 index f7f13a6..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string; estimateId: string }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id, estimateId } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`); -} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/route.ts deleted file mode 100644 index 7469ee5..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ id: string }>; -}; - -export async function GET(request: NextRequest, { params }: Params) { - const { id } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates`); -} - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/repairs/${id}/estimates`); -} 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 deleted file mode 100644 index 143b2d1..0000000 --- a/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -type Params = { - params: Promise<{ - id: string; - }>; -}; - -export async function POST(request: NextRequest, { params }: Params) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - const { id } = await params; - return proxyHermesRequest(request, `/repairs/${id}/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 deleted file mode 100644 index a004455..0000000 --- a/frontend/athena/app/api/system-settings/public-links/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/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 deleted file mode 100644 index deed42c..0000000 --- a/frontend/athena/app/api/system-settings/smtp/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function GET(request: NextRequest) { - return proxyHermesRequest(request, "/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 deleted file mode 100644 index 2075228..0000000 --- a/frontend/athena/app/api/system-settings/smtp/test/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; -import { assertSameOrigin } from "@/lib/server/request-guards"; - -export async function POST(request: NextRequest) { - const originError = assertSameOrigin(request); - - if (originError) { - return originError; - } - - return proxyHermesRequest(request, "/system-settings/smtp/test"); -} diff --git a/frontend/athena/app/backups/page.tsx b/frontend/athena/app/backups/page.tsx deleted file mode 100644 index 3c70d97..0000000 --- a/frontend/athena/app/backups/page.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"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 4f8cb10..ddfb4c0 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.inventory, ...summary.users, ...summary.roles]; + const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles]; return (
@@ -108,23 +108,6 @@ 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 deleted file mode 100644 index ce588c3..0000000 --- a/frontend/athena/app/inventory/page.tsx +++ /dev/null @@ -1,322 +0,0 @@ -"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 9c4cec3..4bcd88f 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -2,12 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wrench, XCircle } from "lucide-react"; +import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, 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"; @@ -139,15 +137,11 @@ export default function RepairDetailPage({ params }: Params) { setStatusError(""); try { const response = await api.put(`/repairs/${repair.id}/status`, { status, note }); - 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), - ]); + const historyResponse = await api.get(`/repairs/${repair.id}/history`); + const notificationResponse = await api.get(`/repairs/${repair.id}/notifications`); 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) { @@ -170,13 +164,6 @@ 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; @@ -210,27 +197,6 @@ 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." }); @@ -341,9 +307,6 @@ export default function RepairDetailPage({ params }: Params) { - {(() => { - const lastMail = notifications?.events[0] ?? null; - return (
@@ -359,12 +322,6 @@ export default function RepairDetailPage({ params }: Params) { - - - - - - {lastCreatedStatusPath && (
@@ -372,17 +329,13 @@ export default function RepairDetailPage({ params }: Params) {

{lastCreatedStatusPath}

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

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

)}
@@ -390,26 +343,7 @@ export default function RepairDetailPage({ params }: Params) {
- 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.

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

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

+

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

- ); - })()} - - - - - - - - diff --git a/frontend/athena/app/settings/page.tsx b/frontend/athena/app/settings/page.tsx deleted file mode 100644 index f5e0059..0000000 --- a/frontend/athena/app/settings/page.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"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 02d54e2..c04c193 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -6,7 +6,6 @@ import { BookOpen, ClipboardList, FileText, - HardDriveDownload, LayoutDashboard, Package, Settings, @@ -67,7 +66,6 @@ const menu = [ icon: Package, name: "Lager", href: "/inventory", - permission: "inventory.read", }, { icon: ShoppingCart, @@ -79,17 +77,10 @@ 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; @@ -142,7 +133,7 @@ export default function Sidebar() {
- Olympus CRM v0.9.1 + Olympus CRM v0.1
); diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx index f2fa6da..a3e1b37 100644 --- a/frontend/athena/components/common/ConfirmDialog.tsx +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -18,8 +18,6 @@ type Props = { description: string; confirmLabel?: string; pending?: boolean; - pendingLabel?: string; - confirmDisabled?: boolean; children?: ReactNode; onOpenChange: (open: boolean) => void; onConfirm: () => void; @@ -31,8 +29,6 @@ export default function ConfirmDialog({ description, confirmLabel = "Löschen", pending = false, - pendingLabel = "Wird gelöscht...", - confirmDisabled = false, children, onOpenChange, onConfirm, @@ -60,9 +56,9 @@ export default function ConfirmDialog({ type="button" variant="destructive" onClick={onConfirm} - disabled={pending || confirmDisabled} + disabled={pending} > - {pending ? pendingLabel : confirmLabel} + {pending ? "Wird gelöscht..." : confirmLabel} diff --git a/frontend/athena/components/inventory/InventoryItemDialog.tsx b/frontend/athena/components/inventory/InventoryItemDialog.tsx deleted file mode 100644 index 76776a2..0000000 --- a/frontend/athena/components/inventory/InventoryItemDialog.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"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

-
- - -