Compare commits

..

21 commits
v0.5.1 ... main

Author SHA1 Message Date
Schubert Ferenc
6c917df515 feat(backup): add enterprise backup & restore foundation 2026-07-05 17:13:31 +02:00
Schubert Ferenc
7835d3ca75 fix(models): disambiguate repair estimate user relationships 2026-07-05 16:35:13 +02:00
Schubert Ferenc
46eeaa1f2e feat(accounting): add invoice preparation workflow 2026-07-05 13:41:33 +02:00
Schubert Ferenc
ffffb68898 feat(lexware): add integration foundation 2026-07-05 13:10:27 +02:00
Schubert Ferenc
8d37eb29b3 fix(estimates): allow approved estimate revocation 2026-07-05 12:26:45 +02:00
Schubert Ferenc
31bdc1047d fix(estimates): add cancel confirmation and permissions 2026-07-05 12:10:09 +02:00
Schubert Ferenc
663bdc41b1 feat(estimates): integrate inventory items 2026-07-05 11:54:23 +02:00
Schubert Ferenc
fb5c2cc26a feat(inventory): add spare parts management 2026-07-05 11:32:06 +02:00
Schubert Ferenc
abc6e308dd feat(estimates): add repair estimates and customer approval workflow 2026-07-05 02:24:02 +02:00
Schubert Ferenc
111d8d12df feat(repairs): add repair estimates 2026-07-05 02:00:44 +02:00
Schubert Ferenc
4436fe5f73 feat(repairs): add repair estimates 2026-07-05 00:57:02 +02:00
Schubert Ferenc
6e7e75f864 feat(repairs): add repair documents 2026-07-05 00:34:57 +02:00
Schubert Ferenc
884a20e043 feat(settings): add SMTP admin configuration 2026-07-04 23:54:20 +02:00
Schubert Ferenc
c58e212e3a feat(repairs): automatic repair status emails 2026-07-04 22:58:47 +02:00
Schubert Ferenc
3ed6596bf0 fix(repairs): normalize public status tokens 2026-07-04 22:19:28 +02:00
Schubert Ferenc
09cce1f2b6 feat(repairs): add status timeline and public link preparation 2026-07-04 12:25:06 +02:00
Schubert Ferenc
e9ec207617 feat(repairs): add repair management foundation 2026-07-04 11:46:58 +02:00
Schubert Ferenc
22e54f212c fix(knowledge): fix manufacturer seed logging 2026-07-03 18:48:40 +02:00
Schubert Ferenc
612e1ad8ed fix(knowledge): improve upload workflow and seed manufacturers 2026-07-03 18:40:05 +02:00
Schubert Ferenc
964b545bc5 feat(storage): add local storage framework 2026-07-03 18:11:58 +02:00
DS | Schubert
228da8f814 feat(knowledge): add service library 2026-07-03 10:25:52 +02:00
170 changed files with 19001 additions and 25 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -12,3 +12,41 @@ INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD= INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME= INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME= INITIAL_ADMIN_LAST_NAME=
# Zentrales Storage Framework.
# Lokal kann STORAGE_HOST_PATH ein Repo-lokaler Ordner sein.
# Auf dem VPS wird empfohlen: STORAGE_HOST_PATH=/opt/olympus-storage
STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50
# Server-zu-Server Token fuer spaetere Website-Reparaturannahme.
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
OLYMPUS_REPAIR_INTAKE_TOKEN=
# Env-Fallback fuer oeffentliche Reparaturstatuslinks.
# Bevorzugt wird die Admin-Konfiguration unter /settings.
# Beispiel: https://test.funktechnik-schubert.de/status
PUBLIC_REPAIR_STATUS_BASE_URL=
# Env-Fallback fuer SMTP. Bevorzugt wird die Admin-Konfiguration unter /settings.
# Fuer Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=

7
.gitignore vendored
View file

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

View file

@ -166,6 +166,58 @@ Hermes
Athena ist die einzige API-Oberflaeche fuer den Browser. 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 ## Dashboard
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`. Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
@ -177,6 +229,7 @@ Beispiele:
- Kundenstatistiken nur mit `customers.read` - Kundenstatistiken nur mit `customers.read`
- Benutzerstatistiken nur mit `users.read` - Benutzerstatistiken nur mit `users.read`
- Rollenstatus nur mit `roles.read` - Rollenstatus nur mit `roles.read`
- Reparaturkennzahlen nur mit `repairs.read`
Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung. Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung.
@ -188,6 +241,339 @@ Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes
Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt. Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt.
## Reparaturmanagement
Das Modul `Reparaturen` verwaltet interne Werkstattvorgaenge. Es liegt fachlich in Hermes und wird im Browser ausschliesslich ueber Athena erreicht.
Datenfluss:
```text
Browser -> Athena /api/repairs... -> Hermes /repairs... -> PostgreSQL
```
Kernfunktionen:
- Reparaturen manuell anlegen und bearbeiten
- eindeutige Reparaturnummer im Format `R<jahr>-<laufende nummer sechsstellig>`, z. B. `R2026-000001`
- Statusmodell von `new` bis `completed` oder `cancelled`
- deutsche UI-Statuslabels in Athena, die internen API-Statuscodes bleiben englisch und stabil
- Statushistorie in `repair_status_history`
- werkstattgerechte Status-Timeline mit Datum, Benutzer und Notiz
- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno, Intake und Statuslink-Verwaltung
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben, laufende Reparaturen, Endpruefung und abgeschlossene Reparaturen
Tabellen:
- `repairs`
- `repair_status_history`
- `repair_intake_events`
- `repair_documents`
- `repair_public_access_tokens`
- `repair_notification_events`
## Lager / Ersatzteile
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen.
Datenfluss:
```text
Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL
```
Kernregeln:
- Browser greifen nie direkt auf Hermes zu.
- Artikelnummern/SKUs sind eindeutig.
- Leere SKU bei Erstellung wird automatisch im Format `ET-<jahr>-000001` vergeben.
- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben.
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft.
- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird.
- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert.
Tabellen:
- `inventory_items`
- `inventory_categories`
- `inventory_locations`
- `inventory_suppliers`
- `inventory_stock_movements`
Bestandsbewegungen:
- `initial`
- `purchase`
- `adjustment`
- `reservation`
- `release`
- `consumption`
- `return`
- `correction`
RBAC-Permissions:
- `inventory.read`
- `inventory.create`
- `inventory.update`
- `inventory.delete`
- `inventory.stock.adjust`
- `inventory.stock.reserve`
- `inventory.stock.consume`
- `inventory.manage.masterdata`
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
Reservierungslogik:
- Entwurf: keine Reservierung.
- Senden: verknuepfte Lagerartikel werden reserviert.
- Freigabe: Reservierung bleibt bestehen.
- Ablehnung: Reservierung wird freigegeben.
- Storno: Reservierung wird freigegeben.
- Loeschung eines gesendeten KV: Reservierung wird freigegeben.
Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
Hermes-Endpunkte:
- `GET /repairs`
- `GET /repairs/{id}`
- `POST /repairs`
- `PUT /repairs/{id}`
- `DELETE /repairs/{id}` als fachliches Storno
- `PUT /repairs/{id}/status`
- `GET /repairs/{id}/history`
- `GET /repairs/{id}/public-link`
- `POST /repairs/{id}/public-link`
- `DELETE /repairs/{id}/public-link`
- `GET /repairs/{id}/notifications`
- `GET /public/repairs/status/{token}`
Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei.
Statuslabels:
```text
new = Neu
accepted = Angenommen
diagnosis = Diagnose
estimate = Kostenvoranschlag
waiting_for_customer = Wartet auf Kunde
approved = Freigegeben
repair = Reparatur
final_test = Endprüfung
ready_for_pickup = Abholbereit
shipped = Versand
completed = Abgeschlossen
cancelled = Storniert
```
### Sicherer Reparaturstatus-Link
v0.8.1 bereitet einen oeffentlichen Statuslink vor. Es wird noch kein Kundenportal und kein Kundenlogin gebaut.
Sicherheitsmodell:
- Klartexttoken wird lang und zufaellig erzeugt.
- In PostgreSQL wird nur ein HMAC/SHA-256-Hash des Tokens gespeichert.
- Der Klartexttoken wird nur einmal bei Erstellung an Athena zurueckgegeben.
- Tokens werden niemals geloggt.
- `repair_public_access_tokens` speichert Aktivstatus, Ablaufdatum, letzte Nutzung und Widerruf.
- `GET /public/repairs/status/{token}` gibt nur kundenfreundliche Statusdaten aus.
- Keine Kundendaten, internen Notizen oder Diagnosedetails werden oeffentlich ausgegeben.
Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die eigentliche Website wird in Olympus nicht veraendert.
### Reparatur-Benachrichtigungen
`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert.
Vorbereitete Ereignisse:
- Reparatur erfasst
- Geraet angenommen
- Diagnose laeuft
- Kostenvoranschlag erstellt
- Wartet auf Kundenfreigabe
- Reparatur laeuft
- Endpruefung
- Abholbereit
- Versand
- Abgeschlossen
Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`.
Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt.
Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant.
Hermes-Endpunkt fuer manuellen Neuversand:
- `POST /repairs/{id}/send-status-mail`
Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf.
### System Settings
`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten:
- `smtp.host`
- `smtp.port`
- `smtp.username`
- `smtp.password`
- `smtp.from_email`
- `smtp.from_name`
- `smtp.use_tls`
- `smtp.enabled`
- `public.repair_status_base_url`
Hermes-Endpunkte:
- `GET /system-settings/smtp`
- `PUT /system-settings/smtp`
- `POST /system-settings/smtp/test`
- `GET /system-settings/public-links`
- `PUT /system-settings/public-links`
Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist.
### Reparaturdokumente
Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework.
Datenmodell `repair_documents`:
- `repair_id`
- `title`
- `document_type`
- `original_filename`
- `stored_filename`
- `storage_path`
- `mime_type`
- `size_bytes`
- `checksum_sha256`
- `visibility`
- `note`
- `uploaded_by_user_id`
- Zeitstempel
Dokumenttypen:
- `device_photo`
- `fault_photo`
- `measurement`
- `estimate`
- `repair_report`
- `shipping`
- `other`
`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert.
Storage:
- Namespace: `repairs/<repair_id>/documents`
- Erlaubte Uploads: JPG, PNG, WEBP und PDF
- Dateinamen werden normalisiert
- SHA-256 wird gespeichert
- Dateiinhalte werden nicht geloggt
- Storage-Pfade werden nicht in Audit-Metadaten geschrieben
- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC
Hermes-Endpunkte:
- `GET /repairs/{id}/documents`
- `POST /repairs/{id}/documents/upload`
- `GET /repairs/{id}/documents/{document_id}`
- `PUT /repairs/{id}/documents/{document_id}`
- `GET /repairs/{id}/documents/{document_id}/download`
- `DELETE /repairs/{id}/documents/{document_id}`
RBAC:
- Liste, Detail und Download erfordern `repairs.read`.
- Upload, Aenderung und Loeschung erfordern `repairs.update`.
Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz.
### Kostenvoranschläge
Ab v0.8.6 verwaltet Olympus Kostenvoranschlaege direkt im Repair-Modul. Es gibt keinen Kundenlogin und keine neue Website-App. Kundenentscheidungen laufen ueber den bestehenden sicheren Public-Statuslink.
Datenmodell:
- `repair_estimates`
- `repair_estimate_items`
- `repair_estimate_events`
Kostenvoranschlagsnummern folgen `KV<jahr>-000001`. Positionstypen sind `labor`, `part`, `flat_rate`, `shipping` und `other`. Statuswerte sind `draft`, `sent`, `approved`, `declined`, `expired` und `cancelled`.
Hermes berechnet alle Summen serverseitig:
- `subtotal_cents` aus Positionen
- `tax_cents` aus `tax_rate_percent`
- `total_cents` als Summe aus netto und Steuer
Interne Hermes-Endpunkte:
- `GET /repairs/{repair_id}/estimates`
- `POST /repairs/{repair_id}/estimates`
- `GET /repairs/{repair_id}/estimates/{estimate_id}`
- `PUT /repairs/{repair_id}/estimates/{estimate_id}`
- `DELETE /repairs/{repair_id}/estimates/{estimate_id}`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/send`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/cancel`
- `GET /repairs/{repair_id}/estimates/{estimate_id}/events`
RBAC:
- `repair_estimates.read`
- `repair_estimates.create`
- `repair_estimates.update`
- `repair_estimates.delete`
- `repair_estimates.send`
Administrator bekommt alle Rechte. Management und Technik bekommen Lesen/Erstellen/Bearbeiten/Senden. Support bekommt Lesen/Senden.
Public API:
- `GET /public/repairs/status/{token}` liefert bei aktivem gesendetem KV ein Feld `estimate`.
- `POST /public/repairs/status/{token}/estimate/approve`
- `POST /public/repairs/status/{token}/estimate/decline`
- `POST /public/repairs/status/{token}/estimate/question`
Die Public-Antwort enthaelt keine internen Notizen, keine internen Events und keine Tokens. Bei Freigabe setzt Olympus den KV auf `approved` und den Reparaturstatus auf `approved`. Bei Ablehnung oder Rueckfrage bleibt der Prozess bei `waiting_for_customer`.
Der KV-Versand nutzt die bestehende SMTP-Konfiguration aus `system_settings` mit Env-Fallback. Fehler beim Mailversand zerstoeren den KV nicht; der Versandversuch wird als Notification Event dokumentiert.
Nicht enthalten in v0.8.6:
- PDF-Erzeugung
- Lexoffice
- Rechnungserstellung
- Kundenlogin
- Anzeige/Freigabe in der oeffentlichen Website
Die Website kann spaeter, z. B. in `funktechnik-schubert-website` v0.4.3, die bereits vorhandenen Public-API-Daten anzeigen und die Entscheidungsendpunkte aufrufen.
### Website Repair Intake
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
Sicherheitsmodell:
- keine Browser-Cookie- oder JWT-Authentifizierung
- Token per Header `X-Olympus-Intake-Token`
- Token aus `OLYMPUS_REPAIR_INTAKE_TOKEN`
- keine CORS-Freigabe fuer Browser erforderlich
- Token wird nicht geloggt
Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`.
Eine spaetere Statusseite ist architektonisch vorbereitet, aber noch nicht in der oeffentlichen Website implementiert.
## RBAC: Rollen und Berechtigungen ## RBAC: Rollen und Berechtigungen
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
@ -244,6 +630,16 @@ Kunden:
- `customers.read` - `customers.read`
- `customers.create` - `customers.create`
- `customers.update` - `customers.update`
- `customers.delete`
Knowledge:
- `knowledge.read`
- `knowledge.create`
- `knowledge.update`
- `knowledge.delete`
- `knowledge.upload`
- `knowledge.download`
### Initial Admin Bootstrap ### Initial Admin Bootstrap
@ -294,7 +690,6 @@ customer_number;company_name;legal_name;customer_type;status;industry;website;em
``` ```
Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert. Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert.
- `customers.delete`
Projekte: Projekte:
@ -511,6 +906,211 @@ Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlerm
Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind. Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind.
## Wissensdatenbank / Service Library
v0.6.0 fuehrt das Modul Wissensdatenbank ein. Es verwaltet Hersteller, Geraete und Modelle, Dokumente sowie Reparaturhinweise fuer Funktechnik, Elektronikreparatur und Werkstattbetrieb.
Hermes stellt dafuer Tabellen mit dem Prefix `knowledge_` bereit:
- `knowledge_manufacturers`
- `knowledge_devices`
- `knowledge_documents`
- `knowledge_notes`
- `knowledge_categories`
Athena stellt die BFF-Routen unter `/api/knowledge/...` bereit. Der Browser spricht weiterhin ausschliesslich mit Athena.
### Knowledge-Workflow
Seit v0.7.1 fuehrt die UI Benutzer explizit durch den fachlichen Ablauf:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Dokumente wie Schaltplaene, Service Manuals oder Abgleichanleitungen werden immer einem konkreten Geraet zugeordnet. Der Upload wird in Athena deaktiviert, solange Hersteller oder Geraet fehlen. Empty States erklaeren, was fehlt, und bieten die naechste sinnvolle Aktion an.
Upload-Voraussetzungen:
- Hersteller ist Pflicht.
- Geraet ist Pflicht.
- Dokumenttyp ist Pflicht.
- Datei ist Pflicht bei neuen Uploads.
- Das Geraet muss zum ausgewaehlten Hersteller gehoeren.
Hermes validiert diese Regeln serverseitig und liefert Benutzerfehler mit klaren Meldungen statt 500er-Antworten.
### Standard-Hersteller-Seeding
Hermes legt beim Startup idempotent Standard-Hersteller an, sofern sie noch nicht existieren:
- Stabo
- President
- Albrecht
- Marconi
- Rohde & Schwarz
- HP
- CRT
- Alinco
- Motorola
- Team
Das Seeding ueberschreibt bestehende Hersteller nicht. Fehlende Slugs werden ergaenzt. Websites und Notizen bleiben leer, solange keine sicheren Stammdaten gepflegt sind.
### Empty States
Athena verwendet fuer Knowledge-Listen die zentrale Komponente `EmptyState`.
Sie wird eingesetzt fuer:
- leere Herstellerliste
- leere Geraeteliste
- leere Dokumentliste
- leere Notizliste
- Suche oder Filter ohne Treffer
Jeder Empty State erklaert kurz den fehlenden Zustand und bietet eine passende Aktion an.
### Knowledge-Dateispeicherung
Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. KnowledgeService speichert, liest und loescht Dateien nicht mehr direkt ueber verstreute Dateioperationen, sondern ueber `StorageService`.
Neue Uploads werden im Namespace `knowledge/documents/<manufacturer_id>` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien.
Reparaturdokumente werden ab v0.8.5 im Namespace `repairs/<repair_id>/documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature.
Konfiguration:
- `STORAGE_PROVIDER`, Default `local`
- `STORAGE_BASE_PATH`, Default `/data/storage`
- `STORAGE_MAX_UPLOAD_MB`, Default `50`
- `KNOWLEDGE_STORAGE_PATH`, Legacy-Fallback fuer bestehende Installationen
- `KNOWLEDGE_MAX_UPLOAD_MB`, Legacy-Fallback fuer bestehende Installationen
Docker Compose bindet den Host-Pfad aus `STORAGE_HOST_PATH` nach `/data/storage` ein. Fuer den VPS ist `/opt/olympus-storage` empfohlen. Bestehende Dateien unter `/data/knowledge` werden nicht automatisch verschoben; wenn Migration noetig ist, muss sie kontrolliert geplant und vorher gebackupt werden.
Erlaubte Uploadtypen:
- PDF
- JPG/JPEG
- PNG
- WEBP
- TXT
- ZIP
Hermes normalisiert Dateinamen, erzeugt eindeutige gespeicherte Dateinamen, validiert Extension und MIME-Type, begrenzt die Uploadgroesse, berechnet SHA256 und verhindert Path Traversal.
## Storage Framework
Das Storage Framework liegt unter `backend/hermes/app/storage`.
Bestandteile:
- `StorageProvider` als Interface
- `LocalDiskStorageProvider` als erste Implementierung
- `StorageService` als zentrale API fuer Fachmodule
- Storage-Schemas und eigene Storage-Exceptions
Zentrale Operationen:
- `save_file()`
- `open_file()`
- `delete_file()`
- `file_exists()`
- `get_file_metadata()`
- `calculate_checksum()`
- `safe_filename()`
- `validate_file_type()`
- `validate_file_size()`
Der aktuelle Provider ist `local`. Die Architektur ist bewusst fuer spaetere Provider vorbereitet:
- NAS
- S3
- MinIO
- Paperless
- Azure Blob
- Backblaze / Wasabi
Pfadstruktur im lokalen Provider:
```text
/data/storage/
knowledge/
documents/
thumbnails/
customers/
projects/
tickets/
imports/
temp/
```
Datei-Inhalte werden nicht geloggt. Browser greifen nie direkt auf Storage oder Hermes-Dateipfade zu; Downloads laufen ueber Athena-BFF und Hermes-Berechtigungspruefung.
## Backup und Deployment
Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
- `scripts/migrate.sh`
- `scripts/healthcheck.sh`
- `scripts/deploy.sh`
- `scripts/backup.sh`
- `scripts/restore.sh`
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
### Backup-Modul ab v0.9.1
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
- Hermes-API: `backend/hermes/app/api/backups.py`
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
- Athena-BFF: `/api/backups/...`
Sicherheitsregeln:
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
- Hermes anonymisiert den Datenbank-Host im Manifest.
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
### Knowledge-RBAC
Neue Berechtigungen:
- `knowledge.read`
- `knowledge.create`
- `knowledge.update`
- `knowledge.delete`
- `knowledge.upload`
- `knowledge.download`
Standardrollen:
- `administrator`: alle Knowledge-Rechte
- `management`: `knowledge.read`, `knowledge.download`
- `technician`: `knowledge.read`, `knowledge.create`, `knowledge.update`, `knowledge.upload`, `knowledge.download`
- `support`: `knowledge.read`, `knowledge.download`
- `guest`: keine Knowledge-Rechte
Hermes erzwingt diese Rechte serverseitig. Athena nutzt sie nur fuer Navigation und UI.
### Paperless-Vorbereitung
Eine echte Paperless-ngx-Integration ist in v0.6.0 nicht aktiv. Dokumente enthalten bereits `paperless_document_id` und `external_url`, damit spaeter ein PaperlessConnector angebunden werden kann. API-Keys oder verpflichtende Paperless-Umgebungsvariablen werden in v0.6.0 nicht eingefuehrt.
Spaetere Verknuepfungen sind fuer Kunden, Projekte, Tickets und Reparaturen vorgesehen.
## Verzeichnisstruktur ## Verzeichnisstruktur
```text ```text
@ -564,6 +1164,33 @@ docker-compose.yml
`LOG_LEVEL` `LOG_LEVEL`
: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`. : Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`.
`STORAGE_PROVIDER`
: Storage Provider. Aktuell produktiv implementiert: `local`.
`STORAGE_BASE_PATH`
: Interner Storage-Basispfad in Hermes. Default `/data/storage`.
`STORAGE_MAX_UPLOAD_MB`
: Maximale Uploadgroesse fuer Storage-Dateien in MB. Default `50`.
`STORAGE_HOST_PATH`
: Docker-Host-Pfad, der nach `/data/storage` gemountet wird. Lokal z. B. `./storage`, auf dem VPS empfohlen `/opt/olympus-storage`.
`KNOWLEDGE_STORAGE_PATH`
: Legacy-Fallback fuer bestehende Knowledge-Dateien. Neue Installationen sollen `STORAGE_*` verwenden.
`KNOWLEDGE_MAX_UPLOAD_MB`
: Legacy-Fallback fuer die maximale Knowledge-Uploadgroesse.
`OLYMPUS_REPAIR_INTAKE_TOKEN`
: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen.
`PUBLIC_REPAIR_STATUS_BASE_URL`
: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, gibt Hermes weiterhin relative Links im Format `/status/<token>` zurueck.
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`
: Env-Fallback fuer automatische Reparaturstatusmails. Bevorzugt wird die Admin-Konfiguration unter `/settings`. `SMTP_PASSWORD` darf nicht geloggt, committed oder an Athena zurueckgegeben werden. Fuer Apple Mail/iCloud: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername vollstaendige Mailadresse, Passwort app-spezifisches Passwort.
### Athena ### Athena
`HERMES_INTERNAL_URL` `HERMES_INTERNAL_URL`

View file

@ -73,7 +73,7 @@ Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind:
- keine `.venv` im Git - keine `.venv` im Git
- keine toten Imports - keine toten Imports
- keine ungenutzten Dateien - keine ungenutzten Dateien
- keine Debug-Ausgaben wie `console.log`, `alert` oder `confirm` - keine Debug-Ausgaben oder Browser-Dialoge im Anwendungscode
Standard-Checks: Standard-Checks:
@ -103,7 +103,7 @@ uv run alembic upgrade head
- Wiederverwendbare Komponenten bevorzugen. - Wiederverwendbare Komponenten bevorzugen.
- UI-Zustaende immer abbilden: Loading, Error, Empty State. - UI-Zustaende immer abbilden: Loading, Error, Empty State.
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden. - Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
- Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine nativen Browser-Dialoge fuer produktive UI-Flows.
- Keine Tokens in Browser-JavaScript speichern. - Keine Tokens in Browser-JavaScript speichern.
- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet. - Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet.
@ -120,10 +120,12 @@ uv run alembic upgrade head
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant. - Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren. - Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen. - Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen.
- Dateiablagen laufen ueber `StorageService`; direkte Dateioperationen in Fachservices sind nur mit guter Begruendung zulaessig.
- Uploads muessen Dateityp, MIME-Type, Groesse, Dateiname und Storage-Pfad validieren.
### Allgemein ### Allgemein
- Keine TODOs als Ersatz fuer fertige Implementierung. - Keine Platzhalter-Kommentare als Ersatz fuer fertige Implementierung.
- Keine Quickfixes. - Keine Quickfixes.
- Keine Workarounds. - Keine Workarounds.
- Keine ungeprueften Annahmen bei Auth, Datenbank oder Docker. - Keine ungeprueften Annahmen bei Auth, Datenbank oder Docker.
@ -156,6 +158,7 @@ Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
Beispiele fuer spaetere Module: Beispiele fuer spaetere Module:
- Kunden - Kunden
- Reparaturen
- Projekte - Projekte
- Lieferanten - Lieferanten
- Kontakte - Kontakte
@ -220,10 +223,64 @@ Importmodi:
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`. Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
### Wissensdatenbank
Das Knowledge-Modul folgt dem bestehenden Modulstandard:
- SQLAlchemy-Modell in `backend/hermes/app/models/knowledge.py`
- Pydantic-Schemas in `backend/hermes/app/schemas/knowledge.py`
- Repository in `backend/hermes/app/repositories/knowledge_repository.py`
- Service-Logik in `backend/hermes/app/services/knowledge_service.py`
- FastAPI-Router in `backend/hermes/app/api/knowledge.py`
- Athena-BFF unter `/api/knowledge/...`
- Frontend-Seiten unter `/knowledge`
Upload-Regeln:
- Browser sendet Dateien nur an Athena.
- Athena leitet FormData serverseitig an Hermes weiter.
- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad.
- Lokale Dateien liegen ueber Docker unter `STORAGE_BASE_PATH`; alte Knowledge-Pfade bleiben nur als Legacy-Fallback erhalten.
- Dokumentuploads benoetigen Hersteller, Geraet, Dokumenttyp und Datei.
- Ein Geraet muss serverseitig zum ausgewaehlten Hersteller gehoeren.
- Fehlende Upload-Voraussetzungen muessen im Formular sichtbar sein und duerfen keinen Upload starten.
- Audit Logs duerfen keine Datei-Inhalte enthalten.
- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen.
Workflow-Regel:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Knowledge-Listen sollen `EmptyState` nutzen, wenn Daten fehlen oder Suche/Filter keinen Treffer liefern.
Paperless-ngx ist nur vorbereitet. `paperless_document_id` und `external_url` duerfen gepflegt werden, aber es werden keine Paperless-Secrets oder API-Keys eingefuehrt.
### Storage
Neue Dateiablagen muessen das zentrale Storage Framework verwenden.
Regeln:
- Keine unvalidierten Pfade aus Benutzereingaben zusammensetzen.
- Keine absoluten User-Pfade akzeptieren.
- Keine Path-Traversal-Moeglichkeiten zulassen.
- Originaldateinamen und gespeicherte Dateinamen fachlich unterscheiden.
- SHA256 fuer gespeicherte Dateien berechnen, sofern das Modul Datei-Metadaten persistiert.
- Keine Datei-Inhalte loggen.
- Bestehende Dateien nicht automatisch verschieben oder loeschen.
Der lokale Provider nutzt `STORAGE_BASE_PATH`; Docker mountet den Host-Pfad aus `STORAGE_HOST_PATH` nach `/data/storage`.
### Deployment ### Deployment
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen. Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
Dateien benoetigen einen persistenten Storage-Mount. Vor produktiven Deployments muessen `STORAGE_PROVIDER`, `STORAGE_BASE_PATH`, `STORAGE_MAX_UPLOAD_MB` und `STORAGE_HOST_PATH` bewusst gesetzt oder die Defaults akzeptiert werden.
Die Skripte unter `scripts/` sind die bevorzugte Grundlage fuer Migration, Healthcheck, Deploy, Backup und Restore.
### Neue Permissions ### Neue Permissions
Neue Module muessen eigene stabile Permission-Strings erhalten. Neue Module muessen eigene stabile Permission-Strings erhalten.
@ -274,6 +331,9 @@ Vor Merge pruefen:
- Audit Logs fuer relevante Aenderungen vorhanden - Audit Logs fuer relevante Aenderungen vorhanden
- API-Fehlerantworten konsistent - API-Fehlerantworten konsistent
- Toasts fuer mutierende UI-Aktionen vorhanden - Toasts fuer mutierende UI-Aktionen vorhanden
- Dateiablagen verwenden `StorageService`
- Storage-Pfade sind gegen Path Traversal geschuetzt
- Backup-/Restore-Auswirkungen fuer Dateiablagen dokumentiert
## Migrationsregeln ## Migrationsregeln

418
README-DEV.md Normal file
View file

@ -0,0 +1,418 @@
# Olympus CRM Entwicklungssetup
Dieses Dokument beschreibt das lokale Setup fuer neue Rechner und parallele Entwicklung auf mehreren Macs.
## Setup auf neuem Mac
Voraussetzungen:
- Git
- Docker Desktop
- Node.js passend zu Athena
- Python/uv fuer lokale Hermes-Checks
Repository klonen:
```bash
git clone <repository-url> Olympus
cd Olympus
```
Umgebung anlegen:
```bash
cp .env.example .env
```
Wichtige lokale Werte:
```env
AUTH_COOKIE_SECURE=false
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
OLYMPUS_REPAIR_INTAKE_TOKEN=
PUBLIC_REPAIR_STATUS_BASE_URL=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=
```
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
## Docker Netzwerk
Das gemeinsame Compose-File nutzt ein externes Docker-Netzwerk:
```bash
docker network create olympus-network
```
Wenn das Netzwerk bereits existiert, meldet Docker das nur als Hinweis.
## Docker Compose Start
```bash
docker compose build
docker compose up -d
```
Hermes ist im gemeinsamen Stack nur intern im Docker-Netzwerk erreichbar. Athena ist lokal ueber den Browser erreichbar:
- Athena: `http://localhost:3001`
- Hermes intern: `http://hermes:8000`
Der Browser spricht nicht direkt mit Hermes.
## Migrationen
Migrationen ausfuehren:
```bash
scripts/migrate.sh
```
Alternativ lokal im Backend:
```bash
cd backend/hermes
uv run alembic upgrade head
```
## Initial Admin
Fuer neue Installationen ohne aktive Benutzer kann Hermes beim Startup einen initialen Admin anlegen.
```env
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_EMAIL=admin@example.local
INITIAL_ADMIN_PASSWORD=<lokales-passwort>
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=
```
Diese Werte nur lokal oder sicher auf dem Zielsystem setzen. Nach dem ersten produktiven Login sollte das Passwort geaendert und die Bootstrap-Variablen wieder entfernt werden.
## Login
Nach Start und Migration:
1. Browser auf `http://localhost:3001` oeffnen.
2. Mit dem initialen Admin oder einem bestehenden Benutzer anmelden.
3. Athena setzt das HttpOnly-Cookie. Hermes setzt keine Browser-Cookies.
## Storage
Neue Dateiablagen laufen ueber das Storage Framework.
Lokaler Standard:
```env
STORAGE_HOST_PATH=./storage
STORAGE_BASE_PATH=/data/storage
```
VPS-Empfehlung:
```env
STORAGE_HOST_PATH=/opt/olympus-storage
STORAGE_BASE_PATH=/data/storage
```
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
## Backup und Restore
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
Ablage:
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
- Backup-Dateien gehoeren nie ins Git
Inhalt eines Backups:
- `manifest.json`
- `database.dump`
- `storage/`
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
- `GET /api/backups`
- `POST /api/backups/create`
- `GET /api/backups/[filename]/download`
- `POST /api/backups/[filename]/validate`
- `POST /api/backups/[filename]/restore`
- `DELETE /api/backups/[filename]/delete`
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
1. Backup validieren.
2. Sicherheitsbestaetigung pruefen.
3. Restore ueber `scripts/restore.sh <backup-zip>` ausfuehren.
4. Ergebnis und Audit Logs kontrollieren.
Empfehlung fuer den Betrieb:
- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
- Backup-Dateien vor Offsite-Kopie verschluesseln.
- Restore nur in Wartungsfenstern ausfuehren.
## Knowledge Workflow
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Hermes legt beim Startup Standard-Hersteller an, wenn sie noch fehlen. Danach koennen Geraete angelegt werden. Dokumentuploads sind erst sinnvoll, wenn Hersteller und Geraet vorhanden sind; Athena blockiert unvollstaendige Uploads direkt im Formular.
## Reparaturmanagement
Das Reparaturmodul ist ab v0.8.0 aktiv.
Lokaler Ablauf:
```bash
cd backend/hermes
SECRET_KEY=local-check uv run alembic upgrade head
```
Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
- `/api/repairs`
- `/api/repairs/[id]`
- `/api/repairs/[id]/documents`
- `/api/repairs/[id]/documents/upload`
- `/api/repairs/[id]/documents/[documentId]`
- `/api/repairs/[id]/documents/[documentId]/download`
- `/api/repairs/[id]/status`
- `/api/repairs/[id]/history`
- `/api/repairs/[id]/public-link`
- `/api/repairs/[id]/notifications`
- `/api/repairs/[id]/send-status-mail`
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
Ab v0.8.1 zeigt Athena deutsche Statuslabels, waehrend die API-Statuscodes englisch und stabil bleiben. Statuswechsel werden als Timeline mit Benutzer, Datum und Notiz angezeigt.
Statuslink-Konzept:
- Interne Verwaltung ueber `GET|POST|DELETE /repairs/{id}/public-link`.
- RBAC-Permission: `repairs.public_link.manage`.
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
- `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`.
- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback.
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
## Lexware Office Foundation
Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet.
Rollenverteilung:
- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten.
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
Konfiguration:
- Adminbereich: `/settings -> Lexware Office`
- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection`
- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection`
- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf.
Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden:
```text
POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
```
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
Exportstatus:
- `prepared`
- `transferred`
- `booked`
- `cancelled`
Benachrichtigungen:
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
- `repair_notification_events` dokumentiert jeden Versandversuch.
- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet.
- Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert.
- SMTP wird bevorzugt ueber `/settings` konfiguriert. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` bleiben als Env-Fallback erhalten.
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert.
Reparaturdokumente:
- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden.
- Hermes speichert Uploads ueber `StorageService` unter `repairs/<repair_id>/documents`.
- Erlaubte Dateitypen: JPG, PNG, WEBP und PDF.
- Metadaten liegen in `repair_documents`; Dateiinhalte liegen nie im Git oder in `public`.
- `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien.
- PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature.
Kostenvoranschlaege:
- Ab v0.8.6 koennen KVs direkt auf der Reparaturdetailseite erstellt, bearbeitet, gesendet, storniert und geloescht werden.
- Athena nutzt ausschliesslich BFF-Routen unter `/api/repairs/[id]/estimates`.
- Hermes berechnet alle Summen serverseitig. Client-Summen sind nur Vorschau.
- Nummernformat: `KV<jahr>-000001`.
- Der Versand nutzt die bestehende SMTP-Konfiguration aus `/settings` mit Env-Fallback.
- Der Versand erzeugt/erneuert einen sicheren Public-Statuslink und sendet ihn an `customer_email`.
- `GET /public/repairs/status/{token}` liefert aktive/gesendete KV-Daten ohne interne Notizen.
- Public-Entscheidungen laufen ohne Kundenlogin ueber:
- `POST /public/repairs/status/{token}/estimate/approve`
- `POST /public/repairs/status/{token}/estimate/decline`
- `POST /public/repairs/status/{token}/estimate/question`
- Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit.
- PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures.
## Lager / Ersatzteile
Das Lagermodul ist ab v0.8.7 aktiv.
Migration lokal ausfuehren:
```bash
cd backend/hermes
SECRET_KEY=local-check uv run alembic upgrade head
```
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
- `/api/inventory/items`
- `/api/inventory/items/search`
- `/api/inventory/items/[id]`
- `/api/inventory/items/[id]/stock/adjust`
- `/api/inventory/items/[id]/stock/reserve`
- `/api/inventory/items/[id]/stock/release`
- `/api/inventory/items/[id]/stock/consume`
- `/api/inventory/items/[id]/movements`
- `/api/inventory/categories`
- `/api/inventory/categories/[id]`
- `/api/inventory/locations`
- `/api/inventory/locations/[id]`
- `/api/inventory/suppliers`
- `/api/inventory/suppliers/[id]`
Hermes-Endpunkte liegen unter `/inventory/...` und sind mit `inventory.*` Permissions geschuetzt.
Bestandslogik:
- SKU optional; bei leerer SKU erzeugt Hermes `ET-<jahr>-000001`.
- `quantity_available = quantity_on_hand - quantity_reserved`.
- Bestandsanpassungen, Reservierungen, Freigaben und Verbrauch erzeugen `inventory_stock_movements`.
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`.
KV-Integration ab v0.8.8:
- Lagerartikel koennen im KV-Dialog gesucht und als Position uebernommen werden.
- Hermes uebernimmt Verkaufspreis, Einheit, Artikelname, SKU und Hersteller serverseitig.
- `repair_estimate_items` speichert Snapshot-Felder, damit spaetere Lageraenderungen alte KVs nicht veraendern.
- Nur gesendete KVs reservieren Bestand.
- Ablehnung, Storno und Loeschung eines gesendeten KV geben Reservierungen frei.
- Freigegebene KVs behalten die Reservierung.
- Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, aber noch nicht aktiv.
Folgefeatures:
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv.
Vorbereitete Website-/Portal-Routen fuer spaeter:
- `/status/<token>`
- `/portal/login`
## Typische Fehler
Hermes restartet wegen fehlender Migration:
```bash
scripts/migrate.sh
docker compose restart hermes
```
`SECRET_KEY` mit `$` wird falsch interpretiert:
- Wert in `.env` korrekt escapen oder quoten.
- Keine Secrets in Commit oder Logs schreiben.
Docker Container Name Conflict:
```bash
docker compose down
docker ps -a
```
Danach blockierenden Altcontainer gezielt entfernen, wenn er wirklich nicht mehr gebraucht wird.
External network fehlt:
```bash
docker network create olympus-network
```
DB leer oder Admin fehlt:
- `INITIAL_ADMIN_*` Werte setzen.
- `scripts/migrate.sh` ausfuehren.
- `docker compose restart hermes`.
Athena nicht erreichbar:
- `docker compose ps` pruefen.
- Port `3001` auf dem Rechner pruefen.
- `scripts/healthcheck.sh` ausfuehren.
## MacBook und Mac mini parallel
Empfehlung:
- `.env` pro Rechner lokal pflegen und nicht committen.
- `STORAGE_HOST_PATH` pro Rechner bewusst setzen.
- Datenbank- und Storage-Backups nicht ungeprueft zwischen Rechnern ueberschreiben.
- Vor Branch-Wechseln Migrationen pruefen.
- Bei paralleler Arbeit keine Docker-Volumes loeschen, solange ungesicherte Uploads existieren.
## Qualitaetschecks
```bash
python3 -m compileall backend/hermes/app
cd frontend/athena
npm run lint
npx next build --webpack
```
Skripte pruefen:
```bash
bash -n scripts/*.sh
```

View file

@ -44,15 +44,181 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Excel-freundliche CSV-Vorlage ueber Athena - Excel-freundliche CSV-Vorlage ueber Athena
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten - Audit Log fuer Kundenimporte ohne CSV-Rohdaten
## v0.6.0 - Projektmodul, geplant ## v0.6.0 - Wissensdatenbank / Service Library
- Projektstammdaten - Herstellerverwaltung
- Projektstatus und Verantwortliche - Geraete- und Modellverwaltung
- Verknuepfung mit Kunden - Dokumentverwaltung mit lokalem Upload und Download
- RBAC-Permissions fuer Projekte - Reparaturhinweise und Knowledge Notes
- Audit Logs fuer Projektaktionen - Suche ueber Hersteller, Modelle, Dokumente und Notizen
- RBAC-Permissions fuer Knowledge-Lesen, Upload und Download
- Paperless-ngx vorbereitet ueber `paperless_document_id` und `external_url`
- Persistentes Docker-Volume fuer Knowledge-Dateien
## v0.7.0 - Tickets, geplant ## v0.7.0 - Storage Framework und Deployment-Grundlage
- Zentrales Storage Framework in Hermes
- LocalDisk Storage Provider
- Knowledge-Dateilogik ueber StorageService
- Storage-Konfiguration fuer lokale Entwicklung und VPS
- Empfohlener VPS-Pfad `/opt/olympus-storage`
- Deployment-, Migrations-, Healthcheck-, Backup- und Restore-Skripte
- README-DEV fuer mehrere Entwicklungsrechner
## v0.7.1 - Knowledge UX und Workflow
- Gefuehrter Workflow Hersteller -> Geraet -> Dokument -> Notiz
- Dokumentupload erst nach Hersteller- und Geraeteanlage
- Inline-Validierung fuer Upload-Pflichtfelder
- Professionelle Empty States fuer Knowledge-Listen und Suchen
- Idempotentes Standard-Hersteller-Seeding
- Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen
## v0.8.0 - Reparaturmanagement
- Reparaturstammdaten und Werkstattvorgaenge
- Reparaturnummern im Format `R<jahr>-000001`
- Statusmodell und Statushistorie
- Website Repair Intake API vorbereitet
- RBAC-Permissions fuer Reparaturen
- Audit Logs und Activity Feed fuer Reparaturaktionen
- Dashboard-Kennzahlen fuer Reparaturen
- Athena-BFF-Routen und Reparatur-UI
## v0.8.1 - Reparaturstatus und Kundenkommunikation
- Deutsche Statuslabels in Athena bei stabilen englischen API-Statuscodes
- Werkstattgerechte Status-Timeline auf der Reparaturdetailseite
- Sicheres Public-Token-Modell fuer spaetere Statuslinks
- Oeffentlicher Hermes-Endpunkt `GET /public/repairs/status/{token}` mit minimalen Kundendaten
- Interne Statuslink-Verwaltung in Athena
- E-Mail-Vorlagen und `repair_notification_events` vorbereitet
- Keine Kundenlogin-Funktion und keine Aenderungen an der oeffentlichen Website
## v0.8.2 - Website-Statusintegration, geplant
- `/status/<token>` als spaeterer Einstieg fuer Statusabfragen auf der Website
- Website ruft Statusdaten serverseitig oder ueber einen eigenen BFF ab
- Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort
- Optionaler E-Mail-Versand mit sicherem Statuslink
## v0.8.3 - Automatische Reparaturstatusmails
- Automatischer Statusmail-Versand bei Reparaturstatuswechseln
- Frische sichere Public-Statuslinks fuer Statusmails
- Professionelles HTML-Mailtemplate fuer Funktechnik Schubert
- Notification-Historie mit Erfolg, Fehler und Empfaenger
- Manueller Neuversand ueber `POST /repairs/{id}/send-status-mail`
- Athena-BFF-Route und Button "E-Mail erneut senden"
- Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail
- SMTP-Ausfall blockiert keine Reparaturprozesse
## v0.8.4 - Admin SMTP Settings
- SMTP-Konfiguration im Olympus-Webinterface unter `/settings`
- Testmail-Versand mit Audit-Log
- `system_settings` als zentrale Tabelle fuer Systemkonfiguration
- Env Vars bleiben als Fallback fuer SMTP und Public Repair Status Base URL
- Statusmail-Versand nutzt bevorzugt Admin-Konfiguration
- Passwort wird nicht an Athena zurueckgegeben
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
## v0.8.5 - Reparaturdokumente
- Dokumente und Bilder direkt an Reparaturen verwalten
- Upload ueber Athena-BFF und Hermes StorageService
- Storage-Pfad `repairs/<repair_id>/documents`
- Erlaubte Dateitypen JPG, PNG, WEBP und PDF
- Dokumenttypen fuer Geraetefotos, Fehlerbilder, Messbilder, Kostenvoranschlag, Reparaturbericht, Versandbeleg und Sonstiges
- Sichtbarkeit `internal`/`customer` vorbereitet, aber noch keine oeffentliche Kundenanzeige
- Bildvorschau mit Lightbox, PDF inline/download
- Audit Logs fuer Upload, Aenderung und Loeschung
## v0.8.6 - Kostenvoranschläge und Kundenfreigabe
- Kostenvoranschläge direkt an Reparaturen
- Positionen fuer Arbeitszeit, Ersatzteile, Pauschalen, Versand und Sonstiges
- Serverseitige Netto-, MwSt.- und Gesamtsummen
- KV-Nummern im Format `KV<jahr>-000001`
- Versand per bestehendem Public-Statuslink und SMTP-Systemsettings
- Public API liefert Estimate-Daten am Statuslink
- Public Approve/Decline/Question ohne Kundenlogin
- Kundenentscheidung aktualisiert Reparaturstatus und KV-Historie
- Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version
- Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen
## v0.8.7 - Lager / Ersatzteile
- Ersatzteil- und Lagerartikelverwaltung
- Kategorien, Lagerorte und Lieferanten
- SKU-Format `ET-<jahr>-000001`
- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit
- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch
- Preise intern in cents, UI-Eingabe in Euro
- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten
- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen
- Audit Logs und Activity Feed fuer Lageraktionen
- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]`
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
## v0.8.8 - Lagerintegration in Kostenvoranschläge
- Lagerartikel als KV-Position ueber Suche auswaehlen
- Verkaufspreis aus `selling_price_cents` uebernehmen
- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen
- Reservierung beim Senden eines KV
- Freigabe behaelt Reservierung
- Ablehnung, Storno und Loeschung geben Reservierungen frei
- Bestandsbewegungen mit `reference_type=repair_estimate`
- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand
- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung
- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv
- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures
## v0.8.9 - Lexware Office Integration Foundation
- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert
- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten
- Lexware-Konfiguration unter `/settings -> Lexware Office`
- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel
- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
- API-Key wird nicht an Athena zurueckgegeben
- Server-seitiger Verbindungstest gegen `GET /v1/profile`
- Neue Tabelle `lexware_sync_records`
- Optionale Lexware-Rechnungsreferenzen an `repair_estimates`
- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
## v0.9.0 - Lexware Rechnungserstellung, geplant
- Echte Rechnungserstellung in Lexware nach final geprueftem API-Mapping
- Kontaktabgleich und Kontaktanlage in Lexware produktionsreif ausbauen
- Exportstatus und Fehlerbehebung im Olympus UI erweitern
- Optionaler Download/Link zur Lexware-Rechnung
## v0.9.1 - Backup und Restore
- Backup-Modul mit Athena-BFF und Hermes-Service-Layer
- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/`
- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups`
- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore`
- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus
- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung
- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh`
- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad
## v0.9.2 - Kundenportal, geplant
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
## v0.9.3 - Tickets, geplant
- Ticketverwaltung - Ticketverwaltung
- Status- und Prioritaetsmodell - Status- und Prioritaetsmodell
@ -60,15 +226,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Tickets - RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen - Audit Logs fuer Ticketaktionen
## v0.8.0 - Kalender und Aufgaben, geplant ## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
- Paperless-ngx Connector fuer Wissensdokumente
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
- Sichere Connector-Konfiguration ohne Browser-Secrets
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
## v1.0.0 - Kalender, Aufgaben und produktiver Beta-Release
- Aufgabenverwaltung - Aufgabenverwaltung
- Kalenderansicht - Kalenderansicht
- Faelligkeiten und Verantwortlichkeiten - Faelligkeiten und Verantwortlichkeiten
- Verknuepfung mit Kunden, Projekten und Tickets - Verknuepfung mit Kunden, Projekten und Tickets
## v1.0.0 - Produktiver Beta-Release
- Gehaertete Produktionskonfiguration - Gehaertete Produktionskonfiguration
- Vollstaendige Migrationsstrategie - Vollstaendige Migrationsstrategie
- Deployment-Dokumentation - Deployment-Dokumentation

BIN
backend/.DS_Store vendored

Binary file not shown.

View file

@ -13,3 +13,26 @@ INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD= INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME= INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME= INITIAL_ADMIN_LAST_NAME=
STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50
OLYMPUS_REPAIR_INTAKE_TOKEN=
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
PUBLIC_REPAIR_STATUS_BASE_URL=
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=

View file

@ -8,6 +8,14 @@ from alembic import context
from app.core.config import settings from app.core.config import settings
from app.db.database import Base from app.db.database import Base
import app.models.user import app.models.user
import app.models.customer
import app.models.knowledge
import app.models.audit
import app.models.rbac
import app.models.repair
import app.models.repair_estimate
import app.models.inventory
import app.models.system_setting
config = context.config config = context.config

View file

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

View file

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

View file

@ -0,0 +1,160 @@
"""create repair estimates
Revision ID: b4e8f1c7a205
Revises: a1c5f9e2d430
Create Date: 2026-07-05 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b4e8f1c7a205"
down_revision: Union[str, Sequence[str], None] = "a1c5f9e2d430"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ESTIMATE_PERMISSIONS = [
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
]
def upgrade() -> None:
op.create_table(
"repair_estimates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("estimate_number", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("customer_message", sa.Text(), server_default="", nullable=False),
sa.Column("internal_note", sa.Text(), nullable=True),
sa.Column("subtotal_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
sa.Column("tax_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("customer_response_message", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("estimate_number"),
)
op.create_index(op.f("ix_repair_estimates_created_by_user_id"), "repair_estimates", ["created_by_user_id"], unique=False)
op.create_index(op.f("ix_repair_estimates_estimate_number"), "repair_estimates", ["estimate_number"], unique=True)
op.create_index(op.f("ix_repair_estimates_repair_id"), "repair_estimates", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_estimates_status"), "repair_estimates", ["status"], unique=False)
op.create_table(
"repair_estimate_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("estimate_id", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("item_type", sa.String(length=40), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("quantity", sa.Numeric(10, 2), server_default="1.00", nullable=False),
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
sa.Column("unit_price_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_estimate_items_estimate_id"), "repair_estimate_items", ["estimate_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_items_item_type"), "repair_estimate_items", ["item_type"], unique=False)
op.create_table(
"repair_estimate_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("estimate_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=40), nullable=False),
sa.Column("actor_type", sa.String(length=40), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_estimate_events_actor_type"), "repair_estimate_events", ["actor_type"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_actor_user_id"), "repair_estimate_events", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_estimate_id"), "repair_estimate_events", ["estimate_id"], unique=False)
op.create_index(op.f("ix_repair_estimate_events_event_type"), "repair_estimate_events", ["event_type"], unique=False)
for name, display_name, description, module in ESTIMATE_PERMISSIONS:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (:name, :display_name, :description, :module)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
role_permissions = {
"administrator": [item[0] for item in ESTIMATE_PERMISSIONS],
"management": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
"technician": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
"support": ["repair_estimates.read", "repair_estimates.send"],
}
for role_name, permission_names in role_permissions.items():
for permission_name in permission_names:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'repair_estimates'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'repair_estimates'")
op.drop_index(op.f("ix_repair_estimate_events_event_type"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_estimate_id"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_actor_user_id"), table_name="repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_events_actor_type"), table_name="repair_estimate_events")
op.drop_table("repair_estimate_events")
op.drop_index(op.f("ix_repair_estimate_items_item_type"), table_name="repair_estimate_items")
op.drop_index(op.f("ix_repair_estimate_items_estimate_id"), table_name="repair_estimate_items")
op.drop_table("repair_estimate_items")
op.drop_index(op.f("ix_repair_estimates_status"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_repair_id"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_estimate_number"), table_name="repair_estimates")
op.drop_index(op.f("ix_repair_estimates_created_by_user_id"), table_name="repair_estimates")
op.drop_table("repair_estimates")

View file

@ -0,0 +1,219 @@
"""create knowledge module
Revision ID: b6f3d8c1a920
Revises: a5e4b7c9d012
Create Date: 2026-07-03 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b6f3d8c1a920"
down_revision: Union[str, Sequence[str], None] = "a5e4b7c9d012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
KNOWLEDGE_PERMISSIONS = [
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
]
ROLE_PERMISSIONS = {
"administrator": [permission[0] for permission in KNOWLEDGE_PERMISSIONS],
"management": ["knowledge.read", "knowledge.download"],
"technician": ["knowledge.read", "knowledge.create", "knowledge.update", "knowledge.upload", "knowledge.download"],
"support": ["knowledge.read", "knowledge.download"],
}
def insert_permission(name: str, display_name: str, description: str, module: str) -> None:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
SELECT :name, :display_name, :description, :module
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = :name)
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
def grant_permission(role_name: str, permission_name: str) -> None:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name = :permission_name
WHERE roles.name = :role_name
AND NOT EXISTS (
SELECT 1 FROM role_permissions
WHERE role_permissions.role_id = roles.id
AND role_permissions.permission_id = permissions.id
)
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def upgrade() -> None:
op.create_table(
"knowledge_manufacturers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=180), nullable=False),
sa.Column("website", sa.String(length=255), server_default="", nullable=False),
sa.Column("notes", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_manufacturers_name"), "knowledge_manufacturers", ["name"], unique=True)
op.create_index(op.f("ix_knowledge_manufacturers_slug"), "knowledge_manufacturers", ["slug"], unique=True)
op.create_table(
"knowledge_categories",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("slug", sa.String(length=140), nullable=False),
sa.Column("description", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_categories_name"), "knowledge_categories", ["name"], unique=True)
op.create_index(op.f("ix_knowledge_categories_slug"), "knowledge_categories", ["slug"], unique=True)
op.create_table(
"knowledge_devices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=180), nullable=False),
sa.Column("slug", sa.String(length=200), nullable=False),
sa.Column("model_number", sa.String(length=120), server_default="", nullable=False),
sa.Column("device_type", sa.String(length=80), server_default="", nullable=False),
sa.Column("frequency_range", sa.String(length=120), server_default="", nullable=False),
sa.Column("production_year_from", sa.Integer(), nullable=True),
sa.Column("production_year_to", sa.Integer(), nullable=True),
sa.Column("notes", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
)
op.create_index(op.f("ix_knowledge_devices_manufacturer_id"), "knowledge_devices", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_devices_name"), "knowledge_devices", ["name"], unique=False)
op.create_index(op.f("ix_knowledge_devices_slug"), "knowledge_devices", ["slug"], unique=False)
op.create_index(op.f("ix_knowledge_devices_model_number"), "knowledge_devices", ["model_number"], unique=False)
op.create_index(op.f("ix_knowledge_devices_device_type"), "knowledge_devices", ["device_type"], unique=False)
op.create_table(
"knowledge_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("device_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=280), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False),
sa.Column("language", sa.String(length=20), server_default="de", nullable=False),
sa.Column("file_name", sa.String(length=255), server_default="", nullable=False),
sa.Column("file_path", sa.String(length=500), server_default="", nullable=False),
sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False),
sa.Column("file_size", sa.Integer(), server_default="0", nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False),
sa.Column("external_url", sa.String(length=500), server_default="", nullable=False),
sa.Column("paperless_document_id", sa.String(length=120), server_default="", nullable=False),
sa.Column("description", sa.Text(), server_default="", nullable=False),
sa.Column("tags", sa.JSON(), server_default="[]", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_documents_manufacturer_id"), "knowledge_documents", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_documents_device_id"), "knowledge_documents", ["device_id"], unique=False)
op.create_index(op.f("ix_knowledge_documents_title"), "knowledge_documents", ["title"], unique=False)
op.create_index(op.f("ix_knowledge_documents_slug"), "knowledge_documents", ["slug"], unique=True)
op.create_index(op.f("ix_knowledge_documents_document_type"), "knowledge_documents", ["document_type"], unique=False)
op.create_index(op.f("ix_knowledge_documents_language"), "knowledge_documents", ["language"], unique=False)
op.create_index(op.f("ix_knowledge_documents_checksum_sha256"), "knowledge_documents", ["checksum_sha256"], unique=False)
op.create_index(op.f("ix_knowledge_documents_paperless_document_id"), "knowledge_documents", ["paperless_document_id"], unique=False)
op.create_table(
"knowledge_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=True),
sa.Column("device_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("note_type", sa.String(length=80), nullable=False),
sa.Column("severity", sa.String(length=40), server_default="", nullable=False),
sa.Column("tags", sa.JSON(), server_default="[]", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_notes_manufacturer_id"), "knowledge_notes", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_notes_device_id"), "knowledge_notes", ["device_id"], unique=False)
op.create_index(op.f("ix_knowledge_notes_title"), "knowledge_notes", ["title"], unique=False)
op.create_index(op.f("ix_knowledge_notes_note_type"), "knowledge_notes", ["note_type"], unique=False)
op.create_index(op.f("ix_knowledge_notes_severity"), "knowledge_notes", ["severity"], unique=False)
for permission in KNOWLEDGE_PERMISSIONS:
insert_permission(*permission)
for role_name, permission_names in ROLE_PERMISSIONS.items():
for permission_name in permission_names:
grant_permission(role_name, permission_name)
def downgrade() -> None:
op.execute(
"""
DELETE FROM role_permissions
USING permissions
WHERE role_permissions.permission_id = permissions.id
AND permissions.module = 'knowledge'
"""
)
op.execute("DELETE FROM permissions WHERE module = 'knowledge'")
op.drop_index(op.f("ix_knowledge_notes_severity"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_note_type"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_title"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_device_id"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_manufacturer_id"), table_name="knowledge_notes")
op.drop_table("knowledge_notes")
op.drop_index(op.f("ix_knowledge_documents_paperless_document_id"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_checksum_sha256"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_language"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_document_type"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_slug"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_title"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_device_id"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_manufacturer_id"), table_name="knowledge_documents")
op.drop_table("knowledge_documents")
op.drop_index(op.f("ix_knowledge_devices_device_type"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_model_number"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_slug"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_name"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_manufacturer_id"), table_name="knowledge_devices")
op.drop_table("knowledge_devices")
op.drop_index(op.f("ix_knowledge_categories_slug"), table_name="knowledge_categories")
op.drop_index(op.f("ix_knowledge_categories_name"), table_name="knowledge_categories")
op.drop_table("knowledge_categories")
op.drop_index(op.f("ix_knowledge_manufacturers_slug"), table_name="knowledge_manufacturers")
op.drop_index(op.f("ix_knowledge_manufacturers_name"), table_name="knowledge_manufacturers")
op.drop_table("knowledge_manufacturers")

View file

@ -0,0 +1,204 @@
"""create repairs module
Revision ID: c8e2f1a9b704
Revises: b6f3d8c1a920
Create Date: 2026-07-04 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c8e2f1a9b704"
down_revision: Union[str, Sequence[str], None] = "b6f3d8c1a920"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_PERMISSIONS = [
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
]
ROLE_PERMISSIONS = {
"administrator": [permission[0] for permission in REPAIR_PERMISSIONS],
"management": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update"],
"technician": ["repairs.read", "repairs.update", "repairs.status.update"],
"support": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update", "repairs.intake"],
}
def upgrade() -> None:
op.create_table(
"repairs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_number", sa.String(length=20), nullable=False),
sa.Column("customer_id", sa.Integer(), nullable=True),
sa.Column("contact_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("priority", sa.String(length=30), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("source_reference", sa.String(length=120), nullable=True),
sa.Column("customer_name", sa.String(length=255), nullable=False),
sa.Column("customer_email", sa.String(length=255), server_default="", nullable=False),
sa.Column("customer_phone", sa.String(length=80), server_default="", nullable=False),
sa.Column("device_manufacturer", sa.String(length=160), nullable=False),
sa.Column("device_model", sa.String(length=160), nullable=False),
sa.Column("device_serial_number", sa.String(length=160), nullable=True),
sa.Column("device_type", sa.String(length=120), server_default="", nullable=False),
sa.Column("accessories", sa.Text(), server_default="", nullable=False),
sa.Column("fault_description", sa.Text(), nullable=False),
sa.Column("previous_work", sa.Text(), server_default="", nullable=False),
sa.Column("device_opened", sa.Boolean(), server_default="false", nullable=False),
sa.Column("intake_notes", sa.Text(), server_default="", nullable=False),
sa.Column("diagnosis_notes", sa.Text(), server_default="", nullable=False),
sa.Column("repair_notes", sa.Text(), server_default="", nullable=False),
sa.Column("estimate_notes", sa.Text(), server_default="", nullable=False),
sa.Column("estimated_cost_cents", sa.Integer(), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint("status in ('new','accepted','diagnosis','estimate','waiting_for_customer','approved','repair','final_test','ready_for_pickup','shipped','completed','cancelled')", name="ck_repairs_status"),
sa.CheckConstraint("priority in ('low','normal','high','urgent')", name="ck_repairs_priority"),
sa.CheckConstraint("source in ('manual','website','customer_portal','email','phone')", name="ck_repairs_source"),
sa.ForeignKeyConstraint(["contact_id"], ["customer_contacts.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("repair_number"),
)
op.create_index(op.f("ix_repairs_contact_id"), "repairs", ["contact_id"], unique=False)
op.create_index(op.f("ix_repairs_customer_id"), "repairs", ["customer_id"], unique=False)
op.create_index(op.f("ix_repairs_customer_name"), "repairs", ["customer_name"], unique=False)
op.create_index(op.f("ix_repairs_device_manufacturer"), "repairs", ["device_manufacturer"], unique=False)
op.create_index(op.f("ix_repairs_device_model"), "repairs", ["device_model"], unique=False)
op.create_index(op.f("ix_repairs_priority"), "repairs", ["priority"], unique=False)
op.create_index(op.f("ix_repairs_repair_number"), "repairs", ["repair_number"], unique=True)
op.create_index(op.f("ix_repairs_source"), "repairs", ["source"], unique=False)
op.create_index(op.f("ix_repairs_source_reference"), "repairs", ["source_reference"], unique=False)
op.create_index(op.f("ix_repairs_status"), "repairs", ["status"], unique=False)
op.create_table(
"repair_status_history",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("old_status", sa.String(length=50), nullable=True),
sa.Column("new_status", sa.String(length=50), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_status_history_actor_user_id"), "repair_status_history", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_repair_status_history_new_status"), "repair_status_history", ["new_status"], unique=False)
op.create_index(op.f("ix_repair_status_history_repair_id"), "repair_status_history", ["repair_id"], unique=False)
op.create_table(
"repair_intake_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=True),
sa.Column("external_source", sa.String(length=80), nullable=False),
sa.Column("external_reference", sa.String(length=160), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_intake_events_external_reference"), "repair_intake_events", ["external_reference"], unique=False)
op.create_index(op.f("ix_repair_intake_events_external_source"), "repair_intake_events", ["external_source"], unique=False)
op.create_index(op.f("ix_repair_intake_events_repair_id"), "repair_intake_events", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_intake_events_status"), "repair_intake_events", ["status"], unique=False)
op.create_table(
"repair_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=True),
sa.Column("storage_path", sa.String(length=500), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_documents_document_type"), "repair_documents", ["document_type"], unique=False)
op.create_index(op.f("ix_repair_documents_repair_id"), "repair_documents", ["repair_id"], unique=False)
for name, display_name, description, module in REPAIR_PERMISSIONS:
op.execute(
sa.text(
"""
insert into permissions (name, display_name, description, module)
values (:name, :display_name, :description, :module)
on conflict (name) do update set
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
for role_name, permissions in ROLE_PERMISSIONS.items():
for permission_name in permissions:
op.execute(
sa.text(
"""
insert into role_permissions (role_id, permission_id)
select roles.id, permissions.id
from roles, permissions
where roles.name = :role_name and permissions.name = :permission_name
on conflict do nothing
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
for permission_name, *_ in REPAIR_PERMISSIONS:
op.execute(
sa.text(
"""
delete from role_permissions
using permissions
where role_permissions.permission_id = permissions.id
and permissions.name = :permission_name
"""
).bindparams(permission_name=permission_name)
)
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=permission_name))
op.drop_index(op.f("ix_repair_documents_repair_id"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_document_type"), table_name="repair_documents")
op.drop_table("repair_documents")
op.drop_index(op.f("ix_repair_intake_events_status"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_repair_id"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_external_source"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_external_reference"), table_name="repair_intake_events")
op.drop_table("repair_intake_events")
op.drop_index(op.f("ix_repair_status_history_repair_id"), table_name="repair_status_history")
op.drop_index(op.f("ix_repair_status_history_new_status"), table_name="repair_status_history")
op.drop_index(op.f("ix_repair_status_history_actor_user_id"), table_name="repair_status_history")
op.drop_table("repair_status_history")
op.drop_index(op.f("ix_repairs_status"), table_name="repairs")
op.drop_index(op.f("ix_repairs_source_reference"), table_name="repairs")
op.drop_index(op.f("ix_repairs_source"), table_name="repairs")
op.drop_index(op.f("ix_repairs_repair_number"), table_name="repairs")
op.drop_index(op.f("ix_repairs_priority"), table_name="repairs")
op.drop_index(op.f("ix_repairs_device_model"), table_name="repairs")
op.drop_index(op.f("ix_repairs_device_manufacturer"), table_name="repairs")
op.drop_index(op.f("ix_repairs_customer_name"), table_name="repairs")
op.drop_index(op.f("ix_repairs_customer_id"), table_name="repairs")
op.drop_index(op.f("ix_repairs_contact_id"), table_name="repairs")
op.drop_table("repairs")

View file

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

View file

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

View file

@ -0,0 +1,126 @@
"""add repair public links and notifications
Revision ID: d4f6a2b8c901
Revises: c8e2f1a9b704
Create Date: 2026-07-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d4f6a2b8c901"
down_revision: Union[str, Sequence[str], None] = "c8e2f1a9b704"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PUBLIC_LINK_PERMISSION = (
"repairs.public_link.manage",
"Reparatur-Statuslinks verwalten",
"Öffentliche Statuslinks für Reparaturen verwalten",
"repairs",
)
ROLE_PERMISSIONS = {
"administrator": ["repairs.public_link.manage"],
"management": ["repairs.public_link.manage"],
"support": ["repairs.public_link.manage"],
}
def upgrade() -> None:
op.create_table(
"repair_public_access_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("token_hint", sa.String(length=16), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_repair_public_access_tokens_is_active"), "repair_public_access_tokens", ["is_active"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_repair_id"), "repair_public_access_tokens", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_token_hash"), "repair_public_access_tokens", ["token_hash"], unique=True)
op.create_table(
"repair_notification_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=255), server_default="", nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status in ('pending','sent','failed','skipped')", name="ck_repair_notification_events_status"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_notification_events_channel"), "repair_notification_events", ["channel"], unique=False)
op.create_index(op.f("ix_repair_notification_events_event_type"), "repair_notification_events", ["event_type"], unique=False)
op.create_index(op.f("ix_repair_notification_events_repair_id"), "repair_notification_events", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_notification_events_status"), "repair_notification_events", ["status"], unique=False)
name, display_name, description, module = PUBLIC_LINK_PERMISSION
op.execute(
sa.text(
"""
insert into permissions (name, display_name, description, module)
values (:name, :display_name, :description, :module)
on conflict (name) do update set
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
for role_name, permissions in ROLE_PERMISSIONS.items():
for permission_name in permissions:
op.execute(
sa.text(
"""
insert into role_permissions (role_id, permission_id)
select roles.id, permissions.id
from roles, permissions
where roles.name = :role_name and permissions.name = :permission_name
on conflict do nothing
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
delete from role_permissions
using permissions
where role_permissions.permission_id = permissions.id
and permissions.name = :permission_name
"""
).bindparams(permission_name=PUBLIC_LINK_PERMISSION[0])
)
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=PUBLIC_LINK_PERMISSION[0]))
op.drop_index(op.f("ix_repair_notification_events_status"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_repair_id"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_event_type"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_channel"), table_name="repair_notification_events")
op.drop_table("repair_notification_events")
op.drop_index(op.f("ix_repair_public_access_tokens_token_hash"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_repair_id"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_is_active"), table_name="repair_public_access_tokens")
op.drop_table("repair_public_access_tokens")

View file

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

View file

@ -0,0 +1,214 @@
"""create inventory
Revision ID: ef8a2d5c9017
Revises: b4e8f1c7a205
Create Date: 2026-07-05 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "ef8a2d5c9017"
down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INVENTORY_PERMISSIONS = [
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
]
def upgrade() -> None:
op.create_table(
"inventory_categories",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=180), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("slug"),
)
op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
op.create_table(
"inventory_locations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
op.create_table(
"inventory_suppliers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=180), nullable=False),
sa.Column("contact_name", sa.String(length=180), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("website", sa.String(length=255), nullable=True),
sa.Column("customer_number", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
op.create_table(
"inventory_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("sku", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("manufacturer", sa.String(length=180), nullable=True),
sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
sa.Column("compatible_devices", sa.Text(), nullable=True),
sa.Column("location_id", sa.Integer(), nullable=True),
sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
sa.Column("selling_price_cents", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
)
op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
op.create_table(
"inventory_stock_movements",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("item_id", sa.Integer(), nullable=False),
sa.Column("movement_type", sa.String(length=40), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
sa.Column("reference_type", sa.String(length=80), nullable=True),
sa.Column("reference_id", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
for name, display_name, description, module in INVENTORY_PERMISSIONS:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (:name, :display_name, :description, :module)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
role_permissions = {
"administrator": [item[0] for item in INVENTORY_PERMISSIONS],
"management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
"technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
"support": ["inventory.read"],
"warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
}
for role_name, permission_names in role_permissions.items():
for permission_name in permission_names:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'inventory'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
op.drop_table("inventory_stock_movements")
op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
op.drop_table("inventory_items")
op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
op.drop_table("inventory_suppliers")
op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
op.drop_table("inventory_locations")
op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
op.drop_table("inventory_categories")

View file

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

View file

@ -0,0 +1,44 @@
"""extend repair estimate items inventory
Revision ID: f4a9c2d7e118
Revises: ef8a2d5c9017
Create Date: 2026-07-05 13:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "f4a9c2d7e118"
down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
op.create_foreign_key(
"fk_repair_estimate_items_inventory_item_id",
"repair_estimate_items",
"inventory_items",
["inventory_item_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
op.drop_column("repair_estimate_items", "inventory_snapshot_name")
op.drop_column("repair_estimate_items", "inventory_item_id")

View file

@ -54,6 +54,20 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "customers.read" in permissions return "customers.read" in permissions
if action.startswith("roles."): if action.startswith("roles."):
return "roles.read" in permissions return "roles.read" in permissions
if action.startswith("knowledge."):
return "knowledge.read" in permissions
if action.startswith("repairs."):
return "repairs.read" in permissions
if action.startswith("repair_estimates."):
return "repair_estimates.read" in permissions
if action.startswith("inventory."):
return "inventory.read" in permissions
if action.startswith("lexware."):
return "lexware.read" in permissions
if action.startswith("accounting."):
return "lexware.read" in permissions
if action.startswith("backups."):
return "backup.read" in permissions
if action.startswith("audit_logs."): if action.startswith("audit_logs."):
return "audit_logs.read" in permissions return "audit_logs.read" in permissions
if action.startswith("auth."): if action.startswith("auth."):

View file

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

View file

@ -4,13 +4,21 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.rbac import get_user_permission_names, require_permission from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db from app.db.database import get_db
from app.models.rbac import Role 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.models.user import User
from app.repositories.customer_repository import CustomerRepository 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.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
from app.services.backup_service import BackupService
from app.services.system_settings_service import SystemSettingsService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,6 +38,9 @@ def get_dashboard_summary(
latest_customers = [] latest_customers = []
users: list[MetricCard] = [] users: list[MetricCard] = []
roles: list[MetricCard] = [] roles: list[MetricCard] = []
repairs: list[MetricCard] = []
inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
if "customers.read" in permissions: if "customers.read" in permissions:
customers = [ customers = [
@ -54,6 +65,126 @@ def get_dashboard_summary(
) )
] ]
if "repairs.read" in permissions:
repairs = [
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
MetricCard(label="Wartet auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
]
if "repair_estimates.read" in permissions:
repairs.extend([
MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)),
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)),
MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
])
if "lexware.read" in permissions:
repairs.extend([
MetricCard(
label="Vorbereitete Rechnungen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
MetricCard(
label="An Buchhaltung übergeben",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "transferred")
) or 0,
),
MetricCard(
label="Noch nicht übertragen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
])
if "inventory.read" in permissions:
inventory = [
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)),
MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)),
MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)),
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
]
if "system_settings.manage" in permissions:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
public_links = SystemSettingsService.get_public_links_settings(db)
latest_testmail = db.scalar(
select(AuditLog)
.where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"]))
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
.limit(1)
)
latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db)
system_status = [
SystemStatusItem(
label="SMTP-Konfiguration",
value={
"database": "Admin-Konfiguration",
"environment": "Umgebung",
"missing": "Nicht konfiguriert",
}[smtp_config.source],
status="ok" if smtp_config.is_configured else "warning",
),
SystemStatusItem(
label="Public Status Base URL",
value=public_links.repair_status_base_url or "Nicht konfiguriert",
status="ok" if public_links.repair_status_base_url else "warning",
),
SystemStatusItem(
label="Letzte SMTP-Testmail",
value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail",
status="info" if latest_testmail else "warning",
),
SystemStatusItem(
label="Letzte fehlgeschlagene Statusmail",
value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler",
status="warning" if latest_failed_status_mail else "ok",
),
]
if "backup.read" in permissions:
backup_stats = BackupService.get_backup_stats()
repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
system_status.extend([
SystemStatusItem(
label="Letztes Backup",
value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
status="ok" if backup_stats.latest_backup_at else "warning",
),
SystemStatusItem(
label="Backup-Speicher",
value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
status="ok" if backup_stats.total_count else "warning",
),
SystemStatusItem(
label="Hermes-Version",
value=settings.app_version,
status="info",
),
])
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
return DashboardSummary( return DashboardSummary(
@ -61,6 +192,9 @@ def get_dashboard_summary(
latest_customers=latest_customers, latest_customers=latest_customers,
users=users, users=users,
roles=roles, roles=roles,
repairs=repairs,
inventory=inventory,
system_status=system_status,
activities=EmptyWidget( activities=EmptyWidget(
title="Letzte Aktivitäten", title="Letzte Aktivitäten",
message="Noch keine Aktivitäten vorhanden.", message="Noch keine Aktivitäten vorhanden.",

View file

@ -0,0 +1,319 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.schemas.inventory import (
InventoryCategoryPayload,
InventoryCategoryResponse,
InventoryItemCreate,
InventoryItemListResponse,
InventoryItemResponse,
InventoryItemUpdate,
InventoryLocationPayload,
InventoryLocationResponse,
InventoryStockAction,
InventoryStockMovementResponse,
InventorySupplierPayload,
InventorySupplierResponse,
)
from app.services.inventory_service import InventoryService
router = APIRouter(prefix="/inventory", tags=["Inventory"])
def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
item = InventoryRepository.get_item(db, item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
return item
def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
category = InventoryRepository.get_category(db, category_id)
if category is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
return category
def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
location = InventoryRepository.get_location(db, location_id)
if location is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
return location
def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
supplier = InventoryRepository.get_supplier(db, supplier_id)
if supplier is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
return supplier
@router.get("/items", response_model=InventoryItemListResponse)
def list_items(
q: str | None = None,
category_id: int | None = None,
supplier_id: int | None = None,
location_id: int | None = None,
low_stock: bool | None = None,
active: bool | None = True,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
items, total = InventoryRepository.list_items(
db,
q=q,
category_id=category_id,
supplier_id=supplier_id,
location_id=location_id,
low_stock=low_stock,
active=active,
limit=limit,
offset=offset,
)
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/items/search", response_model=list[InventoryItemResponse])
def search_items(
q: str | None = None,
category: int | None = None,
manufacturer: str | None = None,
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(
payload: InventoryItemCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.create")),
):
return InventoryService.create_item(db, payload, actor=current_user, request=request)
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
def get_item(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return get_item_or_404(db, item_id)
@router.put("/items/{item_id}", response_model=InventoryItemResponse)
def update_item(
item_id: int,
payload: InventoryItemUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.update")),
):
item = get_item_or_404(db, item_id)
return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
def delete_item(
item_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.delete")),
):
item = get_item_or_404(db, item_id)
return InventoryService.delete_item(db, item, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
def adjust_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.adjust")),
):
item = get_item_or_404(db, item_id)
return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
def reserve_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.reserve")),
):
item = get_item_or_404(db, item_id)
return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
def release_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.reserve")),
):
item = get_item_or_404(db, item_id)
return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
def consume_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.consume")),
):
item = get_item_or_404(db, item_id)
return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
def list_movements(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
get_item_or_404(db, item_id)
return InventoryRepository.list_movements(db, item_id)
@router.get("/categories", response_model=list[InventoryCategoryResponse])
def list_categories(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_categories(db)
@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
payload: InventoryCategoryPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_category(db, payload, actor=current_user, request=request)
@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
def update_category(
category_id: int,
payload: InventoryCategoryPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
category = get_category_or_404(db, category_id)
return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(
category_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
category = get_category_or_404(db, category_id)
InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
return None
@router.get("/locations", response_model=list[InventoryLocationResponse])
def list_locations(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_locations(db)
@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
def create_location(
payload: InventoryLocationPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_location(db, payload, actor=current_user, request=request)
@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
def update_location(
location_id: int,
payload: InventoryLocationPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
location = get_location_or_404(db, location_id)
return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(
location_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
location = get_location_or_404(db, location_id)
InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
return None
@router.get("/suppliers", response_model=list[InventorySupplierResponse])
def list_suppliers(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_suppliers(db)
@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
def create_supplier(
payload: InventorySupplierPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
def update_supplier(
supplier_id: int,
payload: InventorySupplierPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
supplier = get_supplier_or_404(db, supplier_id)
return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_supplier(
supplier_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
supplier = get_supplier_or_404(db, supplier_id)
InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
return None

View file

@ -0,0 +1,334 @@
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
DocumentType,
KnowledgeDeviceCreate,
KnowledgeDeviceResponse,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentResponse,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerResponse,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteResponse,
KnowledgeNoteUpdate,
KnowledgeSearchResult,
NoteType,
normalize_tags,
)
from app.services.audit_service import sanitize, write_audit_log
from app.services.knowledge_service import KnowledgeService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/knowledge", tags=["Knowledge"])
def get_manufacturer_or_404(db: Session, manufacturer_id: int):
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
if manufacturer is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
return manufacturer
def get_device_or_404(db: Session, device_id: int):
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
return device
def get_document_or_404(db: Session, document_id: int):
document = KnowledgeRepository.get_document(db, document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
def get_note_or_404(db: Session, note_id: int):
note = KnowledgeRepository.get_note(db, note_id)
if note is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden")
return note
def delete_or_conflict(db: Session, entity) -> None:
try:
db.delete(entity)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag wird noch verwendet") from exc
@router.get("/manufacturers", response_model=list[KnowledgeManufacturerResponse])
def list_manufacturers(
q: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.manufacturers.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_manufacturers(db, q=q, limit=limit, offset=offset)
@router.get("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def get_manufacturer(manufacturer_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.manufacturers.detail", extra={"actor_user_id": current_user.id, "target_manufacturer_id": manufacturer_id})
return get_manufacturer_or_404(db, manufacturer_id)
@router.post("/manufacturers", response_model=KnowledgeManufacturerResponse, status_code=status.HTTP_201_CREATED)
def create_manufacturer(
payload: KnowledgeManufacturerCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.create")),
):
manufacturer = KnowledgeService.create_manufacturer(db, payload)
write_audit_log(db, action="knowledge.manufacturers.create", entity_type="knowledge_manufacturers", entity_id=manufacturer.id, entity_label=manufacturer.name, actor=current_user, request=request, after_data=manufacturer)
return manufacturer
@router.put("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def update_manufacturer(
manufacturer_id: int,
payload: KnowledgeManufacturerUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.update")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
updated = KnowledgeService.update_manufacturer(db, manufacturer, payload)
write_audit_log(db, action="knowledge.manufacturers.update", entity_type="knowledge_manufacturers", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/manufacturers/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_manufacturer(
manufacturer_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.delete")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
label = manufacturer.name
delete_or_conflict(db, manufacturer)
write_audit_log(db, action="knowledge.manufacturers.delete", entity_type="knowledge_manufacturers", entity_id=manufacturer_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/devices", response_model=list[KnowledgeDeviceResponse])
def list_devices(
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.devices.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_devices(db, q=q, manufacturer_id=manufacturer_id, device_type=device_type, limit=limit, offset=offset)
@router.get("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.devices.detail", extra={"actor_user_id": current_user.id, "target_device_id": device_id})
return get_device_or_404(db, device_id)
@router.post("/devices", response_model=KnowledgeDeviceResponse, status_code=status.HTTP_201_CREATED)
def create_device(payload: KnowledgeDeviceCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
device = KnowledgeService.create_device(db, payload)
write_audit_log(db, action="knowledge.devices.create", entity_type="knowledge_devices", entity_id=device.id, entity_label=device.name, actor=current_user, request=request, after_data=device)
return device
@router.put("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def update_device(device_id: int, payload: KnowledgeDeviceUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
updated = KnowledgeService.update_device(db, device, payload)
write_audit_log(db, action="knowledge.devices.update", entity_type="knowledge_devices", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_device(device_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
label = device.name
delete_or_conflict(db, device)
write_audit_log(db, action="knowledge.devices.delete", entity_type="knowledge_devices", entity_id=device_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents", response_model=list[KnowledgeDocumentResponse])
def list_documents(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.documents.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_documents(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, document_type=document_type, language=language, tag=tag, limit=limit, offset=offset)
@router.get("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def get_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.documents.detail", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return get_document_or_404(db, document_id)
@router.post("/documents", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
def create_document(payload: KnowledgeDocumentCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
document = KnowledgeService.create_document(db, payload)
write_audit_log(db, action="knowledge.documents.create", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data=document)
return document
@router.post("/documents/upload", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
request: Request,
manufacturer_id: int = Form(...),
title: str = Form(...),
document_type: DocumentType = Form(...),
file: UploadFile = File(...),
device_id: int | None = Form(default=None),
language: str = Form(default="de"),
external_url: str = Form(default=""),
paperless_document_id: str = Form(default=""),
description: str = Form(default=""),
tags: str = Form(default=""),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.upload")),
):
payload = KnowledgeDocumentCreate(
manufacturer_id=manufacturer_id,
device_id=device_id,
title=title,
document_type=document_type,
language=language,
external_url=external_url or None,
paperless_document_id=paperless_document_id,
description=description,
tags=normalize_tags(tags),
)
document = await KnowledgeService.upload_document(db, file=file, payload=payload)
write_audit_log(db, action="knowledge.documents.upload", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data={key: value for key, value in sanitize(document).items() if key != "file_path"})
return document
@router.put("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def update_document(document_id: int, payload: KnowledgeDocumentUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
updated = KnowledgeService.update_document(db, document, payload)
write_audit_log(db, action="knowledge.documents.update", entity_type="knowledge_documents", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/documents/{document_id:int}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(document_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
label = document.title
file_path = document.file_path
delete_or_conflict(db, document)
if file_path:
KnowledgeService.delete_storage_key(file_path)
write_audit_log(db, action="knowledge.documents.delete", entity_type="knowledge_documents", entity_id=document_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents/{document_id:int}/download")
def download_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.download"))):
document = get_document_or_404(db, document_id)
path = KnowledgeService.open_document_file(document)
logger.info("knowledge.documents.download", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return FileResponse(path, media_type=document.mime_type or "application/octet-stream", filename=document.file_name)
@router.get("/notes", response_model=list[KnowledgeNoteResponse])
def list_notes(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.notes.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_notes(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, note_type=note_type, tag=tag, limit=limit, offset=offset)
@router.get("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def get_note(note_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.notes.detail", extra={"actor_user_id": current_user.id, "target_note_id": note_id})
return get_note_or_404(db, note_id)
@router.post("/notes", response_model=KnowledgeNoteResponse, status_code=status.HTTP_201_CREATED)
def create_note(payload: KnowledgeNoteCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
note = KnowledgeService.create_note(db, payload)
write_audit_log(db, action="knowledge.notes.create", entity_type="knowledge_notes", entity_id=note.id, entity_label=note.title, actor=current_user, request=request, after_data=note)
return note
@router.put("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def update_note(note_id: int, payload: KnowledgeNoteUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
updated = KnowledgeService.update_note(db, note, payload)
write_audit_log(db, action="knowledge.notes.update", entity_type="knowledge_notes", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
label = note.title
delete_or_conflict(db, note)
write_audit_log(db, action="knowledge.notes.delete", entity_type="knowledge_notes", entity_id=note_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/search", response_model=list[KnowledgeSearchResult])
def search_knowledge(q: str = Query(..., min_length=1), db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.search", extra={"actor_user_id": current_user.id})
manufacturers = KnowledgeRepository.list_manufacturers(db, q=q, limit=10, offset=0)
devices = KnowledgeRepository.list_devices(db, q=q, limit=10, offset=0)
documents = KnowledgeRepository.list_documents(db, q=q, limit=10, offset=0)
notes = KnowledgeRepository.list_notes(db, q=q, limit=10, offset=0)
results: list[KnowledgeSearchResult] = []
results.extend(KnowledgeSearchResult(entity_type="manufacturer", id=item.id, title=item.name, href=f"/knowledge/manufacturers?selected={item.id}") for item in manufacturers)
results.extend(KnowledgeSearchResult(entity_type="device", id=item.id, title=item.name, subtitle=item.model_number, href=f"/knowledge/devices/{item.id}") for item in devices)
results.extend(KnowledgeSearchResult(entity_type="document", id=item.id, title=item.title, subtitle=item.document_type, href=f"/knowledge/documents/{item.id}") for item in documents)
results.extend(KnowledgeSearchResult(entity_type="note", id=item.id, title=item.title, subtitle=item.note_type, href=f"/knowledge/notes?selected={item.id}") for item in notes)
return results[:30]

View file

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

View file

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

View file

@ -0,0 +1,327 @@
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from typing import cast
from app.core.config import settings
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import (
RepairCreate,
RepairDocumentResponse,
RepairDocumentType,
RepairDocumentUpdate,
RepairDocumentVisibility,
RepairIntakePayload,
RepairIntakeResponse,
RepairNotificationEventResponse,
RepairListResponse,
RepairNotificationOverviewResponse,
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
RepairPublicStatusResponse,
RepairResponse,
RepairStatus,
RepairStatusHistoryResponse,
RepairStatusUpdate,
RepairUpdate,
)
from app.services.repair_notification_service import RepairNotificationService
from app.services.repair_document_service import RepairDocumentService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_service import RepairService
router = APIRouter(tags=["Repairs"])
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
repair = RepairRepository.get_by_id(db, repair_id)
if repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
return repair
@router.get("/repairs", response_model=RepairListResponse)
@router.get("/repairs/", response_model=RepairListResponse, include_in_schema=False)
def get_repairs(
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
items, total = RepairRepository.list(
db,
status=status_filter,
q=q,
customer_id=customer_id,
source=source,
priority=priority,
limit=limit,
offset=offset,
)
return RepairListResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/repairs/{repair_id}", response_model=RepairResponse)
def get_repair(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
return get_repair_or_404(db, repair_id)
@router.post("/repairs", response_model=RepairResponse, status_code=status.HTTP_201_CREATED)
@router.post("/repairs/", response_model=RepairResponse, status_code=status.HTTP_201_CREATED, include_in_schema=False)
def create_repair(
repair: RepairCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.create")),
):
return RepairService.create(db, repair, actor=current_user, request=request)
@router.put("/repairs/{repair_id}", response_model=RepairResponse)
def update_repair(
repair_id: int,
repair: RepairUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.update(db, db_repair, repair, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}", response_model=RepairResponse)
def delete_repair(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.delete")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.cancel(db, db_repair, actor=current_user, request=request)
@router.put("/repairs/{repair_id}/status", response_model=RepairResponse)
def update_repair_status(
repair_id: int,
payload: RepairStatusUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.status.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.update_status(db, db_repair, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/history", response_model=list[RepairStatusHistoryResponse])
def get_repair_history(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
return RepairRepository.get_history(db, repair_id)
@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse])
def list_repair_documents(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
return RepairRepository.list_documents(db, repair_id)
@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_repair_document(
repair_id: int,
request: Request,
file: UploadFile = File(...),
title: str = Form(..., min_length=1, max_length=255),
document_type: RepairDocumentType = Form("other"),
visibility: RepairDocumentVisibility = Form("internal"),
note: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
normalized_title = title.strip()
normalized_note = note.strip() if note else None
if not normalized_title:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich")
return await RepairDocumentService.upload_document(
db,
db_repair,
file=file,
title=normalized_title,
document_type=document_type,
visibility=visibility,
note=normalized_note,
actor=current_user,
request=request,
)
@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def get_repair_document(
repair_id: int,
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def update_repair_document(
repair_id: int,
document_id: int,
payload: RepairDocumentUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/documents/{document_id}/download")
def download_repair_document(
repair_id: int,
document_id: int,
disposition: str = Query(default="inline", pattern="^(inline|attachment)$"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
file_path = RepairDocumentService.open_document_file(document)
return FileResponse(
file_path,
media_type=document.mime_type or "application/octet-stream",
headers={
"Content-Disposition": RepairDocumentService.content_disposition(
document,
mode="attachment" if disposition == "attachment" else "inline",
),
},
)
@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_repair_document(
repair_id: int,
document_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request)
return None
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def get_public_link(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.get(db, db_repair)
@router.post("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkCreateResponse, status_code=status.HTTP_201_CREATED)
def create_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.create(db, db_repair, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def revoke_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.revoke(db, db_repair, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/notifications", response_model=RepairNotificationOverviewResponse)
def get_repair_notifications(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.overview(db, db_repair)
@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse)
def send_repair_status_mail(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake(
payload: RepairIntakePayload,
request: Request,
x_olympus_intake_token: str | None = Header(default=None, alias="X-Olympus-Intake-Token"),
db: Session = Depends(get_db),
):
expected_token = settings.olympus_repair_intake_token
if not expected_token or not x_olympus_intake_token or x_olympus_intake_token != expected_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht autorisiert")
repair = RepairService.create_from_intake(db, payload, request=request)
return RepairIntakeResponse(
repair_number=repair.repair_number,
status=cast(RepairStatus, repair.status),
message="Reparaturanfrage übernommen",
)
@router.get("/public/repairs/status/{token}", response_model=RepairPublicStatusResponse)
def get_public_repair_status(
token: str,
db: Session = Depends(get_db),
):
return RepairPublicLinkService.public_status(db, token)

View file

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

View file

@ -1,3 +1,6 @@
from typing import Any
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -6,7 +9,7 @@ class Settings(BaseSettings):
secret_key: str secret_key: str
app_name: str = "Hermes API" app_name: str = "Hermes API"
app_version: str = "0.1.0" app_version: str = "0.9.1"
access_token_expire_minutes: int = 60 access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes" jwt_issuer: str = "hermes"
log_level: str = "INFO" log_level: str = "INFO"
@ -15,11 +18,42 @@ class Settings(BaseSettings):
initial_admin_password: str | None = None initial_admin_password: str | None = None
initial_admin_first_name: str = "" initial_admin_first_name: str = ""
initial_admin_last_name: str = "" initial_admin_last_name: str = ""
storage_provider: str = "local"
storage_base_path: str = "/data/storage"
storage_max_upload_mb: int = 50
knowledge_storage_path: str = "/data/knowledge"
knowledge_max_upload_mb: int = 50
olympus_repair_intake_token: str | None = None
public_repair_status_base_url: str | None = None
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from_email: str | None = None
smtp_from_name: str = "Funktechnik Schubert"
smtp_use_tls: bool = True
lexware_enabled: bool = False
lexware_api_base_url: str = "https://api.lexware.io"
lexware_api_key: str | None = None
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
extra="ignore", extra="ignore",
) )
@model_validator(mode="before")
@classmethod
def apply_legacy_storage_settings(cls, values: Any) -> Any:
if not isinstance(values, dict):
return values
if "storage_base_path" not in values and "knowledge_storage_path" in values:
values["storage_base_path"] = values["knowledge_storage_path"]
if "storage_max_upload_mb" not in values and "knowledge_max_upload_mb" in values:
values["storage_max_upload_mb"] = values["knowledge_max_upload_mb"]
return values
settings = Settings() settings = Settings()

View file

@ -22,8 +22,14 @@ class Base(DeclarativeBase):
# <<< HIER IMPORTIEREN >>> # <<< HIER IMPORTIEREN >>>
import app.models.rbac import app.models.rbac
import app.models.customer import app.models.customer
import app.models.knowledge
import app.models.audit import app.models.audit
import app.models.user 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(): def get_db():

View file

@ -0,0 +1,77 @@
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.knowledge import KnowledgeManufacturer
logger = logging.getLogger(__name__)
DEFAULT_MANUFACTURERS = [
"Stabo",
"President",
"Albrecht",
"Marconi",
"Rohde & Schwarz",
"HP",
"CRT",
"Alinco",
"Motorola",
"Team",
]
def _slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = normalized.replace("&", "und")
normalized = normalized.replace("ß", "ss")
normalized = normalized.replace("ö", "oe")
normalized = normalized.replace("ä", "ae")
normalized = normalized.replace("ü", "ue")
parts = []
current = []
for char in normalized:
if char.isalnum():
current.append(char)
elif current:
parts.append("".join(current))
current = []
if current:
parts.append("".join(current))
return "-".join(parts)
def seed_knowledge_manufacturers(db: Session) -> None:
created_count = 0
existing = {
manufacturer.name.lower(): manufacturer
for manufacturer in db.scalars(select(KnowledgeManufacturer)).all()
}
for name in DEFAULT_MANUFACTURERS:
existing_manufacturer = existing.get(name.lower())
if existing_manufacturer:
if not existing_manufacturer.slug:
existing_manufacturer.slug = _slugify(name)
continue
manufacturer = KnowledgeManufacturer(
name=name,
slug=_slugify(name),
notes="Systemseitig initial angelegt",
)
db.add(manufacturer)
created_count += 1
db.commit()
logger.info(
"knowledge.manufacturers.seeded",
extra={"manufacturer_seed_created_count": created_count},
)

View file

@ -11,14 +11,22 @@ from starlette.requests import Request
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.audit import router as audit_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.customers import router as customers_router
from app.api.dashboard import router as dashboard_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.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.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.api.users import router as users_router
from app.db.database import SessionLocal from app.db.database import SessionLocal
from app.db.health import check_database from app.db.health import check_database
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.knowledge_seed import seed_knowledge_manufacturers
from app.rbac.seed import seed_rbac from app.rbac.seed import seed_rbac
from app.services.initial_admin_bootstrap import bootstrap_initial_admin from app.services.initial_admin_bootstrap import bootstrap_initial_admin
@ -26,17 +34,24 @@ configure_logging()
app = FastAPI( app = FastAPI(
title="Hermes API", title="Hermes API",
version="0.1.0", version="0.9.1",
description="Backend von Olympus", description="Backend von Olympus",
) )
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(backups_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(roles_router) app.include_router(roles_router)
app.include_router(permissions_router) app.include_router(permissions_router)
app.include_router(customers_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(dashboard_router)
app.include_router(system_settings_router)
app.include_router(lexware_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -74,6 +89,7 @@ def startup_seed_rbac():
db = SessionLocal() db = SessionLocal()
try: try:
seed_rbac(db) seed_rbac(db)
seed_knowledge_manufacturers(db)
bootstrap_initial_admin(db) bootstrap_initial_admin(db)
finally: finally:
db.close() db.close()

View file

@ -0,0 +1,114 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class InventoryCategory(Base):
__tablename__ = "inventory_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
class InventoryLocation(Base):
__tablename__ = "inventory_locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
class InventorySupplier(Base):
__tablename__ = "inventory_suppliers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
class InventoryItem(Base):
__tablename__ = "inventory_items"
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
movements: Mapped[list["InventoryStockMovement"]] = relationship(
back_populates="item",
cascade="all, delete-orphan",
order_by="InventoryStockMovement.created_at.desc()",
)
class InventoryStockMovement(Base):
__tablename__ = "inventory_stock_movements"
id: Mapped[int] = mapped_column(primary_key=True)
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
movement_type: Mapped[str] = mapped_column(String(40), index=True)
quantity: Mapped[int] = mapped_column(Integer)
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
item: Mapped[InventoryItem] = relationship(back_populates="movements")
actor = relationship(
"User",
foreign_keys="InventoryStockMovement.actor_user_id",
lazy="joined",
)
@property
def actor_username(self) -> str:
return self.actor.username if self.actor is not None else ""

View file

@ -0,0 +1,101 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class KnowledgeManufacturer(Base):
__tablename__ = "knowledge_manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
website: Mapped[str] = mapped_column(String(255), default="", server_default="")
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
devices: Mapped[list["KnowledgeDevice"]] = relationship(back_populates="manufacturer", cascade="all, delete-orphan")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="manufacturer")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="manufacturer")
class KnowledgeDevice(Base):
__tablename__ = "knowledge_devices"
__table_args__ = (
UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
)
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(180), index=True)
slug: Mapped[str] = mapped_column(String(200), index=True)
model_number: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
device_type: Mapped[str] = mapped_column(String(80), default="", server_default="", index=True)
frequency_range: Mapped[str] = mapped_column(String(120), default="", server_default="")
production_year_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
production_year_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="devices")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="device")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="device")
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="RESTRICT"), index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
slug: Mapped[str] = mapped_column(String(280), unique=True, index=True)
document_type: Mapped[str] = mapped_column(String(80), index=True)
language: Mapped[str] = mapped_column(String(20), default="de", server_default="", index=True)
file_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
file_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
file_size: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
external_url: Mapped[str] = mapped_column(String(500), default="", server_default="")
paperless_document_id: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="documents")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="documents")
class KnowledgeNote(Base):
__tablename__ = "knowledge_notes"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="SET NULL"), nullable=True, index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
content: Mapped[str] = mapped_column(Text)
note_type: Mapped[str] = mapped_column(String(80), index=True)
severity: Mapped[str] = mapped_column(String(40), default="", server_default="", index=True)
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer | None] = relationship(back_populates="notes_rel")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="notes_rel")
class KnowledgeCategory(Base):
__tablename__ = "knowledge_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(140), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View file

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

View file

@ -0,0 +1,162 @@
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class Repair(Base):
__tablename__ = "repairs"
id: Mapped[int] = mapped_column(primary_key=True)
repair_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id", ondelete="SET NULL"), nullable=True, index=True)
contact_id: Mapped[int | None] = mapped_column(ForeignKey("customer_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(50), index=True)
priority: Mapped[str] = mapped_column(String(30), index=True)
source: Mapped[str] = mapped_column(String(50), index=True)
source_reference: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
customer_name: Mapped[str] = mapped_column(String(255), index=True)
customer_email: Mapped[str] = mapped_column(String(255), default="", server_default="")
customer_phone: Mapped[str] = mapped_column(String(80), default="", server_default="")
device_manufacturer: Mapped[str] = mapped_column(String(160), index=True)
device_model: Mapped[str] = mapped_column(String(160), index=True)
device_serial_number: Mapped[str | None] = mapped_column(String(160), nullable=True)
device_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
accessories: Mapped[str] = mapped_column(Text, default="", server_default="")
fault_description: Mapped[str] = mapped_column(Text)
previous_work: Mapped[str] = mapped_column(Text, default="", server_default="")
device_opened: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
intake_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
diagnosis_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
repair_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
estimate_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
estimated_cost_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
class RepairStatusHistory(Base):
__tablename__ = "repair_status_history"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
old_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
new_status: Mapped[str] = mapped_column(String(50), index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
actor = relationship(
"User",
foreign_keys="RepairStatusHistory.actor_user_id",
lazy="joined",
)
@property
def actor_username(self) -> str:
return self.actor.username if self.actor is not None else ""
@property
def actor_display_name(self) -> str:
if self.actor is None:
return ""
display_name = f"{self.actor.first_name} {self.actor.last_name}".strip()
return display_name or self.actor.username
class RepairIntakeEvent(Base):
__tablename__ = "repair_intake_events"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int | None] = mapped_column(ForeignKey("repairs.id", ondelete="SET NULL"), nullable=True, index=True)
external_source: Mapped[str] = mapped_column(String(80), index=True)
external_reference: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(50), index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class RepairDocument(Base):
__tablename__ = "repair_documents"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True)
original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair: Mapped[Repair] = relationship(back_populates="documents")
uploaded_by = relationship(
"User",
foreign_keys="RepairDocument.uploaded_by_user_id",
lazy="joined",
)
@property
def uploaded_by_username(self) -> str:
return self.uploaded_by.username if self.uploaded_by is not None else ""
@property
def uploaded_by_display_name(self) -> str:
if self.uploaded_by is None:
return ""
display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip()
return display_name or self.uploaded_by.username
class RepairPublicAccessToken(Base):
__tablename__ = "repair_public_access_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_hint: Mapped[str | None] = mapped_column(String(16), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="public_access_tokens")
class RepairNotificationEvent(Base):
__tablename__ = "repair_notification_events"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
channel: Mapped[str] = mapped_column(String(40), index=True)
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
subject: Mapped[str] = mapped_column(String(255))
template: Mapped[str] = mapped_column(Text, default="", server_default="")
status: Mapped[str] = mapped_column(String(40), index=True)
success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="notification_events")

View file

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

View file

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

View file

@ -69,7 +69,44 @@ STANDARD_PERMISSIONS = [
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"), ("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "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"), ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"),
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
] ]
ROLE_PERMISSION_NAMES = { ROLE_PERMISSION_NAMES = {
@ -78,8 +115,33 @@ ROLE_PERMISSION_NAMES = {
"dashboard.read", "dashboard.read",
"users.read", "users.read",
"customers.read", "customers.read",
"knowledge.read",
"knowledge.download",
"projects.read", "projects.read",
"tickets.read", "tickets.read",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"repair_estimates.revoke",
"inventory.read",
"inventory.create",
"inventory.update",
"inventory.stock.adjust",
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
"lexware.read",
"lexware.manage",
"lexware.export",
"backup.read",
"backup.create",
"backup.download",
}, },
"sales": { "sales": {
"dashboard.read", "dashboard.read",
@ -95,17 +157,51 @@ ROLE_PERMISSION_NAMES = {
"tickets.read", "tickets.read",
"tickets.create", "tickets.create",
"tickets.update", "tickets.update",
"knowledge.read",
"knowledge.create",
"knowledge.update",
"knowledge.upload",
"knowledge.download",
"repairs.read",
"repairs.update",
"repairs.status.update",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"inventory.read",
"inventory.stock.reserve",
"inventory.stock.consume",
}, },
"support": { "support": {
"dashboard.read", "dashboard.read",
"users.read", "users.read",
"customers.read", "customers.read",
"knowledge.read",
"knowledge.download",
"tickets.read", "tickets.read",
"tickets.create", "tickets.create",
"tickets.update", "tickets.update",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
"repairs.intake",
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
"lexware.read",
}, },
"warehouse": { "warehouse": {
"dashboard.read", "dashboard.read",
"inventory.read",
"inventory.create",
"inventory.update",
"inventory.stock.adjust",
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
}, },
"guest": { "guest": {
"dashboard.read", "dashboard.read",

View file

@ -0,0 +1,229 @@
from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.inventory import (
InventoryCategory,
InventoryItem,
InventoryLocation,
InventoryStockMovement,
InventorySupplier,
)
class InventoryRepository:
@staticmethod
def item_query() -> Select[tuple[InventoryItem]]:
return select(InventoryItem).options(
selectinload(InventoryItem.category),
selectinload(InventoryItem.supplier),
selectinload(InventoryItem.location),
)
@staticmethod
def list_items(
db: Session,
*,
q: str | None = None,
category_id: int | None = None,
supplier_id: int | None = None,
location_id: int | None = None,
low_stock: bool | None = None,
active: bool | None = True,
limit: int = 50,
offset: int = 0,
) -> tuple[list[InventoryItem], int]:
query = InventoryRepository.item_query()
if q:
pattern = f"%{q.strip()}%"
query = query.where(or_(
InventoryItem.sku.ilike(pattern),
InventoryItem.name.ilike(pattern),
InventoryItem.manufacturer.ilike(pattern),
InventoryItem.manufacturer_part_number.ilike(pattern),
InventoryItem.supplier_part_number.ilike(pattern),
))
if category_id is not None:
query = query.where(InventoryItem.category_id == category_id)
if supplier_id is not None:
query = query.where(InventoryItem.supplier_id == supplier_id)
if location_id is not None:
query = query.where(InventoryItem.location_id == location_id)
if low_stock is not None:
if low_stock:
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
else:
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
if active is not None:
query = query.where(InventoryItem.is_active == active)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = list(
db.scalars(
query
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
.offset(offset)
.limit(limit)
)
)
return items, total
@staticmethod
def search_items(
db: Session,
*,
q: str | None = None,
category_id: int | None = None,
manufacturer: str | None = None,
limit: int = 20,
) -> list[InventoryItem]:
query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
if q:
pattern = f"%{q.strip()}%"
query = query.where(or_(
InventoryItem.sku.ilike(pattern),
InventoryItem.name.ilike(pattern),
InventoryItem.manufacturer.ilike(pattern),
InventoryItem.manufacturer_part_number.ilike(pattern),
InventoryItem.supplier_part_number.ilike(pattern),
InventoryItem.compatible_devices.ilike(pattern),
))
if category_id is not None:
query = query.where(InventoryItem.category_id == category_id)
if manufacturer:
query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
@staticmethod
def get_item(db: Session, item_id: int) -> InventoryItem | None:
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
@staticmethod
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
@staticmethod
def get_next_sku(db: Session, year: int | None = None) -> str:
current_year = year or datetime.now(UTC).year
prefix = f"ET-{current_year}-"
latest = db.scalar(
select(InventoryItem.sku)
.where(InventoryItem.sku.like(f"{prefix}%"))
.order_by(InventoryItem.sku.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def list_categories(db: Session) -> list[InventoryCategory]:
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
@staticmethod
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
return db.get(InventoryCategory, category_id)
@staticmethod
def list_locations(db: Session) -> list[InventoryLocation]:
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
@staticmethod
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
return db.get(InventoryLocation, location_id)
@staticmethod
def list_suppliers(db: Session) -> list[InventorySupplier]:
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
@staticmethod
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
return db.get(InventorySupplier, supplier_id)
@staticmethod
def has_movements(db: Session, item_id: int) -> bool:
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
@staticmethod
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.where(InventoryStockMovement.item_id == item_id)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
)
)
@staticmethod
def create_movement(
db: Session,
*,
item_id: int,
movement_type: str,
quantity: int,
reason: str,
reference_type: str | None,
reference_id: int | None,
note: str | None,
actor_user_id: int | None,
) -> InventoryStockMovement:
movement = InventoryStockMovement(
item_id=item_id,
movement_type=movement_type,
quantity=quantity,
reason=reason,
reference_type=reference_type,
reference_id=reference_id,
note=note,
actor_user_id=actor_user_id,
)
db.add(movement)
return movement
@staticmethod
def count_active_items(db: Session) -> int:
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
@staticmethod
def count_low_stock_items(db: Session) -> int:
return db.scalar(
select(func.count(InventoryItem.id))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
) or 0
@staticmethod
def total_stock_value_cents(db: Session) -> int:
return db.scalar(
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.purchase_price_cents.is_not(None))
) or 0
@staticmethod
def count_reserved_items(db: Session) -> int:
return db.scalar(
select(func.count(InventoryItem.id))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.quantity_reserved > 0)
) or 0
@staticmethod
def reserved_stock_value_cents(db: Session) -> int:
return db.scalar(
select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.selling_price_cents.is_not(None))
) or 0
@staticmethod
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
.limit(limit)
)
)

View file

@ -0,0 +1,177 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, joinedload
from app.models.knowledge import (
KnowledgeDevice,
KnowledgeDocument,
KnowledgeManufacturer,
KnowledgeNote,
)
def paginate(query, limit: int, offset: int):
return query.limit(max(1, min(limit, 100))).offset(max(0, offset))
class KnowledgeRepository:
@staticmethod
def list_manufacturers(db: Session, *, q: str = "", limit: int = 50, offset: int = 0) -> list[KnowledgeManufacturer]:
query = select(KnowledgeManufacturer).order_by(KnowledgeManufacturer.name)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeManufacturer.name).like(pattern))
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_manufacturer(db: Session, manufacturer_id: int) -> KnowledgeManufacturer | None:
return db.get(KnowledgeManufacturer, manufacturer_id)
@staticmethod
def get_manufacturer_by_name(db: Session, name: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(func.lower(KnowledgeManufacturer.name) == name.lower())
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def get_manufacturer_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(KnowledgeManufacturer.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_devices(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDevice]:
query = select(KnowledgeDevice).options(joinedload(KnowledgeDevice.manufacturer)).order_by(KnowledgeDevice.name)
if manufacturer_id is not None:
query = query.where(KnowledgeDevice.manufacturer_id == manufacturer_id)
if device_type:
query = query.where(KnowledgeDevice.device_type == device_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
func.lower(KnowledgeDevice.name).like(pattern),
func.lower(KnowledgeDevice.model_number).like(pattern),
)
)
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_device(db: Session, device_id: int) -> KnowledgeDevice | None:
return db.scalar(
select(KnowledgeDevice)
.where(KnowledgeDevice.id == device_id)
.options(joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_device_by_slug(
db: Session,
*,
manufacturer_id: int,
slug: str,
exclude_id: int | None = None,
) -> KnowledgeDevice | None:
query = select(KnowledgeDevice).where(KnowledgeDevice.manufacturer_id == manufacturer_id, KnowledgeDevice.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDevice.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_documents(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDocument]:
query = (
select(KnowledgeDocument)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeDocument.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeDocument.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeDocument.device_id == device_id)
if document_type:
query = query.where(KnowledgeDocument.document_type == document_type)
if language:
query = query.where(KnowledgeDocument.language == language)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeDocument.title).like(pattern))
documents = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
documents = [document for document in documents if any(item.lower() == tag_lower for item in document.tags)]
return documents
@staticmethod
def get_document(db: Session, document_id: int) -> KnowledgeDocument | None:
return db.scalar(
select(KnowledgeDocument)
.where(KnowledgeDocument.id == document_id)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_document_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeDocument | None:
query = select(KnowledgeDocument).where(KnowledgeDocument.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDocument.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_notes(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeNote]:
query = (
select(KnowledgeNote)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeNote.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeNote.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeNote.device_id == device_id)
if note_type:
query = query.where(KnowledgeNote.note_type == note_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(or_(func.lower(KnowledgeNote.title).like(pattern), func.lower(KnowledgeNote.content).like(pattern)))
notes = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
notes = [note for note in notes if any(item.lower() == tag_lower for item in note.tags)]
return notes
@staticmethod
def get_note(db: Session, note_id: int) -> KnowledgeNote | None:
return db.scalar(
select(KnowledgeNote)
.where(KnowledgeNote.id == note_id)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
)

View file

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

View file

@ -0,0 +1,449 @@
from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
class RepairRepository:
@staticmethod
def query(
*,
status: str | None = None,
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
) -> Select[tuple[Repair]]:
query = select(Repair).options(selectinload(Repair.history))
if status:
query = query.where(Repair.status == status)
if customer_id is not None:
query = query.where(Repair.customer_id == customer_id)
if source:
query = query.where(Repair.source == source)
if priority:
query = query.where(Repair.priority == priority)
if q:
term = f"%{q.strip()}%"
query = query.where(
or_(
Repair.repair_number.ilike(term),
Repair.customer_name.ilike(term),
Repair.device_manufacturer.ilike(term),
Repair.device_model.ilike(term),
Repair.fault_description.ilike(term),
Repair.source_reference.ilike(term),
)
)
return query
@staticmethod
def list(
db: Session,
*,
status: str | None = None,
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Repair], int]:
base_query = RepairRepository.query(
status=status,
q=q,
customer_id=customer_id,
source=source,
priority=priority,
)
total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0
items = list(
db.scalars(
base_query
.order_by(Repair.created_at.desc(), Repair.id.desc())
.offset(offset)
.limit(limit)
)
)
return items, total
@staticmethod
def get_by_id(db: Session, repair_id: int) -> Repair | None:
return db.scalar(select(Repair).options(selectinload(Repair.history)).where(Repair.id == repair_id))
@staticmethod
def get_next_number(db: Session, year: int) -> str:
prefix = f"R{year}-"
latest = db.scalar(
select(Repair.repair_number)
.where(Repair.repair_number.like(f"{prefix}%"))
.order_by(Repair.repair_number.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def create(db: Session, payload: RepairCreate, *, repair_number: str) -> Repair:
repair = Repair(repair_number=repair_number, **RepairRepository._payload_data(payload))
db.add(repair)
db.flush()
RepairRepository.add_history(db, repair.id, old_status=None, new_status=repair.status, note="Reparatur angelegt", actor_user_id=None)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def update(db: Session, repair: Repair, payload: RepairUpdate) -> Repair:
for key, value in RepairRepository._payload_data(payload).items():
setattr(repair, key, value)
RepairRepository._apply_status_timestamps(repair)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor_user_id: int | None) -> Repair:
old_status = repair.status
repair.status = payload.status
RepairRepository._apply_status_timestamps(repair)
RepairRepository.add_history(
db,
repair.id,
old_status=old_status,
new_status=payload.status,
note=payload.note,
actor_user_id=actor_user_id,
)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def cancel(db: Session, repair: Repair, *, actor_user_id: int | None) -> Repair:
old_status = repair.status
repair.status = "cancelled"
repair.completed_at = datetime.now(UTC)
RepairRepository.add_history(
db,
repair.id,
old_status=old_status,
new_status="cancelled",
note="Reparatur storniert",
actor_user_id=actor_user_id,
)
db.commit()
db.refresh(repair)
return repair
@staticmethod
def add_history(db: Session, repair_id: int, *, old_status: str | None, new_status: str, note: str | None, actor_user_id: int | None) -> RepairStatusHistory:
history = RepairStatusHistory(
repair_id=repair_id,
old_status=old_status,
new_status=new_status,
note=note,
actor_user_id=actor_user_id,
)
db.add(history)
return history
@staticmethod
def get_history(db: Session, repair_id: int) -> list[RepairStatusHistory]:
return list(
db.scalars(
select(RepairStatusHistory)
.options(selectinload(RepairStatusHistory.actor))
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
)
)
@staticmethod
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
return list(
db.scalars(
select(RepairStatusHistory)
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
)
)
@staticmethod
def count_by_status(db: Session, status: str) -> int:
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
@staticmethod
def create_intake_event(
db: Session,
*,
external_source: str,
external_reference: str | None,
payload: dict,
status: str,
error_message: str | None = None,
repair_id: int | None = None,
processed_at: datetime | None = None,
) -> RepairIntakeEvent:
event = RepairIntakeEvent(
external_source=external_source,
external_reference=external_reference,
payload=payload,
status=status,
error_message=error_message,
repair_id=repair_id,
processed_at=processed_at,
)
db.add(event)
db.commit()
db.refresh(event)
return event
@staticmethod
def update_intake_event(db: Session, event: RepairIntakeEvent, *, status: str, repair_id: int | None = None, error_message: str | None = None) -> RepairIntakeEvent:
event.status = status
event.repair_id = repair_id
event.error_message = error_message
event.processed_at = datetime.now(UTC)
db.commit()
db.refresh(event)
return event
@staticmethod
def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(RepairPublicAccessToken.revoked_at.is_(None))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
.limit(1)
)
@staticmethod
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.options(selectinload(RepairPublicAccessToken.repair))
.where(RepairPublicAccessToken.token_hash == token_hash)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(RepairPublicAccessToken.revoked_at.is_(None))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.limit(1)
)
@staticmethod
def create_public_link(
db: Session,
*,
repair_id: int,
token_hash: str,
token_hint: str,
expires_at: datetime | None = None,
) -> RepairPublicAccessToken:
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
public_link = RepairPublicAccessToken(
repair_id=repair_id,
token_hash=token_hash,
token_hint=token_hint,
expires_at=expires_at,
)
db.add(public_link)
db.commit()
db.refresh(public_link)
return public_link
@staticmethod
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
now = datetime.now(UTC)
links = list(
db.scalars(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
)
)
for link in links:
link.is_active = False
link.revoked_at = now
if commit:
db.commit()
@staticmethod
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
public_link.last_used_at = datetime.now(UTC)
db.commit()
@staticmethod
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
return list(
db.scalars(
select(RepairNotificationEvent)
.where(RepairNotificationEvent.repair_id == repair_id)
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
)
)
@staticmethod
def create_notification_event(
db: Session,
*,
repair_id: int,
event_type: str,
channel: str,
recipient: str,
subject: str,
template: str,
status: str,
success: bool,
error_message: str | None = None,
sent_at: datetime | None = None,
) -> RepairNotificationEvent:
event = RepairNotificationEvent(
repair_id=repair_id,
event_type=event_type,
channel=channel,
recipient=recipient,
subject=subject,
template=template,
status=status,
success=success,
error_message=error_message,
sent_at=sent_at,
)
db.add(event)
db.commit()
db.refresh(event)
return event
@staticmethod
def count_status_mails_sent_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(True))
.where(func.date(RepairNotificationEvent.created_at) == today)
) or 0
@staticmethod
def count_failed_status_mails(db: Session) -> int:
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(False))
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
) or 0
@staticmethod
def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None:
return db.scalar(
select(RepairNotificationEvent)
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(False))
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
.limit(1)
)
@staticmethod
def count_open_repairs_without_customer_email(db: Session) -> int:
return db.scalar(
select(func.count(Repair.id))
.where(~Repair.status.in_(["completed", "cancelled"]))
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
) or 0
@staticmethod
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
return list(
db.scalars(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
)
)
@staticmethod
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
return db.scalar(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.where(RepairDocument.id == document_id)
)
@staticmethod
def create_document(
db: Session,
*,
repair_id: int,
title: str,
document_type: str,
original_filename: str,
stored_filename: str,
storage_path: str,
mime_type: str,
size_bytes: int,
checksum_sha256: str,
visibility: str,
note: str | None,
uploaded_by_user_id: int | None,
) -> RepairDocument:
document = RepairDocument(
repair_id=repair_id,
title=title,
document_type=document_type,
original_filename=original_filename,
stored_filename=stored_filename,
storage_path=storage_path,
mime_type=mime_type,
size_bytes=size_bytes,
checksum_sha256=checksum_sha256,
visibility=visibility,
note=note,
uploaded_by_user_id=uploaded_by_user_id,
)
db.add(document)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
@staticmethod
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
for key, value in payload.model_dump().items():
setattr(document, key, value)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
@staticmethod
def delete_document(db: Session, document: RepairDocument) -> None:
db.delete(document)
db.commit()
@staticmethod
def count_documents(db: Session) -> int:
return db.scalar(select(func.count(RepairDocument.id))) or 0
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()
data["customer_email"] = str(payload.customer_email or "")
data["device_serial_number"] = payload.device_serial_number or None
data["source_reference"] = payload.source_reference or None
return data
@staticmethod
def _apply_status_timestamps(repair: Repair) -> None:
if repair.status == "approved" and repair.approved_at is None:
repair.approved_at = datetime.now(UTC)
if repair.status in {"completed", "cancelled"} and repair.completed_at is None:
repair.completed_at = datetime.now(UTC)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,231 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
InventoryMovementType = Literal[
"initial",
"purchase",
"adjustment",
"reservation",
"release",
"consumption",
"return",
"correction",
]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_optional_text(value: object) -> str | None:
text = normalize_text(value)
return text or None
class InventoryCategoryPayload(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
@field_validator("name", "description", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventoryLocationPayload(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
@field_validator("name", "description", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventorySupplierPayload(BaseModel):
name: str = Field(min_length=1, max_length=180)
contact_name: str | None = None
email: str | None = None
phone: str | None = None
website: str | None = None
customer_number: str | None = None
notes: str | None = None
@field_validator("*", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_optional_text(value)
class InventoryItemPayload(BaseModel):
sku: str | None = Field(default=None, max_length=40)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
category_id: int | None = None
manufacturer: str | None = None
manufacturer_part_number: str | None = None
supplier_id: int | None = None
supplier_part_number: str | None = None
compatible_devices: str | None = None
location_id: int | None = None
quantity_on_hand: int = Field(default=0, ge=0)
quantity_reserved: int = Field(default=0, ge=0)
reorder_level: int = Field(default=0, ge=0)
unit: str = Field(default="Stk.", max_length=40)
purchase_price_cents: int | None = Field(default=None, ge=0)
selling_price_cents: int | None = Field(default=None, ge=0)
currency: str = Field(default="EUR", max_length=3)
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
notes: str | None = None
is_active: bool = True
@field_validator(
"sku",
"name",
"description",
"manufacturer",
"manufacturer_part_number",
"supplier_part_number",
"compatible_devices",
"unit",
"currency",
"notes",
mode="before",
)
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper() or "EUR"
@field_validator("tax_rate_percent", mode="before")
@classmethod
def normalize_tax_rate(cls, value: object) -> object:
if isinstance(value, str):
return value.strip().replace(",", ".")
return value
@model_validator(mode="after")
def validate_reserved_quantity(self):
if self.quantity_reserved > self.quantity_on_hand:
raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
return self
class InventoryItemCreate(InventoryItemPayload):
pass
class InventoryItemUpdate(InventoryItemPayload):
pass
class InventoryStockAction(BaseModel):
quantity: int = Field(ge=0)
reason: str = Field(min_length=1, max_length=255)
note: str | None = None
reference_type: str | None = Field(default=None, max_length=80)
reference_id: int | None = None
@field_validator("reason", "note", "reference_type", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventoryCategoryResponse(InventoryCategoryPayload):
id: int
slug: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryLocationResponse(InventoryLocationPayload):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventorySupplierResponse(InventorySupplierPayload):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryItemResponse(BaseModel):
id: int
sku: str
name: str
description: str | None
category_id: int | None
manufacturer: str | None
manufacturer_part_number: str | None
supplier_id: int | None
supplier_part_number: str | None
compatible_devices: str | None
location_id: int | None
quantity_on_hand: int
quantity_reserved: int
quantity_available: int
reorder_level: int
unit: str
purchase_price_cents: int | None
selling_price_cents: int | None
currency: str
tax_rate_percent: Decimal
notes: str | None
is_active: bool
created_at: datetime
updated_at: datetime
category: InventoryCategoryResponse | None
supplier: InventorySupplierResponse | None
location: InventoryLocationResponse | None
model_config = ConfigDict(from_attributes=True)
class InventoryItemListResponse(BaseModel):
items: list[InventoryItemResponse]
total: int
limit: int
offset: int
class InventoryStockMovementResponse(BaseModel):
id: int
item_id: int
movement_type: str
quantity: int
reason: str
reference_type: str | None
reference_id: int | None
note: str | None
actor_user_id: int | None
actor_username: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,200 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
DocumentType = Literal[
"manual",
"service_manual",
"schematic",
"alignment",
"parts_list",
"firmware",
"datasheet",
"service_bulletin",
"other",
]
NoteType = Literal["repair", "known_fault", "alignment", "spare_part", "general"]
NoteSeverity = Literal["low", "medium", "high", "critical", ""]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_tags(value: object) -> list[str]:
if value is None or value == "":
return []
if isinstance(value, str):
parts = value.split(",")
elif isinstance(value, list):
parts = value
else:
parts = []
seen: set[str] = set()
tags: list[str] = []
for part in parts:
tag = str(part).strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
class KnowledgeManufacturerBase(BaseModel):
name: str = Field(min_length=2, max_length=160)
website: HttpUrl | None = None
notes: str = ""
@field_validator("name", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeManufacturerCreate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerUpdate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerResponse(KnowledgeManufacturerBase):
id: int
slug: str
website: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class KnowledgeDeviceBase(BaseModel):
manufacturer_id: int
name: str = Field(min_length=2, max_length=180)
model_number: str = Field(default="", max_length=120)
device_type: str = Field(default="", max_length=80)
frequency_range: str = Field(default="", max_length=120)
production_year_from: int | None = Field(default=None, ge=1900, le=2100)
production_year_to: int | None = Field(default=None, ge=1900, le=2100)
notes: str = ""
@field_validator("name", "model_number", "device_type", "frequency_range", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeDeviceCreate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceUpdate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceResponse(KnowledgeDeviceBase):
id: int
slug: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
model_config = ConfigDict(from_attributes=True)
class KnowledgeDocumentBase(BaseModel):
manufacturer_id: int
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
document_type: DocumentType
language: str = Field(default="de", min_length=2, max_length=20)
external_url: HttpUrl | None = None
paperless_document_id: str = Field(default="", max_length=120)
description: str = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "language", "paperless_document_id", "description", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeDocumentCreate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentUpdate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentResponse(KnowledgeDocumentBase):
id: int
slug: str
external_url: str
file_name: str
file_path: str
mime_type: str
file_size: int
checksum_sha256: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeNoteBase(BaseModel):
manufacturer_id: int | None = None
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
content: str = Field(min_length=1)
note_type: NoteType = "general"
severity: NoteSeverity = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "content", "severity", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeNoteCreate(KnowledgeNoteBase):
pass
class KnowledgeNoteUpdate(KnowledgeNoteBase):
pass
class KnowledgeNoteResponse(KnowledgeNoteBase):
id: int
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse | None = None
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeSearchResult(BaseModel):
entity_type: str
id: int
title: str
subtitle: str = ""
href: str

View file

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

View file

@ -0,0 +1,273 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from app.schemas.repair_estimate import PublicEstimateResponse
RepairStatus = Literal[
"new",
"accepted",
"diagnosis",
"estimate",
"waiting_for_customer",
"approved",
"repair",
"final_test",
"ready_for_pickup",
"shipped",
"completed",
"cancelled",
]
RepairPriority = Literal["low", "normal", "high", "urgent"]
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
RepairDocumentType = Literal[
"device_photo",
"fault_photo",
"measurement",
"estimate",
"repair_report",
"shipping",
"other",
]
RepairDocumentVisibility = Literal["internal", "customer"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RepairBase(BaseModel):
customer_id: int | None = None
contact_id: int | None = None
status: RepairStatus = "new"
priority: RepairPriority = "normal"
source: RepairSource = "manual"
source_reference: str | None = Field(default=None, max_length=120)
customer_name: str = Field(default="", max_length=255)
customer_email: EmailStr | None = None
customer_phone: str = Field(default="", max_length=80)
device_manufacturer: str = Field(min_length=1, max_length=160)
device_model: str = Field(min_length=1, max_length=160)
device_serial_number: str | None = Field(default=None, max_length=160)
device_type: str = Field(default="", max_length=120)
accessories: str = ""
fault_description: str = Field(min_length=1)
previous_work: str = ""
device_opened: bool = False
intake_notes: str = ""
diagnosis_notes: str = ""
repair_notes: str = ""
estimate_notes: str = ""
estimated_cost_cents: int | None = Field(default=None, ge=0)
@field_validator(
"source_reference",
"customer_name",
"customer_phone",
"device_manufacturer",
"device_model",
"device_serial_number",
"device_type",
"accessories",
"fault_description",
"previous_work",
"intake_notes",
"diagnosis_notes",
"repair_notes",
"estimate_notes",
mode="before",
)
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@model_validator(mode="after")
def validate_customer_identity(self):
if self.customer_id is None and not self.customer_name:
raise ValueError("Kundenname ist erforderlich, wenn kein Kunde ausgewählt ist")
return self
class RepairCreate(RepairBase):
pass
class RepairUpdate(RepairBase):
pass
class RepairStatusUpdate(BaseModel):
status: RepairStatus
note: str | None = None
@field_validator("note", mode="before")
@classmethod
def normalize_note(cls, value: object) -> str | None:
if value is None:
return None
text = normalize_text(value)
return text or None
class RepairResponse(RepairBase):
id: int
repair_number: str
customer_email: str
device_serial_number: str | None
approved_at: datetime | None
completed_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairListResponse(BaseModel):
items: list[RepairResponse]
total: int
limit: int
offset: int
class RepairStatusHistoryResponse(BaseModel):
id: int
repair_id: int
old_status: str | None
new_status: str
note: str | None
actor_user_id: int | None
actor_username: str = ""
actor_display_name: str = ""
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairIntakePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None
phone: str = Field(default="", max_length=80)
manufacturer: str = Field(min_length=1, max_length=160)
model: str = Field(min_length=1, max_length=160)
serial_number: str | None = Field(default=None, max_length=160)
device_type: str = Field(default="", max_length=120)
accessories: str = ""
fault_description: str = Field(min_length=1)
previous_work: str = ""
device_opened: bool = False
source_reference: str | None = Field(default=None, max_length=120)
@field_validator("*", mode="before")
@classmethod
def normalize_payload(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value
class RepairIntakeResponse(BaseModel):
repair_number: str
status: RepairStatus
message: str
class RepairPublicLinkResponse(BaseModel):
is_active: bool
token_hint: str | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime | None = None
revoked_at: datetime | None = None
public_status_path: str | None = None
class RepairPublicLinkCreateResponse(RepairPublicLinkResponse):
token: str
public_status_path: str
class RepairPublicStatusHistoryItem(BaseModel):
status: RepairStatus
status_label: str
created_at: datetime
class RepairPublicStatusResponse(BaseModel):
repair_number: str
public_status_label: str
device_manufacturer: str
device_model: str
status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime
estimate: PublicEstimateResponse | None = None
class RepairNotificationTemplateResponse(BaseModel):
event_type: str
status: RepairStatus | None = None
subject: str
text: str
html: str | None = None
class RepairNotificationEventResponse(BaseModel):
id: int
repair_id: int
event_type: str
channel: str
recipient: str
subject: str
template: str
status: str
success: bool
error_message: str | None
created_at: datetime
sent_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class RepairNotificationOverviewResponse(BaseModel):
templates: list[RepairNotificationTemplateResponse]
events: list[RepairNotificationEventResponse]
class RepairDocumentBase(BaseModel):
title: str = Field(min_length=1, max_length=255)
document_type: RepairDocumentType = "other"
visibility: RepairDocumentVisibility = "internal"
note: str | None = None
@field_validator("title", "note", mode="before")
@classmethod
def normalize_document_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class RepairDocumentUpdate(RepairDocumentBase):
pass
class RepairDocumentResponse(RepairDocumentBase):
id: int
repair_id: int
original_filename: str
stored_filename: str
storage_path: str
mime_type: str
size_bytes: int
checksum_sha256: str
uploaded_by_user_id: int | None
uploaded_by_username: str = ""
uploaded_by_display_name: str = ""
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,208 @@
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
EstimateActorType = Literal["user", "customer", "system"]
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RepairEstimateItemPayload(BaseModel):
item_type: EstimateItemType = "other"
inventory_item_id: int | None = None
inventory_price_overridden: bool = False
title: str = Field(default="", max_length=255)
description: str | None = None
quantity: Decimal = Field(gt=Decimal("0"))
unit: str = Field(default="Stk.", max_length=40)
unit_price_cents: int = Field(ge=0)
@field_validator("title", "description", "unit", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@field_validator("quantity", mode="before")
@classmethod
def normalize_quantity(cls, value: object) -> object:
if isinstance(value, str):
return value.strip().replace(",", ".")
return value
@model_validator(mode="after")
def validate_inventory_or_title(self):
if self.inventory_item_id is None and not self.title.strip():
raise ValueError("Titel ist erforderlich")
return self
class RepairEstimatePayload(BaseModel):
title: str = Field(min_length=1, max_length=255)
customer_message: str = ""
internal_note: str | None = None
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
currency: str = Field(default="EUR", max_length=3)
valid_until: date | None = None
items: list[RepairEstimateItemPayload]
@field_validator("title", "customer_message", "internal_note", "currency", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper() or "EUR"
@field_validator("tax_rate_percent", mode="before")
@classmethod
def normalize_tax_rate(cls, value: object) -> object:
if isinstance(value, str):
return value.strip().replace(",", ".")
return value
@model_validator(mode="after")
def validate_items(self):
if not self.items:
raise ValueError("Mindestens eine Position ist erforderlich")
return self
class RepairEstimateCreate(RepairEstimatePayload):
pass
class RepairEstimateUpdate(RepairEstimatePayload):
pass
class RepairEstimateItemResponse(BaseModel):
id: int
estimate_id: int
inventory_item_id: int | None
inventory_snapshot_name: str
inventory_snapshot_sku: str
inventory_snapshot_manufacturer: str | None
inventory_snapshot_part_number: str | None
position: int
item_type: EstimateItemType
title: str
description: str | None
quantity: Decimal
unit: str
unit_price_cents: int
total_cents: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateEventResponse(BaseModel):
id: int
estimate_id: int
event_type: str
actor_type: str
actor_user_id: int | None
note: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateResponse(BaseModel):
id: int
repair_id: int
estimate_number: str
status: EstimateStatus
title: str
customer_message: str
internal_note: str | None
subtotal_cents: int
tax_rate_percent: Decimal
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
sent_at: datetime | None
approved_at: datetime | None
declined_at: datetime | None
customer_response_message: str | None
lexware_invoice_id: str | None
lexware_invoice_number: str | None
lexware_invoice_status: str | None
lexware_synced_at: datetime | None
accounting_export_status: str | None
accounting_note: str | None
accounting_transferred_at: datetime | None
accounting_transferred_by_user_id: int | None
created_by_user_id: int | None
created_at: datetime
updated_at: datetime
items: list[RepairEstimateItemResponse]
model_config = ConfigDict(from_attributes=True)
class PublicEstimateItemResponse(BaseModel):
item_type: EstimateItemType
inventory_snapshot_name: str
inventory_snapshot_sku: str
inventory_snapshot_manufacturer: str | None
inventory_snapshot_part_number: str | None
title: str
description: str | None
quantity: Decimal
unit: str
unit_price_cents: int
total_cents: int
class PublicEstimateResponse(BaseModel):
estimate_number: str
status: EstimateStatus
status_label: str
title: str
customer_message: str
subtotal_cents: int
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
items: list[PublicEstimateItemResponse]
class PublicEstimateDecisionRequest(BaseModel):
message: str | None = Field(default=None, max_length=2000)
@field_validator("message", mode="before")
@classmethod
def normalize_message(cls, value: object) -> str | None:
if value is None:
return None
text = normalize_text(value)
return text or None
def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int:
total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(total)
def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int:
tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(tax)

View file

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

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"} SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any: def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@ -130,7 +130,98 @@ def action_title(action: str) -> str:
"customer_contacts.create": "Ansprechpartner erstellt", "customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet", "customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht", "customer_contacts.delete": "Ansprechpartner gelöscht",
"knowledge.manufacturers.create": "Hersteller erstellt",
"knowledge.manufacturers.update": "Hersteller bearbeitet",
"knowledge.manufacturers.delete": "Hersteller gelöscht",
"knowledge.devices.create": "Gerät erstellt",
"knowledge.devices.update": "Gerät bearbeitet",
"knowledge.devices.delete": "Gerät gelöscht",
"knowledge.documents.create": "Dokument erstellt",
"knowledge.documents.upload": "Dokument hochgeladen",
"knowledge.documents.update": "Dokument bearbeitet",
"knowledge.documents.delete": "Dokument gelöscht",
"knowledge.notes.create": "Notiz erstellt",
"knowledge.notes.update": "Notiz bearbeitet",
"knowledge.notes.delete": "Notiz gelöscht",
"users.initial_admin_bootstrap": "Initialer Administrator erstellt", "users.initial_admin_bootstrap": "Initialer Administrator erstellt",
"knowledge.manufacturers.create": "Hersteller erstellt",
"knowledge.manufacturers.update": "Hersteller bearbeitet",
"knowledge.manufacturers.delete": "Hersteller gelöscht",
"knowledge.devices.create": "Gerät erstellt",
"knowledge.devices.update": "Gerät bearbeitet",
"knowledge.devices.delete": "Gerät gelöscht",
"knowledge.documents.create": "Dokument erstellt",
"knowledge.documents.upload": "Dokument hochgeladen",
"knowledge.documents.update": "Dokument bearbeitet",
"knowledge.documents.delete": "Dokument gelöscht",
"knowledge.notes.create": "Wissensnotiz erstellt",
"knowledge.notes.update": "Wissensnotiz bearbeitet",
"knowledge.notes.delete": "Wissensnotiz gelöscht",
"repairs.create": "Reparatur erstellt",
"repairs.update": "Reparatur bearbeitet",
"repairs.status_update": "Reparaturstatus geändert",
"repairs.cancel": "Reparatur storniert",
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
"repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt",
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
"repairs.status_mail.sent": "Statusmail versendet",
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"repair_estimates.create": "Kostenvoranschlag erstellt",
"repair_estimates.update": "Kostenvoranschlag geändert",
"repair_estimates.send": "Kostenvoranschlag gesendet",
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"inventory.items.create": "Lagerartikel erstellt",
"inventory.items.update": "Lagerartikel geändert",
"inventory.items.deactivate": "Lagerartikel deaktiviert",
"inventory.items.delete": "Lagerartikel gelöscht",
"inventory.stock.adjust": "Bestand angepasst",
"inventory.stock.reserve": "Bestand reserviert",
"inventory.stock.release": "Reservierung aufgehoben",
"inventory.stock.consume": "Bestand verbraucht",
"inventory.estimate.reserve": "KV reserviert Lagerbestand",
"inventory.estimate.release": "KV-Reservierung aufgehoben",
"inventory.estimate.consume": "Reservierung verbraucht",
"inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben",
"inventory.stock.low": "Lagerbestand knapp",
"inventory.categories.create": "Lagerkategorie erstellt",
"inventory.categories.update": "Lagerkategorie geändert",
"inventory.categories.delete": "Lagerkategorie gelöscht",
"inventory.locations.create": "Lagerort erstellt",
"inventory.locations.update": "Lagerort geändert",
"inventory.locations.delete": "Lagerort gelöscht",
"inventory.suppliers.create": "Lieferant erstellt",
"inventory.suppliers.update": "Lieferant geändert",
"inventory.suppliers.delete": "Lieferant gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
"lexware.settings.update": "Lexware Einstellungen geändert",
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
"backups.create": "Backup erstellt",
"backups.download": "Backup heruntergeladen",
"backups.delete": "Backup gelöscht",
"backups.validate": "Backup validiert",
"backups.restore_started": "Restore gestartet",
"backups.restore_failed": "Restore fehlgeschlagen",
"backups.restore_completed": "Restore abgeschlossen",
} }
return labels.get(action, action) return labels.get(action, action)

View file

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

View file

@ -0,0 +1,415 @@
import re
import unicodedata
from decimal import Decimal
from typing import TypeVar
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.schemas.inventory import (
InventoryCategoryPayload,
InventoryItemCreate,
InventoryItemUpdate,
InventoryLocationPayload,
InventoryStockAction,
InventorySupplierPayload,
)
from app.services.audit_service import write_audit_log
MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
def _slugify(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
return slug or "eintrag"
def _item_label(item: InventoryItem) -> str:
return f"{item.sku} · {item.name}"
def _audit_item_data(item: InventoryItem) -> dict:
return {
"id": item.id,
"sku": item.sku,
"name": item.name,
"category_id": item.category_id,
"supplier_id": item.supplier_id,
"location_id": item.location_id,
"quantity_on_hand": item.quantity_on_hand,
"quantity_reserved": item.quantity_reserved,
"quantity_available": item.quantity_available,
"reorder_level": item.reorder_level,
"selling_price_cents": item.selling_price_cents,
"currency": item.currency,
"is_active": item.is_active,
}
class InventoryService:
@staticmethod
def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
InventoryService._validate_sku_available(db, sku)
item = InventoryItem(sku=sku)
InventoryService._apply_item_payload(item, payload)
try:
db.add(item)
db.flush()
if item.quantity_on_hand > 0:
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type="initial",
quantity=item.quantity_on_hand,
reason="Anfangsbestand",
reference_type=None,
reference_id=None,
note=None,
actor_user_id=actor.id,
)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
db.refresh(item)
item = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action="inventory.items.create",
entity_type="inventory_items",
entity_id=item.id,
entity_label=_item_label(item),
actor=actor,
request=request,
after_data=_audit_item_data(item),
)
return item
@staticmethod
def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
before_data = _audit_item_data(item)
sku = (payload.sku or "").upper() or item.sku
InventoryService._validate_sku_available(db, sku, item_id=item.id)
item.sku = sku
InventoryService._apply_item_payload(item, payload)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
db.refresh(item)
updated = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action="inventory.items.update",
entity_type="inventory_items",
entity_id=updated.id,
entity_label=_item_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(updated),
)
return updated
@staticmethod
def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
before_data = _audit_item_data(item)
label = _item_label(item)
if InventoryRepository.has_movements(db, item.id):
item.is_active = False
InventoryService._recalculate_available(item)
db.commit()
db.refresh(item)
write_audit_log(
db,
action="inventory.items.deactivate",
entity_type="inventory_items",
entity_id=item.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(item),
)
return InventoryRepository.get_item(db, item.id) or item
db.delete(item)
db.commit()
write_audit_log(
db,
action="inventory.items.delete",
entity_type="inventory_items",
entity_id=item.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
)
return None
@staticmethod
def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
before_data = _audit_item_data(item)
item.quantity_on_hand = payload.quantity
if item.quantity_reserved > item.quantity_on_hand:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
@staticmethod
def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_available:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
before_data = _audit_item_data(item)
item.quantity_reserved += payload.quantity
InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
@staticmethod
def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_reserved:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
before_data = _audit_item_data(item)
item.quantity_reserved -= payload.quantity
InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
@staticmethod
def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_on_hand:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
before_data = _audit_item_data(item)
item.quantity_on_hand -= payload.quantity
item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
@staticmethod
def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
for estimate_item in estimate.items:
if estimate_item.inventory_item_id is None:
continue
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
if item is None or not item.is_active:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
if quantity > item.quantity_available:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
)
item.quantity_reserved += quantity
InventoryService._recalculate_available(item)
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type="reservation",
quantity=quantity,
reason="estimate_reserved",
reference_type="repair_estimate",
reference_id=estimate.id,
note=f"Kostenvoranschlag {estimate.estimate_number}",
actor_user_id=actor_user_id,
)
@staticmethod
def release_estimate_reservation(
db: Session,
estimate: RepairEstimate,
*,
actor_user_id: int | None,
reason: str = "estimate_released",
) -> None:
for estimate_item in estimate.items:
if estimate_item.inventory_item_id is None:
continue
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
if item is None:
continue
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
item.quantity_reserved = max(0, item.quantity_reserved - quantity)
InventoryService._recalculate_available(item)
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type="release",
quantity=quantity,
reason=reason,
reference_type="repair_estimate",
reference_id=estimate.id,
note=f"Kostenvoranschlag {estimate.estimate_number}",
actor_user_id=actor_user_id,
)
@staticmethod
def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
for estimate_item in estimate.items:
if estimate_item.inventory_item_id is None:
continue
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
if item is None:
continue
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
item.quantity_reserved -= quantity
item.quantity_on_hand -= quantity
InventoryService._recalculate_available(item)
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type="consumption",
quantity=quantity,
reason="estimate_consumed",
reference_type="repair_estimate",
reference_id=estimate.id,
note=f"Kostenvoranschlag {estimate.estimate_number}",
actor_user_id=actor_user_id,
)
@staticmethod
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
@staticmethod
def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
before_data = {"id": category.id, "name": category.name, "slug": category.slug}
category.name = payload.name
category.slug = _slugify(payload.name)
category.description = payload.description
return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
@staticmethod
def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
location = InventoryLocation(name=payload.name, description=payload.description)
return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
@staticmethod
def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
before_data = {"id": location.id, "name": location.name}
location.name = payload.name
location.description = payload.description
return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
@staticmethod
def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
supplier = InventorySupplier(**payload.model_dump())
return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
@staticmethod
def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
before_data = {"id": supplier.id, "name": supplier.name}
for key, value in payload.model_dump().items():
setattr(supplier, key, value)
return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
@staticmethod
def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
label = getattr(entity, "name", "")
before_data = {"id": entity.id, "name": label}
db.delete(entity)
db.commit()
write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
@staticmethod
def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
for key, value in payload.model_dump(exclude={"sku"}).items():
setattr(item, key, value)
InventoryService._recalculate_available(item)
@staticmethod
def _recalculate_available(item: InventoryItem) -> None:
item.quantity_available = item.quantity_on_hand - item.quantity_reserved
@staticmethod
def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
existing = InventoryRepository.get_item_by_sku(db, sku)
if existing is not None and existing.id != item_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
@staticmethod
def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
if payload.quantity <= 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
@staticmethod
def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
if quantity != quantity.to_integral_value():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
)
normalized = int(quantity)
if normalized <= 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
return normalized
@staticmethod
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
InventoryService._recalculate_available(item)
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type=movement_type,
quantity=payload.quantity,
reason=payload.reason,
reference_type=payload.reference_type,
reference_id=payload.reference_id,
note=payload.note,
actor_user_id=actor_user_id,
)
@staticmethod
def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
InventoryService._recalculate_available(item)
db.commit()
db.refresh(item)
updated = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action=action,
entity_type="inventory_items",
entity_id=updated.id,
entity_label=_item_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(updated),
)
return updated
@staticmethod
def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
try:
db.add(entity)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
db.refresh(entity)
label = getattr(entity, "name", "")
write_audit_log(
db,
action=action,
entity_type=entity.__tablename__,
entity_id=entity.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
after_data={"id": entity.id, "name": label},
)
return entity

View file

@ -0,0 +1,251 @@
import re
import uuid
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
KnowledgeDeviceCreate,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteUpdate,
)
from app.storage import get_storage_service
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
def slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = normalized.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
return normalized or uuid.uuid4().hex[:10]
def unique_slug(db: Session, base: str, exists) -> str:
slug = slugify(base)
candidate = slug
index = 2
while exists(db, candidate):
candidate = f"{slug}-{index}"
index += 1
return candidate
def parse_tags(value: str) -> list[str]:
seen: set[str] = set()
tags: list[str] = []
for item in value.split(","):
tag = item.strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
detail = str(exc) or "Ungültige Datei"
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=status_code, detail=detail)
class KnowledgeService:
@staticmethod
def create_manufacturer(db: Session, payload: KnowledgeManufacturerCreate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
slug = unique_slug(db, payload.name, lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None)
manufacturer = KnowledgeManufacturer(name=payload.name, slug=slug, website=str(payload.website or ""), notes=payload.notes)
db.add(manufacturer)
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def update_manufacturer(db: Session, manufacturer: KnowledgeManufacturer, payload: KnowledgeManufacturerUpdate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name, manufacturer.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
manufacturer.name = payload.name
manufacturer.slug = unique_slug(
db,
payload.name,
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value, manufacturer.id) is not None,
)
manufacturer.website = str(payload.website or "")
manufacturer.notes = payload.notes
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def create_device(db: Session, payload: KnowledgeDeviceCreate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
device = KnowledgeDevice(slug=slug, **payload.model_dump())
db.add(device)
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def update_device(db: Session, device: KnowledgeDevice, payload: KnowledgeDeviceUpdate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug, exclude_id=device.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
for key, value in payload.model_dump().items():
setattr(device, key, value)
device.slug = slug
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def create_document(db: Session, payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
document = KnowledgeDocument(slug=slug, external_url=str(payload.external_url or ""), **payload.model_dump(exclude={"external_url"}))
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
async def upload_document(
db: Session,
*,
file: UploadFile,
payload: KnowledgeDocumentCreate,
) -> KnowledgeDocument:
KnowledgeService._validate_upload_links(db, payload.manufacturer_id, payload.device_id)
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
original_name = file.filename or ""
mime_type = file.content_type
try:
metadata = storage_service.save_file(
namespace=f"knowledge/documents/{payload.manufacturer_id}",
content=content,
original_filename=original_name,
mime_type=mime_type,
)
except StorageValidationError as exc:
raise storage_validation_error(exc) from exc
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
document = KnowledgeDocument(
slug=slug,
external_url=str(payload.external_url or ""),
file_name=metadata.original_filename,
file_path=metadata.storage_key,
mime_type=metadata.mime_type,
file_size=metadata.size,
checksum_sha256=metadata.checksum_sha256,
**payload.model_dump(exclude={"external_url"}),
)
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def update_document(db: Session, document: KnowledgeDocument, payload: KnowledgeDocumentUpdate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value, document.id) is not None)
for key, value in payload.model_dump(exclude={"external_url"}).items():
setattr(document, key, value)
document.external_url = str(payload.external_url or "")
document.slug = slug
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def create_note(db: Session, payload: KnowledgeNoteCreate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
note = KnowledgeNote(**payload.model_dump())
db.add(note)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def open_document_file(document: KnowledgeDocument):
if not document.file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
try:
return get_storage_service().open_file(document.file_path)
except StorageFileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def delete_document_file(document: KnowledgeDocument) -> None:
if not document.file_path:
return
KnowledgeService.delete_storage_key(document.file_path)
@staticmethod
def delete_storage_key(storage_key: str) -> None:
try:
get_storage_service().delete_file(storage_key)
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
for key, value in payload.model_dump().items():
setattr(note, key, value)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
if not manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
if device_id is not None:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if device.manufacturer_id != manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
@staticmethod
def _validate_optional_links(db: Session, manufacturer_id: int | None, device_id: int | None) -> None:
if manufacturer_id is not None and KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
if device_id is not None:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if manufacturer_id is not None and device.manufacturer_id != manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
@staticmethod
def _validate_upload_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
if not manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
if device_id is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie ein Gerät aus.")
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
if manufacturer is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Der ausgewählte Hersteller existiert nicht.")
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Das ausgewählte Gerät existiert nicht.")
if device.manufacturer_id != manufacturer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Das ausgewählte Gerät gehört nicht zum ausgewählten Hersteller.",
)

View file

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

View file

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

View file

@ -0,0 +1,662 @@
from datetime import UTC, datetime
from decimal import Decimal
from html import escape
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairStatusUpdate
from app.schemas.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateItemResponse,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateItemPayload,
RepairEstimateUpdate,
calculate_item_total,
calculate_tax,
)
from app.services.audit_service import write_audit_log
from app.services.inventory_service import InventoryService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.system_settings_service import SystemSettingsService
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _estimate_label(estimate: RepairEstimate) -> str:
return f"{estimate.estimate_number} · {estimate.title}"
def _money(cents: int, currency: str = "EUR") -> str:
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
return {
"id": estimate.id,
"repair_id": estimate.repair_id,
"estimate_number": estimate.estimate_number,
"status": estimate.status,
"title": estimate.title,
"currency": estimate.currency,
"valid_until": estimate.valid_until,
"sent_at": estimate.sent_at,
"approved_at": estimate.approved_at,
"declined_at": estimate.declined_at,
"created_by_user_id": estimate.created_by_user_id,
"inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None],
}
ESTIMATE_STATUS_LABELS = {
"draft": "Entwurf",
"sent": "Wartet auf Freigabe",
"approved": "Freigegeben",
"declined": "Abgelehnt",
"expired": "Abgelaufen",
"cancelled": "Storniert",
"revoked": "Kostenvoranschlag wird überarbeitet",
}
class RepairEstimateService:
@staticmethod
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
estimate = RepairEstimate(
repair_id=repair.id,
estimate_number=estimate_number,
status="draft",
created_by_user_id=actor.id,
)
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
RepairEstimateService._apply_payload(db, estimate, payload)
try:
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
except IntegrityError:
db.rollback()
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.create",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
after_data=_audit_estimate_data(estimate),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request)
return estimate
@staticmethod
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
before_data = _audit_estimate_data(estimate)
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
was_sent = estimate.status == "sent"
if was_sent:
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
RepairEstimateService._apply_payload(db, estimate, payload)
if was_sent:
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
write_audit_log(
db,
action="inventory.estimate.reserve",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.update",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_estimate_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request)
return updated
@staticmethod
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
if estimate.status not in {"draft", "sent", "cancelled"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
before_data = _audit_estimate_data(estimate)
label = _estimate_label(estimate)
if estimate.status == "sent":
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
write_audit_log(
db,
action="inventory.estimate.release",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=label,
actor=actor,
request=request,
metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number},
)
RepairEstimateRepository.delete(db, estimate)
write_audit_log(
db,
action="repair_estimates.delete",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
if not repair.customer_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
was_sent = estimate.status == "sent"
if not was_sent:
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
link = RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
public_status_url = link.public_status_path
estimate = RepairEstimateRepository.mark_sent(db, estimate)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
if not was_sent:
write_audit_log(
db,
action="inventory.estimate.reserve",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_mail",
channel="email",
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
template="repair_estimate",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
write_audit_log(
db,
action="repair_estimates.send",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
was_sent = estimate.status == "sent"
if was_sent:
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
estimate.status = "cancelled"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action="repair_estimates.cancel",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
if was_sent:
write_audit_log(
db,
action="inventory.estimate.release",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status != "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
before_data = _audit_estimate_data(estimate)
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
estimate.status = "revoked"
RepairEstimateRepository.add_event(
db,
estimate_id=estimate.id,
event_type="revoked",
actor_type="user",
actor_user_id=actor.id,
note="Freigabe zurückgenommen",
commit=False,
)
db.commit()
db.refresh(estimate)
if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
RepairRepository.update_status(
db,
repair,
RepairStatusUpdate(
status="waiting_for_customer",
note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
),
actor_user_id=actor.id,
)
link = RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
subject = "Kostenvoranschlag wurde zurückgenommen"
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured and repair.customer_email:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=subject,
text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_revoked_mail",
channel="email",
recipient=repair.customer_email,
subject=subject,
template="repair_estimate_revoked",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.revoke",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_estimate_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
)
write_audit_log(
db,
action="inventory.estimate.release",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
)
return updated
@staticmethod
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
if estimate is None:
return None
return PublicEstimateResponse(
estimate_number=estimate.estimate_number,
status=estimate.status,
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
title=estimate.title,
customer_message=estimate.customer_message,
subtotal_cents=estimate.subtotal_cents,
tax_cents=estimate.tax_cents,
total_cents=estimate.total_cents,
currency=estimate.currency,
valid_until=estimate.valid_until,
items=[
PublicEstimateItemResponse(
item_type=item.item_type,
inventory_snapshot_name=item.inventory_snapshot_name,
inventory_snapshot_sku=item.inventory_snapshot_sku,
inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer,
inventory_snapshot_part_number=item.inventory_snapshot_part_number,
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price_cents=item.unit_price_cents,
total_cents=item.total_cents,
)
for item in estimate.items
],
)
@staticmethod
def customer_decision(
db: Session,
repair: Repair,
estimate: RepairEstimate,
decision: str,
payload: PublicEstimateDecisionRequest,
*,
request: Request | None,
) -> RepairEstimate:
if estimate.status != "sent":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
now = datetime.now(UTC)
estimate.customer_response_message = payload.message
if decision == "approve":
estimate.status = "approved"
estimate.approved_at = now
event_type = "approved"
repair_status = "approved"
note = "Kunde hat den Kostenvoranschlag freigegeben"
audit_action = "repair_estimates.approve"
elif decision == "decline":
estimate.status = "declined"
estimate.declined_at = now
event_type = "declined"
repair_status = "waiting_for_customer"
note = "Kunde hat den Kostenvoranschlag abgelehnt"
audit_action = "repair_estimates.decline"
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
else:
event_type = "question"
repair_status = "waiting_for_customer"
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
audit_action = "repair_estimates.question"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action=audit_action,
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
)
if decision == "decline":
write_audit_log(
db,
action="inventory.estimate.release",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
request=request,
metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
estimate.title = payload.title
estimate.customer_message = payload.customer_message
estimate.internal_note = payload.internal_note
estimate.tax_rate_percent = payload.tax_rate_percent
estimate.currency = payload.currency
estimate.valid_until = payload.valid_until
items, subtotal = RepairEstimateService._build_items(db, payload.items)
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
estimate.subtotal_cents = subtotal
estimate.tax_cents = tax_cents
estimate.total_cents = subtotal + tax_cents
estimate.items = items
@staticmethod
def _build_items(
db: Session,
payload_items: list[RepairEstimateItemPayload],
) -> tuple[list[RepairEstimateItem], int]:
items: list[RepairEstimateItem] = []
subtotal = 0
for index, payload in enumerate(payload_items, start=1):
item_type = payload.item_type
title = payload.title
description = payload.description
unit = payload.unit
unit_price_cents = payload.unit_price_cents
inventory_item_id = payload.inventory_item_id
inventory_snapshot_name = ""
inventory_snapshot_sku = ""
inventory_snapshot_manufacturer = None
inventory_snapshot_part_number = None
if inventory_item_id is not None:
inventory_item = InventoryRepository.get_item(db, inventory_item_id)
if inventory_item is None or not inventory_item.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden")
item_type = "part"
title = inventory_item.name
unit = inventory_item.unit
inventory_snapshot_name = inventory_item.name
inventory_snapshot_sku = inventory_item.sku
inventory_snapshot_manufacturer = inventory_item.manufacturer
inventory_snapshot_part_number = inventory_item.manufacturer_part_number
if not payload.inventory_price_overridden:
unit_price_cents = inventory_item.selling_price_cents or 0
total = calculate_item_total(payload.quantity, unit_price_cents)
subtotal += total
items.append(
RepairEstimateItem(
inventory_item_id=inventory_item_id,
inventory_snapshot_name=inventory_snapshot_name,
inventory_snapshot_sku=inventory_snapshot_sku,
inventory_snapshot_manufacturer=inventory_snapshot_manufacturer,
inventory_snapshot_part_number=inventory_snapshot_part_number,
position=index,
item_type=item_type,
title=title,
description=description,
quantity=payload.quantity,
unit=unit,
unit_price_cents=unit_price_cents,
total_cents=total,
)
)
return items, subtotal
@staticmethod
def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]:
overrides: list[dict] = []
for payload in payload_items:
if payload.inventory_item_id is None or not payload.inventory_price_overridden:
continue
inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id)
if inventory_item is None:
continue
default_price = inventory_item.selling_price_cents or 0
if payload.unit_price_cents != default_price:
overrides.append({
"inventory_item_id": inventory_item.id,
"label": f"{inventory_item.sku} · {inventory_item.name}",
"default_price_cents": default_price,
"override_price_cents": payload.unit_price_cents,
})
return overrides
@staticmethod
def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None:
for override in overrides:
write_audit_log(
db,
action="inventory.estimate.price_override",
entity_type="inventory_items",
entity_id=override["inventory_item_id"],
entity_label=override["label"],
actor=actor,
request=request,
metadata={
"estimate_id": estimate.id,
"estimate_number": estimate.estimate_number,
"default_price_cents": override["default_price_cents"],
"override_price_cents": override["override_price_cents"],
},
)
@staticmethod
def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
seen_item_ids: set[int] = set()
for estimate_item in estimate.items:
if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids:
continue
seen_item_ids.add(estimate_item.inventory_item_id)
inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level:
continue
write_audit_log(
db,
action="inventory.stock.low",
entity_type="inventory_items",
entity_id=inventory_item.id,
entity_label=f"{inventory_item.sku} · {inventory_item.name}",
actor=actor,
request=request,
metadata={
"estimate_id": estimate.id,
"estimate_number": estimate.estimate_number,
"quantity_available": inventory_item.quantity_available,
"reorder_level": inventory_item.reorder_level,
},
)
@staticmethod
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n"
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
</td></tr></table></body></html>"""
@staticmethod
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
f"Reparatur: {repair.repair_number}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _revoked_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Status ansehen</a></p>
</td></tr></table></body></html>"""

View file

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

View file

@ -0,0 +1,169 @@
import hashlib
import hmac
import secrets
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.schemas.repair import (
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
RepairPublicStatusHistoryItem,
RepairPublicStatusResponse,
)
from app.services.audit_service import write_audit_log
from app.services.repair_status_labels import STATUS_LABELS
from app.services.system_settings_service import SystemSettingsService
def normalize_token(token: str) -> str:
return token.strip()
def repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def public_status_path(db: Session, token: str) -> str:
normalized_token = normalize_token(token)
path = f"/status/{normalized_token}"
base_url = SystemSettingsService.get_public_repair_status_base_url(db)
if not base_url:
return path
return f"{base_url}/{normalized_token}"
class RepairPublicLinkService:
@staticmethod
def hash_token(token: str) -> str:
normalized_token = normalize_token(token)
return hmac.new(settings.secret_key.encode("utf-8"), normalized_token.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def create_token() -> str:
return secrets.token_urlsafe(48)
@staticmethod
def response_from_link(public_link: RepairPublicAccessToken | None) -> RepairPublicLinkResponse:
if public_link is None:
return RepairPublicLinkResponse(is_active=False)
return RepairPublicLinkResponse(
is_active=public_link.is_active,
token_hint=public_link.token_hint,
expires_at=public_link.expires_at,
last_used_at=public_link.last_used_at,
created_at=public_link.created_at,
revoked_at=public_link.revoked_at,
)
@staticmethod
def get(db: Session, repair: Repair) -> RepairPublicLinkResponse:
return RepairPublicLinkService.response_from_link(RepairRepository.get_active_public_link(db, repair.id))
@staticmethod
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
return RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.create",
)
@staticmethod
def create_with_audit(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
audit_action: str,
) -> RepairPublicLinkCreateResponse:
token = RepairPublicLinkService.create_token()
public_link = RepairRepository.create_public_link(
db,
repair_id=repair.id,
token_hash=RepairPublicLinkService.hash_token(token),
token_hint=token[-6:],
)
write_audit_log(
db,
action=audit_action,
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
metadata={"token_hint": public_link.token_hint},
)
return RepairPublicLinkCreateResponse(
is_active=True,
token=token,
token_hint=public_link.token_hint,
expires_at=public_link.expires_at,
last_used_at=public_link.last_used_at,
created_at=public_link.created_at,
revoked_at=public_link.revoked_at,
public_status_path=public_status_path(db, token),
)
@staticmethod
def revoke(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkResponse:
active_link = RepairRepository.get_active_public_link(db, repair.id)
RepairRepository.revoke_public_links(db, repair_id=repair.id)
write_audit_log(
db,
action="repairs.public_link.revoke",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
metadata={"token_hint": active_link.token_hint if active_link else None},
)
return RepairPublicLinkResponse(is_active=False, token_hint=active_link.token_hint if active_link else None, revoked_at=datetime.now(UTC))
@staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id)
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
from app.services.repair_estimate_service import RepairEstimateService
response = RepairPublicStatusResponse(
repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
device_manufacturer=repair.device_manufacturer,
device_model=repair.device_model,
status_history_public=[
RepairPublicStatusHistoryItem(
status=item.new_status,
status_label=STATUS_LABELS.get(item.new_status, item.new_status),
created_at=item.created_at,
)
for item in history
],
updated_at=repair.updated_at,
estimate=RepairEstimateService.public_response(estimate),
)
RepairRepository.mark_public_link_used(db, public_link)
return response
@staticmethod
def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
return public_link

View file

@ -0,0 +1,171 @@
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate
from app.services.audit_service import sanitize, write_audit_log
from app.services.repair_notification_service import RepairNotificationService
def repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
class RepairService:
@staticmethod
def create(db: Session, payload: RepairCreate, *, actor: User | None, request: Request | None = None) -> Repair:
RepairService._validate_customer(db, payload.customer_id)
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
try:
repair = RepairRepository.create(db, payload, repair_number=repair_number)
except IntegrityError:
db.rollback()
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
repair = RepairRepository.create(db, payload, repair_number=repair_number)
write_audit_log(
db,
action="repairs.create",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
after_data=repair,
)
return repair
@staticmethod
def update(db: Session, repair: Repair, payload: RepairUpdate, *, actor: User, request: Request) -> Repair:
RepairService._validate_customer(db, payload.customer_id)
before_data = sanitize(repair)
updated = RepairRepository.update(db, repair, payload)
write_audit_log(
db,
action="repairs.update",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
)
return updated
@staticmethod
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor: User, request: Request) -> Repair:
before_data = sanitize(repair)
updated = RepairRepository.update_status(db, repair, payload, actor_user_id=actor.id)
write_audit_log(
db,
action="repairs.status_update",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status},
)
try:
RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request)
except Exception:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
metadata={"reason": "unexpected_notification_error"},
)
return updated
@staticmethod
def cancel(db: Session, repair: Repair, *, actor: User, request: Request) -> Repair:
before_data = sanitize(repair)
updated = RepairRepository.cancel(db, repair, actor_user_id=actor.id)
write_audit_log(
db,
action="repairs.cancel",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
)
return updated
@staticmethod
def create_from_intake(db: Session, payload: RepairIntakePayload, *, request: Request | None = None) -> Repair:
event = RepairRepository.create_intake_event(
db,
external_source="website",
external_reference=payload.source_reference,
payload=payload.model_dump(mode="json"),
status="received",
)
try:
repair_payload = RepairCreate(
customer_id=None,
contact_id=None,
status="new",
priority="normal",
source="website",
source_reference=payload.source_reference,
customer_name=payload.name,
customer_email=payload.email,
customer_phone=payload.phone,
device_manufacturer=payload.manufacturer,
device_model=payload.model,
device_serial_number=payload.serial_number,
device_type=payload.device_type,
accessories=payload.accessories,
fault_description=payload.fault_description,
previous_work=payload.previous_work,
device_opened=payload.device_opened,
intake_notes="Website Intake",
)
repair = RepairService.create(db, repair_payload, actor=None, request=request)
RepairRepository.update_intake_event(db, event, status="processed", repair_id=repair.id)
write_audit_log(
db,
action="repairs.intake_processed",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
request=request,
metadata={"external_source": "website", "external_reference": payload.source_reference},
)
return repair
except Exception as exc:
RepairRepository.update_intake_event(db, event, status="failed", error_message="Intake konnte nicht verarbeitet werden")
write_audit_log(
db,
action="repairs.intake_failed",
entity_type="repairs",
request=request,
metadata={"external_source": "website", "external_reference": payload.source_reference},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reparaturannahme konnte nicht verarbeitet werden",
) from exc
@staticmethod
def _validate_customer(db: Session, customer_id: int | None) -> None:
if customer_id is None:
return
if CustomerRepository.get_by_id(db, customer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kunde nicht gefunden")

View file

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

View file

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

View file

@ -0,0 +1,3 @@
from app.storage.service import StorageService, get_storage_service
__all__ = ["StorageService", "get_storage_service"]

View file

@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from pathlib import Path
from app.storage.schemas import FileMetadata, StoredFileMetadata
class StorageProvider(ABC):
@abstractmethod
def save_file(
self,
*,
namespace: str,
content: bytes,
original_filename: str,
stored_filename: str,
mime_type: str,
) -> StoredFileMetadata:
raise NotImplementedError
@abstractmethod
def open_file(self, storage_key: str) -> Path:
raise NotImplementedError
@abstractmethod
def delete_file(self, storage_key: str) -> None:
raise NotImplementedError
@abstractmethod
def file_exists(self, storage_key: str) -> bool:
raise NotImplementedError
@abstractmethod
def get_file_metadata(self, storage_key: str) -> FileMetadata:
raise NotImplementedError

View file

@ -0,0 +1,14 @@
class StorageError(Exception):
"""Base exception for storage operations."""
class StorageConfigurationError(StorageError):
pass
class StorageValidationError(StorageError):
pass
class StorageFileNotFoundError(StorageError):
pass

View file

@ -0,0 +1,142 @@
from pathlib import Path
import hashlib
from app.storage.base import StorageProvider
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
from app.storage.schemas import FileMetadata, StoredFileMetadata
class LocalDiskStorageProvider(StorageProvider):
def __init__(
self,
*,
base_path: str,
legacy_base_paths: list[str] | None = None,
) -> None:
self.base_path = Path(base_path).expanduser().resolve()
self.legacy_base_paths = [
Path(path).expanduser().resolve()
for path in legacy_base_paths or []
if path
]
self._ensure_layout()
def save_file(
self,
*,
namespace: str,
content: bytes,
original_filename: str,
stored_filename: str,
mime_type: str,
) -> StoredFileMetadata:
if Path(stored_filename).name != stored_filename or ".." in Path(stored_filename).parts:
raise StorageValidationError("Ungültiger gespeicherter Dateiname")
namespace_path = self._safe_namespace_path(namespace)
namespace_path.mkdir(parents=True, exist_ok=True)
absolute_path = self._safe_child(namespace_path / stored_filename)
absolute_path.write_bytes(content)
relative_path = absolute_path.relative_to(self.base_path).as_posix()
checksum = self._checksum(absolute_path)
return StoredFileMetadata(
storage_key=relative_path,
original_filename=original_filename,
stored_filename=stored_filename,
relative_path=relative_path,
absolute_path=absolute_path,
mime_type=mime_type,
size=len(content),
checksum_sha256=checksum,
)
def open_file(self, storage_key: str) -> Path:
path = self._resolve_storage_key(storage_key)
if not path.exists() or not path.is_file():
raise StorageFileNotFoundError("Datei nicht gefunden")
return path
def delete_file(self, storage_key: str) -> None:
path = self._resolve_storage_key(storage_key)
if not path.exists():
return
if not path.is_file():
raise StorageValidationError("Storage-Key verweist nicht auf eine Datei")
path.unlink()
def file_exists(self, storage_key: str) -> bool:
try:
path = self._resolve_storage_key(storage_key)
except StorageValidationError:
return False
return path.exists() and path.is_file()
def get_file_metadata(self, storage_key: str) -> FileMetadata:
path = self.open_file(storage_key)
return FileMetadata(
storage_key=storage_key,
relative_path=self._relative_storage_key(path),
absolute_path=path,
size=path.stat().st_size,
checksum_sha256=self._checksum(path),
)
def _ensure_layout(self) -> None:
for relative_path in [
"knowledge/documents",
"knowledge/thumbnails",
"customers",
"projects",
"tickets",
"repairs",
"imports",
"temp",
]:
(self.base_path / relative_path).mkdir(parents=True, exist_ok=True)
def _safe_namespace_path(self, namespace: str) -> Path:
if namespace.startswith("/") or ".." in Path(namespace).parts:
raise StorageValidationError("Ungültiger Storage-Namespace")
return self._safe_child(self.base_path / namespace)
def _safe_child(self, path: Path) -> Path:
resolved = path.resolve()
if self.base_path != resolved and self.base_path not in resolved.parents:
raise StorageValidationError("Ungültiger Storage-Pfad")
return resolved
def _resolve_storage_key(self, storage_key: str) -> Path:
raw_path = Path(storage_key)
candidate_paths: list[Path] = []
if raw_path.is_absolute():
candidate_paths.append(raw_path)
else:
candidate_paths.append(self.base_path / raw_path)
candidate_paths.extend(legacy_base / raw_path for legacy_base in self.legacy_base_paths)
for candidate in candidate_paths:
resolved = candidate.expanduser().resolve()
if self._is_allowed_path(resolved):
return resolved
raise StorageValidationError("Ungültiger Storage-Key")
def _is_allowed_path(self, path: Path) -> bool:
roots = [self.base_path, *self.legacy_base_paths]
return any(root == path or root in path.parents for root in roots)
def _relative_storage_key(self, path: Path) -> str:
for root in [self.base_path, *self.legacy_base_paths]:
try:
return path.relative_to(root).as_posix()
except ValueError:
continue
return path.name
@staticmethod
def _checksum(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()

View file

@ -0,0 +1,23 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class StoredFileMetadata:
storage_key: str
original_filename: str
stored_filename: str
relative_path: str
absolute_path: Path
mime_type: str
size: int
checksum_sha256: str
@dataclass(frozen=True)
class FileMetadata:
storage_key: str
relative_path: str
absolute_path: Path
size: int
checksum_sha256: str

View file

@ -0,0 +1,103 @@
from functools import lru_cache
from pathlib import Path
import hashlib
import mimetypes
import re
import uuid
from app.core.config import settings
from app.storage.base import StorageProvider
from app.storage.exceptions import StorageConfigurationError, StorageValidationError
from app.storage.local import LocalDiskStorageProvider
from app.storage.schemas import FileMetadata, StoredFileMetadata
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"}
ALLOWED_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
"application/zip",
"application/x-zip-compressed",
}
class StorageService:
def __init__(self, provider: StorageProvider, *, max_upload_mb: int) -> None:
self.provider = provider
self.max_upload_mb = max(1, max_upload_mb)
def save_file(
self,
*,
namespace: str,
content: bytes,
original_filename: str,
mime_type: str | None = None,
) -> StoredFileMetadata:
self.validate_file_size(len(content))
safe_name = self.safe_filename(original_filename)
resolved_mime_type = mime_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
self.validate_file_type(safe_name, resolved_mime_type)
stored_filename = f"{uuid.uuid4().hex}-{safe_name}"
return self.provider.save_file(
namespace=namespace,
content=content,
original_filename=original_filename,
stored_filename=stored_filename,
mime_type=resolved_mime_type,
)
def open_file(self, storage_key: str) -> Path:
return self.provider.open_file(storage_key)
def delete_file(self, storage_key: str) -> None:
self.provider.delete_file(storage_key)
def file_exists(self, storage_key: str) -> bool:
return self.provider.file_exists(storage_key)
def get_file_metadata(self, storage_key: str) -> FileMetadata:
return self.provider.get_file_metadata(storage_key)
@staticmethod
def calculate_checksum(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
@staticmethod
def safe_filename(file_name: str) -> str:
name = Path(file_name or "").name.strip()
if not name:
raise StorageValidationError("Dateiname fehlt")
stem = Path(name).stem.strip().lower()
stem = stem.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
suffix = Path(name).suffix.lower()
safe_stem = stem or uuid.uuid4().hex[:10]
return f"{safe_stem}{suffix}" if suffix else safe_stem
def validate_file_type(self, file_name: str, mime_type: str) -> None:
extension = Path(file_name).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise StorageValidationError("Dateityp ist nicht erlaubt")
if mime_type not in ALLOWED_MIME_TYPES:
raise StorageValidationError("MIME-Type ist nicht erlaubt")
def validate_file_size(self, size: int) -> None:
if size <= 0:
raise StorageValidationError("Upload-Datei ist leer")
if size > self.max_upload_mb * 1024 * 1024:
raise StorageValidationError("Upload-Datei ist zu groß")
@lru_cache
def get_storage_service() -> StorageService:
if settings.storage_provider != "local":
raise StorageConfigurationError("Nur STORAGE_PROVIDER=local ist aktuell implementiert")
provider = LocalDiskStorageProvider(
base_path=settings.storage_base_path,
legacy_base_paths=[settings.knowledge_storage_path],
)
return StorageService(provider, max_upload_mb=settings.storage_max_upload_mb)

View file

@ -7,7 +7,7 @@ services:
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API APP_NAME: Hermes API
APP_VERSION: 0.1.0 APP_VERSION: 0.9.1
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
@ -17,6 +17,17 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes} JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
STORAGE_BASE_PATH: ${STORAGE_BASE_PATH:-/data/storage}
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
ports: ports:
- "8000:8000" - "8000:8000"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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