Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c917df515 | ||
|
|
7835d3ca75 | ||
|
|
46eeaa1f2e | ||
|
|
ffffb68898 | ||
|
|
8d37eb29b3 | ||
|
|
31bdc1047d | ||
|
|
663bdc41b1 | ||
|
|
fb5c2cc26a | ||
|
|
abc6e308dd | ||
|
|
111d8d12df | ||
|
|
4436fe5f73 | ||
|
|
6e7e75f864 |
111 changed files with 10150 additions and 85 deletions
|
|
@ -20,6 +20,7 @@ STORAGE_PROVIDER=local
|
||||||
STORAGE_BASE_PATH=/data/storage
|
STORAGE_BASE_PATH=/data/storage
|
||||||
STORAGE_MAX_UPLOAD_MB=50
|
STORAGE_MAX_UPLOAD_MB=50
|
||||||
STORAGE_HOST_PATH=./storage
|
STORAGE_HOST_PATH=./storage
|
||||||
|
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
|
||||||
|
|
||||||
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
||||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||||
|
|
@ -44,3 +45,8 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||||
|
LEXWARE_ENABLED=false
|
||||||
|
LEXWARE_API_BASE_URL=https://api.lexware.io
|
||||||
|
LEXWARE_API_KEY=
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -39,6 +39,13 @@ out/
|
||||||
# ===========================
|
# ===========================
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Runtime storage
|
||||||
|
# ===========================
|
||||||
|
storage/
|
||||||
|
/backups/
|
||||||
|
*.dump
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Coverage
|
# Coverage
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
|
||||||
262
ARCHITECTURE.md
262
ARCHITECTURE.md
|
|
@ -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`.
|
||||||
|
|
@ -219,6 +271,72 @@ Tabellen:
|
||||||
- `repair_public_access_tokens`
|
- `repair_public_access_tokens`
|
||||||
- `repair_notification_events`
|
- `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:
|
Hermes-Endpunkte:
|
||||||
|
|
||||||
- `GET /repairs`
|
- `GET /repairs`
|
||||||
|
|
@ -322,6 +440,124 @@ Hermes-Endpunkte:
|
||||||
|
|
||||||
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.
|
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
|
### Website Repair Intake
|
||||||
|
|
||||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
||||||
|
|
@ -741,6 +977,8 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl
|
||||||
|
|
||||||
Neue Uploads werden im Namespace `knowledge/documents/<manufacturer_id>` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien.
|
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:
|
Konfiguration:
|
||||||
|
|
||||||
- `STORAGE_PROVIDER`, Default `local`
|
- `STORAGE_PROVIDER`, Default `local`
|
||||||
|
|
@ -822,9 +1060,29 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
|
||||||
|
|
||||||
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
|
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
|
||||||
|
|
||||||
`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
|
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
|
||||||
|
|
||||||
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
|
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
|
||||||
|
|
||||||
|
### Backup-Modul ab v0.9.1
|
||||||
|
|
||||||
|
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
|
||||||
|
|
||||||
|
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
|
||||||
|
- Hermes-API: `backend/hermes/app/api/backups.py`
|
||||||
|
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
|
||||||
|
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
|
||||||
|
- Athena-BFF: `/api/backups/...`
|
||||||
|
|
||||||
|
Sicherheitsregeln:
|
||||||
|
|
||||||
|
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
|
||||||
|
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
|
||||||
|
- Hermes anonymisiert den Datenbank-Host im Manifest.
|
||||||
|
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
|
||||||
|
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
|
||||||
|
|
||||||
|
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
|
||||||
|
|
||||||
### Knowledge-RBAC
|
### Knowledge-RBAC
|
||||||
|
|
||||||
|
|
|
||||||
161
README-DEV.md
161
README-DEV.md
|
|
@ -42,10 +42,15 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
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.
|
`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.
|
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
||||||
|
|
||||||
## Docker Netzwerk
|
## Docker Netzwerk
|
||||||
|
|
@ -129,6 +134,46 @@ STORAGE_BASE_PATH=/data/storage
|
||||||
|
|
||||||
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
|
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
|
## Knowledge Workflow
|
||||||
|
|
||||||
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
||||||
|
|
@ -154,6 +199,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
|
||||||
|
|
||||||
- `/api/repairs`
|
- `/api/repairs`
|
||||||
- `/api/repairs/[id]`
|
- `/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]/status`
|
||||||
- `/api/repairs/[id]/history`
|
- `/api/repairs/[id]/history`
|
||||||
- `/api/repairs/[id]/public-link`
|
- `/api/repairs/[id]/public-link`
|
||||||
|
|
@ -175,6 +224,41 @@ Statuslink-Konzept:
|
||||||
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
||||||
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
- 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:
|
Benachrichtigungen:
|
||||||
|
|
||||||
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
||||||
|
|
@ -185,6 +269,83 @@ Benachrichtigungen:
|
||||||
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
|
- `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.
|
- 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:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
- `/status/<token>`
|
- `/status/<token>`
|
||||||
|
|
|
||||||
97
ROADMAP.md
97
ROADMAP.md
|
|
@ -123,13 +123,102 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Passwort wird nicht an Athena zurueckgegeben
|
- Passwort wird nicht an Athena zurueckgegeben
|
||||||
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
|
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
|
||||||
|
|
||||||
## v0.8.5 - Kundenportal, 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
|
- `/portal/login` fuer spaeteren Kundenlogin
|
||||||
- Separates Authentifizierungsmodell fuer Kunden
|
- Separates Authentifizierungsmodell fuer Kunden
|
||||||
- Keine Vermischung mit internen Olympus-Benutzern
|
- Keine Vermischung mit internen Olympus-Benutzern
|
||||||
|
|
||||||
## v0.9.0 - Tickets, geplant
|
## v0.9.3 - Tickets, geplant
|
||||||
|
|
||||||
- Ticketverwaltung
|
- Ticketverwaltung
|
||||||
- Status- und Prioritaetsmodell
|
- Status- und Prioritaetsmodell
|
||||||
|
|
@ -137,10 +226,10 @@ 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.10.0 - Integrationen Paperless/Lexoffice, geplant
|
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
|
||||||
|
|
||||||
- Paperless-ngx Connector fuer Wissensdokumente
|
- Paperless-ngx Connector fuer Wissensdokumente
|
||||||
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
|
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
|
||||||
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
||||||
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,8 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
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=
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import app.models.knowledge
|
||||||
import app.models.audit
|
import app.models.audit
|
||||||
import app.models.rbac
|
import app.models.rbac
|
||||||
import app.models.repair
|
import app.models.repair
|
||||||
|
import app.models.repair_estimate
|
||||||
|
import app.models.inventory
|
||||||
import app.models.system_setting
|
import app.models.system_setting
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""extend repair documents
|
||||||
|
|
||||||
|
Revision ID: a1c5f9e2d430
|
||||||
|
Revises: f2b8d4e6a910
|
||||||
|
Create Date: 2026-07-05 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "a1c5f9e2d430"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False)
|
||||||
|
op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True))
|
||||||
|
op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
|
||||||
|
op.create_foreign_key(
|
||||||
|
op.f("fk_repair_documents_uploaded_by_user_id_users"),
|
||||||
|
"repair_documents",
|
||||||
|
"users",
|
||||||
|
["uploaded_by_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents")
|
||||||
|
op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents")
|
||||||
|
op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents")
|
||||||
|
op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey")
|
||||||
|
op.drop_column("repair_documents", "updated_at")
|
||||||
|
op.drop_column("repair_documents", "uploaded_by_user_id")
|
||||||
|
op.drop_column("repair_documents", "note")
|
||||||
|
op.drop_column("repair_documents", "visibility")
|
||||||
|
op.drop_column("repair_documents", "checksum_sha256")
|
||||||
|
op.drop_column("repair_documents", "size_bytes")
|
||||||
|
op.drop_column("repair_documents", "mime_type")
|
||||||
|
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None)
|
||||||
|
op.drop_column("repair_documents", "stored_filename")
|
||||||
|
op.drop_column("repair_documents", "original_filename")
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""add repair estimate revoke permission
|
||||||
|
|
||||||
|
Revision ID: a7c3e9d4b821
|
||||||
|
Revises: f4a9c2d7e118
|
||||||
|
Create Date: 2026-07-05 15:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "a7c3e9d4b821"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION_NAME = "repair_estimates.revoke"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES (
|
||||||
|
:name,
|
||||||
|
'Kostenvoranschläge zurücknehmen',
|
||||||
|
'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
|
||||||
|
'repair_estimates'
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
module = excluded.module
|
||||||
|
"""
|
||||||
|
).bindparams(name=PERMISSION_NAME)
|
||||||
|
)
|
||||||
|
for role_name in ("administrator", "management"):
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles, permissions
|
||||||
|
WHERE roles.name = :role_name
|
||||||
|
AND permissions.name = :permission_name
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(role_name=role_name, permission_name=PERMISSION_NAME)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE name = :permission_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
).bindparams(permission_name=PERMISSION_NAME)
|
||||||
|
)
|
||||||
|
op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""create repair estimates
|
||||||
|
|
||||||
|
Revision ID: b4e8f1c7a205
|
||||||
|
Revises: a1c5f9e2d430
|
||||||
|
Create Date: 2026-07-05 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "b4e8f1c7a205"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "a1c5f9e2d430"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
ESTIMATE_PERMISSIONS = [
|
||||||
|
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
|
||||||
|
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
|
||||||
|
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
|
||||||
|
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
|
||||||
|
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"repair_estimates",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("estimate_number", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("customer_message", sa.Text(), server_default="", nullable=False),
|
||||||
|
sa.Column("internal_note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("subtotal_cents", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
|
||||||
|
sa.Column("tax_cents", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||||
|
sa.Column("valid_until", sa.Date(), nullable=True),
|
||||||
|
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("customer_response_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("estimate_number"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_repair_estimates_created_by_user_id"), "repair_estimates", ["created_by_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimates_estimate_number"), "repair_estimates", ["estimate_number"], unique=True)
|
||||||
|
op.create_index(op.f("ix_repair_estimates_repair_id"), "repair_estimates", ["repair_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimates_status"), "repair_estimates", ["status"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"repair_estimate_items",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("estimate_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("item_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("quantity", sa.Numeric(10, 2), server_default="1.00", nullable=False),
|
||||||
|
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
|
||||||
|
sa.Column("unit_price_cents", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_items_estimate_id"), "repair_estimate_items", ["estimate_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_items_item_type"), "repair_estimate_items", ["item_type"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"repair_estimate_events",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("estimate_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("actor_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_events_actor_type"), "repair_estimate_events", ["actor_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_events_actor_user_id"), "repair_estimate_events", ["actor_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_events_estimate_id"), "repair_estimate_events", ["estimate_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_estimate_events_event_type"), "repair_estimate_events", ["event_type"], unique=False)
|
||||||
|
|
||||||
|
for name, display_name, description, module in ESTIMATE_PERMISSIONS:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES (:name, :display_name, :description, :module)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
module = excluded.module
|
||||||
|
"""
|
||||||
|
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||||
|
)
|
||||||
|
|
||||||
|
role_permissions = {
|
||||||
|
"administrator": [item[0] for item in ESTIMATE_PERMISSIONS],
|
||||||
|
"management": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
|
||||||
|
"technician": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
|
||||||
|
"support": ["repair_estimates.read", "repair_estimates.send"],
|
||||||
|
}
|
||||||
|
for role_name, permission_names in role_permissions.items():
|
||||||
|
for permission_name in permission_names:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles, permissions
|
||||||
|
WHERE roles.name = :role_name
|
||||||
|
AND permissions.name = :permission_name
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE module = 'repair_estimates'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM permissions WHERE module = 'repair_estimates'")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_events_event_type"), table_name="repair_estimate_events")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_events_estimate_id"), table_name="repair_estimate_events")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_events_actor_user_id"), table_name="repair_estimate_events")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_events_actor_type"), table_name="repair_estimate_events")
|
||||||
|
op.drop_table("repair_estimate_events")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_items_item_type"), table_name="repair_estimate_items")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_items_estimate_id"), table_name="repair_estimate_items")
|
||||||
|
op.drop_table("repair_estimate_items")
|
||||||
|
op.drop_index(op.f("ix_repair_estimates_status"), table_name="repair_estimates")
|
||||||
|
op.drop_index(op.f("ix_repair_estimates_repair_id"), table_name="repair_estimates")
|
||||||
|
op.drop_index(op.f("ix_repair_estimates_estimate_number"), table_name="repair_estimates")
|
||||||
|
op.drop_index(op.f("ix_repair_estimates_created_by_user_id"), table_name="repair_estimates")
|
||||||
|
op.drop_table("repair_estimates")
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""add lexware foundation
|
||||||
|
|
||||||
|
Revision ID: c9d4e5f6a7b8
|
||||||
|
Revises: a7c3e9d4b821
|
||||||
|
Create Date: 2026-07-05 16:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "c9d4e5f6a7b8"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
LEXWARE_PERMISSIONS = [
|
||||||
|
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
||||||
|
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
||||||
|
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"lexware_sync_records",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("entity_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
|
||||||
|
sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
|
||||||
|
sa.Column("payload_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
|
||||||
|
|
||||||
|
op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
for name, display_name, description, module in LEXWARE_PERMISSIONS:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES (:name, :display_name, :description, :module)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
module = excluded.module
|
||||||
|
"""
|
||||||
|
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||||
|
)
|
||||||
|
|
||||||
|
role_permissions = {
|
||||||
|
"administrator": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||||
|
"management": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||||
|
"support": ["lexware.read"],
|
||||||
|
}
|
||||||
|
for role_name, permission_names in role_permissions.items():
|
||||||
|
for permission_name in permission_names:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles, permissions
|
||||||
|
WHERE roles.name = :role_name
|
||||||
|
AND permissions.name = :permission_name
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE module = 'lexware'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM permissions WHERE module = 'lexware'")
|
||||||
|
op.drop_column("repair_estimates", "lexware_synced_at")
|
||||||
|
op.drop_column("repair_estimates", "lexware_invoice_status")
|
||||||
|
op.drop_column("repair_estimates", "lexware_invoice_number")
|
||||||
|
op.drop_column("repair_estimates", "lexware_invoice_id")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
|
||||||
|
op.drop_table("lexware_sync_records")
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""add accounting export status
|
||||||
|
|
||||||
|
Revision ID: d2e3f4a5b6c7
|
||||||
|
Revises: c9d4e5f6a7b8
|
||||||
|
Create Date: 2026-07-05 17:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "d2e3f4a5b6c7"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
|
||||||
|
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||||
|
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
|
||||||
|
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
|
||||||
|
|
||||||
|
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_repair_estimates_accounting_transferred_by_user_id",
|
||||||
|
"repair_estimates",
|
||||||
|
"users",
|
||||||
|
["accounting_transferred_by_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
|
||||||
|
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
|
||||||
|
op.drop_column("repair_estimates", "accounting_transferred_at")
|
||||||
|
op.drop_column("repair_estimates", "accounting_note")
|
||||||
|
op.drop_column("repair_estimates", "accounting_export_status")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
|
||||||
|
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
|
||||||
|
op.drop_column("lexware_sync_records", "transferred_by_user_id")
|
||||||
|
op.drop_column("lexware_sync_records", "transferred_at")
|
||||||
|
op.drop_column("lexware_sync_records", "accounting_note")
|
||||||
|
op.drop_column("lexware_sync_records", "export_status")
|
||||||
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""create inventory
|
||||||
|
|
||||||
|
Revision ID: ef8a2d5c9017
|
||||||
|
Revises: b4e8f1c7a205
|
||||||
|
Create Date: 2026-07-05 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "ef8a2d5c9017"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
INVENTORY_PERMISSIONS = [
|
||||||
|
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||||
|
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||||
|
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||||
|
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
|
||||||
|
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
|
||||||
|
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
||||||
|
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
||||||
|
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"inventory_categories",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
sa.UniqueConstraint("slug"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
|
||||||
|
op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_locations",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_suppliers",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("contact_name", sa.String(length=180), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("website", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("customer_number", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_items",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("sku", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("manufacturer", sa.String(length=180), nullable=True),
|
||||||
|
sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
|
||||||
|
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
|
||||||
|
sa.Column("compatible_devices", sa.Text(), nullable=True),
|
||||||
|
sa.Column("location_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
|
||||||
|
sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("selling_price_cents", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||||
|
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
|
||||||
|
op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_stock_movements",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("item_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("movement_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("quantity", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
|
||||||
|
sa.Column("reference_type", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("reference_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
|
||||||
|
|
||||||
|
for name, display_name, description, module in INVENTORY_PERMISSIONS:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES (:name, :display_name, :description, :module)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
module = excluded.module
|
||||||
|
"""
|
||||||
|
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||||
|
)
|
||||||
|
|
||||||
|
role_permissions = {
|
||||||
|
"administrator": [item[0] for item in INVENTORY_PERMISSIONS],
|
||||||
|
"management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||||
|
"technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
|
||||||
|
"support": ["inventory.read"],
|
||||||
|
"warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||||
|
}
|
||||||
|
for role_name, permission_names in role_permissions.items():
|
||||||
|
for permission_name in permission_names:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles, permissions
|
||||||
|
WHERE roles.name = :role_name
|
||||||
|
AND permissions.name = :permission_name
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE module = 'inventory'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
|
||||||
|
op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
|
||||||
|
op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
|
||||||
|
op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
|
||||||
|
op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
|
||||||
|
op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
|
||||||
|
op.drop_table("inventory_stock_movements")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
|
||||||
|
op.drop_table("inventory_items")
|
||||||
|
op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
|
||||||
|
op.drop_table("inventory_suppliers")
|
||||||
|
op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
|
||||||
|
op.drop_table("inventory_locations")
|
||||||
|
op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
|
||||||
|
op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
|
||||||
|
op.drop_table("inventory_categories")
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""extend repair estimate items inventory
|
||||||
|
|
||||||
|
Revision ID: f4a9c2d7e118
|
||||||
|
Revises: ef8a2d5c9017
|
||||||
|
Create Date: 2026-07-05 13:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "f4a9c2d7e118"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
|
||||||
|
op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_repair_estimate_items_inventory_item_id",
|
||||||
|
"repair_estimate_items",
|
||||||
|
"inventory_items",
|
||||||
|
["inventory_item_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_name")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_item_id")
|
||||||
Binary file not shown.
|
|
@ -58,6 +58,16 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
||||||
return "knowledge.read" in permissions
|
return "knowledge.read" in permissions
|
||||||
if action.startswith("repairs."):
|
if action.startswith("repairs."):
|
||||||
return "repairs.read" in permissions
|
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."):
|
||||||
|
|
|
||||||
150
backend/hermes/app/api/backups.py
Normal file
150
backend/hermes/app/api/backups.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.rbac import require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.api_response import ApiSuccess
|
||||||
|
from app.schemas.backup import BackupRestoreRequest
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.services.backup_service import BackupService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/backups", tags=["Backups"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiSuccess)
|
||||||
|
def list_backups(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.read")),
|
||||||
|
):
|
||||||
|
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_backup(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.create")),
|
||||||
|
):
|
||||||
|
backup = BackupService.create_backup(actor=current_user)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.create",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=backup.filename,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"filename": backup.filename,
|
||||||
|
"size_bytes": backup.size_bytes,
|
||||||
|
"app_version": backup.app_version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ApiSuccess(data=backup, message="Backup erstellt")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{filename}/download")
|
||||||
|
def download_backup(
|
||||||
|
filename: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.download")),
|
||||||
|
):
|
||||||
|
path = BackupService.resolve_backup_path(filename)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.download",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=path.name,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
|
||||||
|
)
|
||||||
|
return FileResponse(path=path, media_type="application/zip", filename=path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{filename}", response_model=ApiSuccess)
|
||||||
|
def delete_backup(
|
||||||
|
filename: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.delete")),
|
||||||
|
):
|
||||||
|
path = BackupService.resolve_backup_path(filename)
|
||||||
|
size_bytes = path.stat().st_size
|
||||||
|
BackupService.delete_backup(filename)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.delete",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=path.name,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": path.name, "size_bytes": size_bytes},
|
||||||
|
)
|
||||||
|
return ApiSuccess(message="Backup geloescht")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
|
||||||
|
def validate_backup_restore(
|
||||||
|
filename: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.restore")),
|
||||||
|
):
|
||||||
|
validation = BackupService.validate_backup(filename)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.validate",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=filename,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
|
||||||
|
)
|
||||||
|
return ApiSuccess(data=validation, message=validation.message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{filename}/restore", response_model=ApiSuccess)
|
||||||
|
def restore_backup(
|
||||||
|
filename: str,
|
||||||
|
payload: BackupRestoreRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("backup.restore")),
|
||||||
|
):
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.restore_started",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=filename,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": filename},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
|
||||||
|
except Exception:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.restore_failed",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=filename,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": filename},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="backups.restore_completed",
|
||||||
|
entity_type="backup",
|
||||||
|
entity_label=filename,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
metadata={"filename": filename},
|
||||||
|
)
|
||||||
|
return ApiSuccess(data=validation, message="Restore abgeschlossen")
|
||||||
|
|
@ -4,15 +4,20 @@ 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.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_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, SystemStatusItem
|
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||||
|
from app.services.backup_service import BackupService
|
||||||
from app.services.system_settings_service import SystemSettingsService
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -34,6 +39,7 @@ def get_dashboard_summary(
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
inventory: list[MetricCard] = []
|
||||||
system_status: list[SystemStatusItem] = []
|
system_status: list[SystemStatusItem] = []
|
||||||
|
|
||||||
if "customers.read" in permissions:
|
if "customers.read" in permissions:
|
||||||
|
|
@ -70,6 +76,55 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
|
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="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="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:
|
if "system_settings.manage" in permissions:
|
||||||
|
|
@ -109,6 +164,27 @@ def get_dashboard_summary(
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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(
|
||||||
|
|
@ -117,6 +193,7 @@ def get_dashboard_summary(
|
||||||
users=users,
|
users=users,
|
||||||
roles=roles,
|
roles=roles,
|
||||||
repairs=repairs,
|
repairs=repairs,
|
||||||
|
inventory=inventory,
|
||||||
system_status=system_status,
|
system_status=system_status,
|
||||||
activities=EmptyWidget(
|
activities=EmptyWidget(
|
||||||
title="Letzte Aktivitäten",
|
title="Letzte Aktivitäten",
|
||||||
|
|
|
||||||
319
backend/hermes/app/api/inventory.py
Normal file
319
backend/hermes/app/api/inventory.py
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.rbac import require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
|
from app.schemas.inventory import (
|
||||||
|
InventoryCategoryPayload,
|
||||||
|
InventoryCategoryResponse,
|
||||||
|
InventoryItemCreate,
|
||||||
|
InventoryItemListResponse,
|
||||||
|
InventoryItemResponse,
|
||||||
|
InventoryItemUpdate,
|
||||||
|
InventoryLocationPayload,
|
||||||
|
InventoryLocationResponse,
|
||||||
|
InventoryStockAction,
|
||||||
|
InventoryStockMovementResponse,
|
||||||
|
InventorySupplierPayload,
|
||||||
|
InventorySupplierResponse,
|
||||||
|
)
|
||||||
|
from app.services.inventory_service import InventoryService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/inventory", tags=["Inventory"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
|
||||||
|
item = InventoryRepository.get_item(db, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
|
||||||
|
category = InventoryRepository.get_category(db, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
|
||||||
|
location = InventoryRepository.get_location(db, location_id)
|
||||||
|
if location is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
|
||||||
|
supplier = InventoryRepository.get_supplier(db, supplier_id)
|
||||||
|
if supplier is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
|
||||||
|
return supplier
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=InventoryItemListResponse)
|
||||||
|
def list_items(
|
||||||
|
q: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
supplier_id: int | None = None,
|
||||||
|
location_id: int | None = None,
|
||||||
|
low_stock: bool | None = None,
|
||||||
|
active: bool | None = True,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
items, total = InventoryRepository.list_items(
|
||||||
|
db,
|
||||||
|
q=q,
|
||||||
|
category_id=category_id,
|
||||||
|
supplier_id=supplier_id,
|
||||||
|
location_id=location_id,
|
||||||
|
low_stock=low_stock,
|
||||||
|
active=active,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/search", response_model=list[InventoryItemResponse])
|
||||||
|
def search_items(
|
||||||
|
q: str | None = None,
|
||||||
|
category: int | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_item(
|
||||||
|
payload: InventoryItemCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.create")),
|
||||||
|
):
|
||||||
|
return InventoryService.create_item(db, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
def get_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return get_item_or_404(db, item_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
def update_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: InventoryItemUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.update")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
|
||||||
|
def delete_item(
|
||||||
|
item_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.delete")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.delete_item(db, item, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
|
||||||
|
def adjust_stock(
|
||||||
|
item_id: int,
|
||||||
|
payload: InventoryStockAction,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.stock.adjust")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
|
||||||
|
def reserve_stock(
|
||||||
|
item_id: int,
|
||||||
|
payload: InventoryStockAction,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
|
||||||
|
def release_stock(
|
||||||
|
item_id: int,
|
||||||
|
payload: InventoryStockAction,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
|
||||||
|
def consume_stock(
|
||||||
|
item_id: int,
|
||||||
|
payload: InventoryStockAction,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.stock.consume")),
|
||||||
|
):
|
||||||
|
item = get_item_or_404(db, item_id)
|
||||||
|
return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
|
||||||
|
def list_movements(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
get_item_or_404(db, item_id)
|
||||||
|
return InventoryRepository.list_movements(db, item_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=list[InventoryCategoryResponse])
|
||||||
|
def list_categories(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return InventoryRepository.list_categories(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(
|
||||||
|
payload: InventoryCategoryPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
return InventoryService.create_category(db, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
|
||||||
|
def update_category(
|
||||||
|
category_id: int,
|
||||||
|
payload: InventoryCategoryPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
category = get_category_or_404(db, category_id)
|
||||||
|
return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_category(
|
||||||
|
category_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
category = get_category_or_404(db, category_id)
|
||||||
|
InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations", response_model=list[InventoryLocationResponse])
|
||||||
|
def list_locations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return InventoryRepository.list_locations(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_location(
|
||||||
|
payload: InventoryLocationPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
return InventoryService.create_location(db, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
|
||||||
|
def update_location(
|
||||||
|
location_id: int,
|
||||||
|
payload: InventoryLocationPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
location = get_location_or_404(db, location_id)
|
||||||
|
return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_location(
|
||||||
|
location_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
location = get_location_or_404(db, location_id)
|
||||||
|
InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/suppliers", response_model=list[InventorySupplierResponse])
|
||||||
|
def list_suppliers(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return InventoryRepository.list_suppliers(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_supplier(
|
||||||
|
payload: InventorySupplierPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
|
||||||
|
def update_supplier(
|
||||||
|
supplier_id: int,
|
||||||
|
payload: InventorySupplierPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
supplier = get_supplier_or_404(db, supplier_id)
|
||||||
|
return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_supplier(
|
||||||
|
supplier_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||||
|
):
|
||||||
|
supplier = get_supplier_or_404(db, supplier_id)
|
||||||
|
InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
|
||||||
|
return None
|
||||||
89
backend/hermes/app/api/lexware.py
Normal file
89
backend/hermes/app/api/lexware.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.rbac import require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.repair import Repair
|
||||||
|
from app.models.repair_estimate import RepairEstimate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.schemas.lexware import (
|
||||||
|
AccountingTransferUpdate,
|
||||||
|
LexwareInvoicePreparationResponse,
|
||||||
|
LexwareSettingsResponse,
|
||||||
|
LexwareSettingsUpdate,
|
||||||
|
LexwareTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.services.lexware_service import LexwareService
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Lexware"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
|
||||||
|
repair = RepairRepository.get_by_id(db, repair_id)
|
||||||
|
if repair is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
|
||||||
|
return repair
|
||||||
|
|
||||||
|
|
||||||
|
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
|
||||||
|
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
|
||||||
|
if estimate is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||||
|
return estimate
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||||
|
def get_lexware_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("lexware.read")),
|
||||||
|
):
|
||||||
|
return LexwareService.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||||
|
def update_lexware_settings(
|
||||||
|
payload: LexwareSettingsUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("lexware.manage")),
|
||||||
|
):
|
||||||
|
return LexwareService.update_settings(db, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse)
|
||||||
|
def test_lexware_connection(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("lexware.manage")),
|
||||||
|
):
|
||||||
|
return LexwareService.test_connection(db, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse)
|
||||||
|
def prepare_lexware_invoice(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("lexware.export")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
|
||||||
|
def mark_accounting_transferred(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
payload: AccountingTransferUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("lexware.export")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
|
||||||
194
backend/hermes/app/api/repair_estimates.py
Normal file
194
backend/hermes/app/api/repair_estimates.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.rbac import require_any_permission, require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.repair import Repair
|
||||||
|
from app.models.repair_estimate import RepairEstimate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.schemas.repair_estimate import (
|
||||||
|
PublicEstimateDecisionRequest,
|
||||||
|
PublicEstimateResponse,
|
||||||
|
RepairEstimateCreate,
|
||||||
|
RepairEstimateEventResponse,
|
||||||
|
RepairEstimateResponse,
|
||||||
|
RepairEstimateUpdate,
|
||||||
|
)
|
||||||
|
from app.services.repair_estimate_service import RepairEstimateService
|
||||||
|
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Repair Estimates"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
|
||||||
|
repair = RepairRepository.get_by_id(db, repair_id)
|
||||||
|
if repair is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
|
||||||
|
return repair
|
||||||
|
|
||||||
|
|
||||||
|
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
|
||||||
|
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
|
||||||
|
if estimate is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||||
|
return estimate
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/estimates", response_model=list[RepairEstimateResponse])
|
||||||
|
def list_estimates(
|
||||||
|
repair_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||||
|
):
|
||||||
|
get_repair_or_404(db, repair_id)
|
||||||
|
return RepairEstimateRepository.list_by_repair(db, repair_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates", response_model=RepairEstimateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
payload: RepairEstimateCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.create")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
return RepairEstimateService.create(db, repair, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
|
||||||
|
def get_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||||
|
):
|
||||||
|
get_repair_or_404(db, repair_id)
|
||||||
|
return get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
|
||||||
|
def update_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
payload: RepairEstimateUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.update")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return RepairEstimateService.update(db, repair, estimate, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/repairs/{repair_id}/estimates/{estimate_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.delete")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
RepairEstimateService.delete(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/send", response_model=RepairEstimateResponse)
|
||||||
|
def send_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.send")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return RepairEstimateService.send(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/cancel", response_model=RepairEstimateResponse)
|
||||||
|
def cancel_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse)
|
||||||
|
def revoke_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.revoke")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
|
||||||
|
def list_estimate_events(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||||
|
):
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return estimate.events
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_repair_and_estimate(db: Session, token: str) -> tuple[Repair, RepairEstimate]:
|
||||||
|
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
|
||||||
|
repair = public_link.repair
|
||||||
|
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
|
||||||
|
if estimate is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||||
|
RepairRepository.mark_public_link_used(db, public_link)
|
||||||
|
return repair, estimate
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/public/repairs/status/{token}/estimate/approve", response_model=PublicEstimateResponse)
|
||||||
|
def approve_public_estimate(
|
||||||
|
token: str,
|
||||||
|
payload: PublicEstimateDecisionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||||
|
updated = RepairEstimateService.customer_decision(db, repair, estimate, "approve", payload, request=request)
|
||||||
|
return RepairEstimateService.public_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/public/repairs/status/{token}/estimate/decline", response_model=PublicEstimateResponse)
|
||||||
|
def decline_public_estimate(
|
||||||
|
token: str,
|
||||||
|
payload: PublicEstimateDecisionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||||
|
updated = RepairEstimateService.customer_decision(db, repair, estimate, "decline", payload, request=request)
|
||||||
|
return RepairEstimateService.public_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/public/repairs/status/{token}/estimate/question", response_model=PublicEstimateResponse)
|
||||||
|
def question_public_estimate(
|
||||||
|
token: str,
|
||||||
|
payload: PublicEstimateDecisionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||||
|
updated = RepairEstimateService.customer_decision(db, repair, estimate, "question", payload, request=request)
|
||||||
|
return RepairEstimateService.public_response(updated)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
@ -11,6 +12,10 @@ from app.models.user import User
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
from app.schemas.repair import (
|
from app.schemas.repair import (
|
||||||
RepairCreate,
|
RepairCreate,
|
||||||
|
RepairDocumentResponse,
|
||||||
|
RepairDocumentType,
|
||||||
|
RepairDocumentUpdate,
|
||||||
|
RepairDocumentVisibility,
|
||||||
RepairIntakePayload,
|
RepairIntakePayload,
|
||||||
RepairIntakeResponse,
|
RepairIntakeResponse,
|
||||||
RepairNotificationEventResponse,
|
RepairNotificationEventResponse,
|
||||||
|
|
@ -26,6 +31,7 @@ from app.schemas.repair import (
|
||||||
RepairUpdate,
|
RepairUpdate,
|
||||||
)
|
)
|
||||||
from app.services.repair_notification_service import RepairNotificationService
|
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_public_link_service import RepairPublicLinkService
|
||||||
from app.services.repair_service import RepairService
|
from app.services.repair_service import RepairService
|
||||||
|
|
||||||
|
|
@ -130,6 +136,117 @@ def get_repair_history(
|
||||||
return RepairRepository.get_history(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)
|
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||||
def get_public_link(
|
def get_public_link(
|
||||||
repair_id: int,
|
repair_id: int,
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -9,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"
|
||||||
|
|
@ -32,6 +32,9 @@ class Settings(BaseSettings):
|
||||||
smtp_from_email: str | None = None
|
smtp_from_email: str | None = None
|
||||||
smtp_from_name: str = "Funktechnik Schubert"
|
smtp_from_name: str = "Funktechnik Schubert"
|
||||||
smtp_use_tls: bool = True
|
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",
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -25,8 +25,11 @@ import app.models.customer
|
||||||
import app.models.knowledge
|
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
|
||||||
|
import app.models.repair_estimate
|
||||||
import app.models.system_setting
|
import app.models.system_setting
|
||||||
|
import app.models.lexware
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ 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.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.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.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
|
||||||
|
|
@ -30,20 +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(knowledge_router)
|
||||||
app.include_router(repairs_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(system_settings_router)
|
||||||
|
app.include_router(lexware_router)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
114
backend/hermes/app/models/inventory.py
Normal file
114
backend/hermes/app/models/inventory.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCategory(Base):
|
||||||
|
__tablename__ = "inventory_categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLocation(Base):
|
||||||
|
__tablename__ = "inventory_locations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySupplier(Base):
|
||||||
|
__tablename__ = "inventory_suppliers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItem(Base):
|
||||||
|
__tablename__ = "inventory_items"
|
||||||
|
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
|
||||||
|
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||||
|
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||||
|
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
movements: Mapped[list["InventoryStockMovement"]] = relationship(
|
||||||
|
back_populates="item",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="InventoryStockMovement.created_at.desc()",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryStockMovement(Base):
|
||||||
|
__tablename__ = "inventory_stock_movements"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
|
||||||
|
movement_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer)
|
||||||
|
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||||
|
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||||
|
actor = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys="InventoryStockMovement.actor_user_id",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_username(self) -> str:
|
||||||
|
return self.actor.username if self.actor is not None else ""
|
||||||
27
backend/hermes/app/models/lexware.py
Normal file
27
backend/hermes/app/models/lexware.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareSyncRecord(Base):
|
||||||
|
__tablename__ = "lexware_sync_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
|
||||||
|
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
|
||||||
|
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
|
||||||
|
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
|
|
@ -41,6 +41,7 @@ class Repair(Base):
|
||||||
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
|
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")
|
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")
|
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):
|
class RepairStatusHistory(Base):
|
||||||
|
|
@ -55,7 +56,11 @@ class RepairStatusHistory(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||||
actor = relationship("User", lazy="joined")
|
actor = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys="RepairStatusHistory.actor_user_id",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actor_username(self) -> str:
|
def actor_username(self) -> str:
|
||||||
|
|
@ -89,10 +94,37 @@ class RepairDocument(Base):
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||||
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
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())
|
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):
|
class RepairPublicAccessToken(Base):
|
||||||
|
|
|
||||||
108
backend/hermes/app/models/repair_estimate.py
Normal file
108
backend/hermes/app/models/repair_estimate.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimate(Base):
|
||||||
|
__tablename__ = "repair_estimates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||||
|
estimate_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
customer_message: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||||
|
internal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
subtotal_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||||
|
tax_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||||
|
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
lexware_invoice_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||||
|
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
repair = relationship("Repair", lazy="joined")
|
||||||
|
created_by = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys="RepairEstimate.created_by_user_id",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
accounting_transferred_by = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
items: Mapped[list["RepairEstimateItem"]] = relationship(
|
||||||
|
back_populates="estimate",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="RepairEstimateItem.position",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
events: Mapped[list["RepairEstimateEvent"]] = relationship(
|
||||||
|
back_populates="estimate",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="RepairEstimateEvent.created_at",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateItem(Base):
|
||||||
|
__tablename__ = "repair_estimate_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
||||||
|
inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="")
|
||||||
|
inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
|
inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer)
|
||||||
|
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=Decimal("1.00"), server_default="1.00")
|
||||||
|
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||||
|
unit_price_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
||||||
|
inventory_item = relationship("InventoryItem", lazy="joined")
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateEvent(Base):
|
||||||
|
__tablename__ = "repair_estimate_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
|
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
|
||||||
|
actor = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys="RepairEstimateEvent.actor_user_id",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
@ -85,6 +85,28 @@ STANDARD_PERMISSIONS = [
|
||||||
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
||||||
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
||||||
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "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 = {
|
||||||
|
|
@ -102,6 +124,24 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repairs.update",
|
"repairs.update",
|
||||||
"repairs.status.update",
|
"repairs.status.update",
|
||||||
"repairs.public_link.manage",
|
"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",
|
||||||
|
|
@ -125,6 +165,13 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repairs.read",
|
"repairs.read",
|
||||||
"repairs.update",
|
"repairs.update",
|
||||||
"repairs.status.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",
|
||||||
|
|
@ -141,9 +188,20 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repairs.status.update",
|
"repairs.status.update",
|
||||||
"repairs.intake",
|
"repairs.intake",
|
||||||
"repairs.public_link.manage",
|
"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",
|
||||||
|
|
|
||||||
229
backend/hermes/app/repositories/inventory_repository.py
Normal file
229
backend/hermes/app/repositories/inventory_repository.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.models.inventory import (
|
||||||
|
InventoryCategory,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryLocation,
|
||||||
|
InventoryStockMovement,
|
||||||
|
InventorySupplier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryRepository:
|
||||||
|
@staticmethod
|
||||||
|
def item_query() -> Select[tuple[InventoryItem]]:
|
||||||
|
return select(InventoryItem).options(
|
||||||
|
selectinload(InventoryItem.category),
|
||||||
|
selectinload(InventoryItem.supplier),
|
||||||
|
selectinload(InventoryItem.location),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_items(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
q: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
supplier_id: int | None = None,
|
||||||
|
location_id: int | None = None,
|
||||||
|
low_stock: bool | None = None,
|
||||||
|
active: bool | None = True,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[InventoryItem], int]:
|
||||||
|
query = InventoryRepository.item_query()
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
query = query.where(or_(
|
||||||
|
InventoryItem.sku.ilike(pattern),
|
||||||
|
InventoryItem.name.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||||
|
InventoryItem.supplier_part_number.ilike(pattern),
|
||||||
|
))
|
||||||
|
if category_id is not None:
|
||||||
|
query = query.where(InventoryItem.category_id == category_id)
|
||||||
|
if supplier_id is not None:
|
||||||
|
query = query.where(InventoryItem.supplier_id == supplier_id)
|
||||||
|
if location_id is not None:
|
||||||
|
query = query.where(InventoryItem.location_id == location_id)
|
||||||
|
if low_stock is not None:
|
||||||
|
if low_stock:
|
||||||
|
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||||
|
else:
|
||||||
|
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
|
||||||
|
if active is not None:
|
||||||
|
query = query.where(InventoryItem.is_active == active)
|
||||||
|
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
items = list(
|
||||||
|
db.scalars(
|
||||||
|
query
|
||||||
|
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_items(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
q: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[InventoryItem]:
|
||||||
|
query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
query = query.where(or_(
|
||||||
|
InventoryItem.sku.ilike(pattern),
|
||||||
|
InventoryItem.name.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||||
|
InventoryItem.supplier_part_number.ilike(pattern),
|
||||||
|
InventoryItem.compatible_devices.ilike(pattern),
|
||||||
|
))
|
||||||
|
if category_id is not None:
|
||||||
|
query = query.where(InventoryItem.category_id == category_id)
|
||||||
|
if manufacturer:
|
||||||
|
query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
|
||||||
|
return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||||
|
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
|
||||||
|
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_next_sku(db: Session, year: int | None = None) -> str:
|
||||||
|
current_year = year or datetime.now(UTC).year
|
||||||
|
prefix = f"ET-{current_year}-"
|
||||||
|
latest = db.scalar(
|
||||||
|
select(InventoryItem.sku)
|
||||||
|
.where(InventoryItem.sku.like(f"{prefix}%"))
|
||||||
|
.order_by(InventoryItem.sku.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
next_number = 1
|
||||||
|
if latest:
|
||||||
|
next_number = int(latest.split("-")[-1]) + 1
|
||||||
|
return f"{prefix}{next_number:06d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_categories(db: Session) -> list[InventoryCategory]:
|
||||||
|
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
|
||||||
|
return db.get(InventoryCategory, category_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_locations(db: Session) -> list[InventoryLocation]:
|
||||||
|
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
|
||||||
|
return db.get(InventoryLocation, location_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_suppliers(db: Session) -> list[InventorySupplier]:
|
||||||
|
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
|
||||||
|
return db.get(InventorySupplier, supplier_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has_movements(db: Session, item_id: int) -> bool:
|
||||||
|
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(InventoryStockMovement)
|
||||||
|
.where(InventoryStockMovement.item_id == item_id)
|
||||||
|
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_movement(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
item_id: int,
|
||||||
|
movement_type: str,
|
||||||
|
quantity: int,
|
||||||
|
reason: str,
|
||||||
|
reference_type: str | None,
|
||||||
|
reference_id: int | None,
|
||||||
|
note: str | None,
|
||||||
|
actor_user_id: int | None,
|
||||||
|
) -> InventoryStockMovement:
|
||||||
|
movement = InventoryStockMovement(
|
||||||
|
item_id=item_id,
|
||||||
|
movement_type=movement_type,
|
||||||
|
quantity=quantity,
|
||||||
|
reason=reason,
|
||||||
|
reference_type=reference_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
note=note,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
db.add(movement)
|
||||||
|
return movement
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_active_items(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_low_stock_items(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(InventoryItem.id))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def total_stock_value_cents(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.purchase_price_cents.is_not(None))
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_reserved_items(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(InventoryItem.id))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.quantity_reserved > 0)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserved_stock_value_cents(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.selling_price_cents.is_not(None))
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(InventoryStockMovement)
|
||||||
|
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
)
|
||||||
136
backend/hermes/app/repositories/repair_estimate_repository.py
Normal file
136
backend/hermes/app/repositories/repair_estimate_repository.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.models.repair_estimate import RepairEstimate, RepairEstimateEvent, RepairEstimateItem
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateRepository:
|
||||||
|
@staticmethod
|
||||||
|
def list_by_repair(db: Session, repair_id: int) -> list[RepairEstimate]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(RepairEstimate)
|
||||||
|
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
|
||||||
|
.where(RepairEstimate.repair_id == repair_id)
|
||||||
|
.order_by(RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(db: Session, *, repair_id: int, estimate_id: int) -> RepairEstimate | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(RepairEstimate)
|
||||||
|
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
|
||||||
|
.where(RepairEstimate.repair_id == repair_id)
|
||||||
|
.where(RepairEstimate.id == estimate_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_latest_public(db: Session, repair_id: int) -> RepairEstimate | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(RepairEstimate)
|
||||||
|
.options(selectinload(RepairEstimate.items))
|
||||||
|
.where(RepairEstimate.repair_id == repair_id)
|
||||||
|
.where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
|
||||||
|
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_next_number(db: Session, year: int) -> str:
|
||||||
|
prefix = f"KV{year}-"
|
||||||
|
latest = db.scalar(
|
||||||
|
select(RepairEstimate.estimate_number)
|
||||||
|
.where(RepairEstimate.estimate_number.like(f"{prefix}%"))
|
||||||
|
.order_by(RepairEstimate.estimate_number.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
next_number = 1
|
||||||
|
if latest:
|
||||||
|
next_number = int(latest.split("-")[-1]) + 1
|
||||||
|
return f"{prefix}{next_number:06d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save(db: Session, estimate: RepairEstimate) -> RepairEstimate:
|
||||||
|
db.add(estimate)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def replace_items(db: Session, estimate: RepairEstimate, items: list[RepairEstimateItem]) -> None:
|
||||||
|
estimate.items.clear()
|
||||||
|
db.flush()
|
||||||
|
for item in items:
|
||||||
|
estimate.items.append(item)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(db: Session, estimate: RepairEstimate) -> None:
|
||||||
|
db.delete(estimate)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
estimate_id: int,
|
||||||
|
event_type: str,
|
||||||
|
actor_type: str,
|
||||||
|
actor_user_id: int | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> RepairEstimateEvent:
|
||||||
|
event = RepairEstimateEvent(
|
||||||
|
estimate_id=estimate_id,
|
||||||
|
event_type=event_type,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_sent(db: Session, estimate: RepairEstimate) -> RepairEstimate:
|
||||||
|
estimate.status = "sent"
|
||||||
|
estimate.sent_at = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_open(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status.in_(["draft", "sent"]))) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_waiting(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "sent")) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_approved_today(db: Session) -> int:
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(RepairEstimate.id))
|
||||||
|
.where(RepairEstimate.status == "approved")
|
||||||
|
.where(func.date(RepairEstimate.approved_at) == today)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_declined(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_revoked_today(db: Session) -> int:
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(RepairEstimate.id))
|
||||||
|
.join(RepairEstimateEvent, RepairEstimateEvent.estimate_id == RepairEstimate.id)
|
||||||
|
.where(RepairEstimate.status == "revoked")
|
||||||
|
.where(RepairEstimateEvent.event_type == "revoked")
|
||||||
|
.where(func.date(RepairEstimateEvent.created_at) == today)
|
||||||
|
) or 0
|
||||||
|
|
@ -3,8 +3,8 @@ from datetime import UTC, datetime
|
||||||
from sqlalchemy import Select, func, or_, select
|
from sqlalchemy import Select, func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
||||||
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
|
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
|
||||||
|
|
||||||
|
|
||||||
class RepairRepository:
|
class RepairRepository:
|
||||||
|
|
@ -362,6 +362,77 @@ class RepairRepository:
|
||||||
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
||||||
) or 0
|
) 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
|
@staticmethod
|
||||||
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
|
|
|
||||||
57
backend/hermes/app/schemas/backup.py
Normal file
57
backend/hermes/app/schemas/backup.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManifest(BaseModel):
|
||||||
|
backup_id: str
|
||||||
|
created_at: datetime
|
||||||
|
app_version: str
|
||||||
|
backup_type: str = "full"
|
||||||
|
database_url_host_anonymized: str
|
||||||
|
database_name: str
|
||||||
|
storage_base_path: str
|
||||||
|
included_sections: list[str] = Field(default_factory=list)
|
||||||
|
file_count: int = 0
|
||||||
|
total_size_bytes: int = 0
|
||||||
|
checksum_sha256: str
|
||||||
|
created_by_user_id: int | None = None
|
||||||
|
created_by_username: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class BackupSummary(BaseModel):
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
created_at: datetime | None = None
|
||||||
|
app_version: str = ""
|
||||||
|
backup_type: str = "full"
|
||||||
|
database_name: str = ""
|
||||||
|
storage_base_path: str = ""
|
||||||
|
file_count: int = 0
|
||||||
|
total_size_bytes: int = 0
|
||||||
|
created_by_user_id: int | None = None
|
||||||
|
created_by_username: str = ""
|
||||||
|
validation_status: str = "valid"
|
||||||
|
validation_message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class BackupListResponse(BaseModel):
|
||||||
|
items: list[BackupSummary] = Field(default_factory=list)
|
||||||
|
total_count: int = 0
|
||||||
|
total_size_bytes: int = 0
|
||||||
|
latest_backup_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupValidationResponse(BaseModel):
|
||||||
|
filename: str
|
||||||
|
valid: bool
|
||||||
|
message: str
|
||||||
|
issues: list[str] = Field(default_factory=list)
|
||||||
|
checksum_valid: bool = False
|
||||||
|
restore_supported: bool = False
|
||||||
|
requires_cli_restore: bool = True
|
||||||
|
manifest: BackupManifest | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRestoreRequest(BaseModel):
|
||||||
|
confirm_text: str
|
||||||
|
|
@ -25,6 +25,7 @@ class DashboardSummary(BaseModel):
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
inventory: list[MetricCard] = []
|
||||||
system_status: list[SystemStatusItem] = []
|
system_status: list[SystemStatusItem] = []
|
||||||
activities: EmptyWidget
|
activities: EmptyWidget
|
||||||
tasks: EmptyWidget
|
tasks: EmptyWidget
|
||||||
|
|
|
||||||
231
backend/hermes/app/schemas/inventory.py
Normal file
231
backend/hermes/app/schemas/inventory.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
InventoryMovementType = Literal[
|
||||||
|
"initial",
|
||||||
|
"purchase",
|
||||||
|
"adjustment",
|
||||||
|
"reservation",
|
||||||
|
"release",
|
||||||
|
"consumption",
|
||||||
|
"return",
|
||||||
|
"correction",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_optional_text(value: object) -> str | None:
|
||||||
|
text = normalize_text(value)
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCategoryPayload(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLocationPayload(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySupplierPayload(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=180)
|
||||||
|
contact_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
customer_number: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("*", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_optional_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItemPayload(BaseModel):
|
||||||
|
sku: str | None = Field(default=None, max_length=40)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
manufacturer: str | None = None
|
||||||
|
manufacturer_part_number: str | None = None
|
||||||
|
supplier_id: int | None = None
|
||||||
|
supplier_part_number: str | None = None
|
||||||
|
compatible_devices: str | None = None
|
||||||
|
location_id: int | None = None
|
||||||
|
quantity_on_hand: int = Field(default=0, ge=0)
|
||||||
|
quantity_reserved: int = Field(default=0, ge=0)
|
||||||
|
reorder_level: int = Field(default=0, ge=0)
|
||||||
|
unit: str = Field(default="Stk.", max_length=40)
|
||||||
|
purchase_price_cents: int | None = Field(default=None, ge=0)
|
||||||
|
selling_price_cents: int | None = Field(default=None, ge=0)
|
||||||
|
currency: str = Field(default="EUR", max_length=3)
|
||||||
|
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
|
||||||
|
notes: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"sku",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"manufacturer",
|
||||||
|
"manufacturer_part_number",
|
||||||
|
"supplier_part_number",
|
||||||
|
"compatible_devices",
|
||||||
|
"unit",
|
||||||
|
"currency",
|
||||||
|
"notes",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def normalize_currency(cls, value: str) -> str:
|
||||||
|
return value.upper() or "EUR"
|
||||||
|
|
||||||
|
@field_validator("tax_rate_percent", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_tax_rate(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().replace(",", ".")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_reserved_quantity(self):
|
||||||
|
if self.quantity_reserved > self.quantity_on_hand:
|
||||||
|
raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItemCreate(InventoryItemPayload):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItemUpdate(InventoryItemPayload):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryStockAction(BaseModel):
|
||||||
|
quantity: int = Field(ge=0)
|
||||||
|
reason: str = Field(min_length=1, max_length=255)
|
||||||
|
note: str | None = None
|
||||||
|
reference_type: str | None = Field(default=None, max_length=80)
|
||||||
|
reference_id: int | None = None
|
||||||
|
|
||||||
|
@field_validator("reason", "note", "reference_type", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCategoryResponse(InventoryCategoryPayload):
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLocationResponse(InventoryLocationPayload):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySupplierResponse(InventorySupplierPayload):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItemResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
sku: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
category_id: int | None
|
||||||
|
manufacturer: str | None
|
||||||
|
manufacturer_part_number: str | None
|
||||||
|
supplier_id: int | None
|
||||||
|
supplier_part_number: str | None
|
||||||
|
compatible_devices: str | None
|
||||||
|
location_id: int | None
|
||||||
|
quantity_on_hand: int
|
||||||
|
quantity_reserved: int
|
||||||
|
quantity_available: int
|
||||||
|
reorder_level: int
|
||||||
|
unit: str
|
||||||
|
purchase_price_cents: int | None
|
||||||
|
selling_price_cents: int | None
|
||||||
|
currency: str
|
||||||
|
tax_rate_percent: Decimal
|
||||||
|
notes: str | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
category: InventoryCategoryResponse | None
|
||||||
|
supplier: InventorySupplierResponse | None
|
||||||
|
location: InventoryLocationResponse | None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItemListResponse(BaseModel):
|
||||||
|
items: list[InventoryItemResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryStockMovementResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
item_id: int
|
||||||
|
movement_type: str
|
||||||
|
quantity: int
|
||||||
|
reason: str
|
||||||
|
reference_type: str | None
|
||||||
|
reference_id: int | None
|
||||||
|
note: str | None
|
||||||
|
actor_user_id: int | None
|
||||||
|
actor_username: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
96
backend/hermes/app/schemas/lexware.py
Normal file
96
backend/hermes/app/schemas/lexware.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.schemas.system_setting import SettingsSource, normalize_text
|
||||||
|
|
||||||
|
|
||||||
|
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
|
||||||
|
LexwareSyncDirection = Literal["push", "pull"]
|
||||||
|
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareSettingsResponse(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
api_base_url: str = "https://api.lexware.io"
|
||||||
|
api_key_is_set: bool = False
|
||||||
|
organization_name: str = ""
|
||||||
|
default_tax_rate: Decimal = Decimal("19.00")
|
||||||
|
default_payment_terms_days: int = 14
|
||||||
|
source: SettingsSource
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
api_base_url: str = Field(default="https://api.lexware.io", max_length=500)
|
||||||
|
api_key: str | None = Field(default=None, max_length=2000)
|
||||||
|
organization_name: str = Field(default="", max_length=255)
|
||||||
|
default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100"))
|
||||||
|
default_payment_terms_days: int = Field(default=14, ge=0, le=365)
|
||||||
|
|
||||||
|
@field_validator("api_base_url", "api_key", "organization_name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str:
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
@field_validator("api_base_url")
|
||||||
|
@classmethod
|
||||||
|
def normalize_base_url(cls, value: str) -> str:
|
||||||
|
return (value or "https://api.lexware.io").rstrip("/")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_enabled_configuration(self):
|
||||||
|
if self.enabled and not self.api_base_url:
|
||||||
|
raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareTestConnectionResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
source: SettingsSource
|
||||||
|
api_base_url: str
|
||||||
|
organization_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareCustomerMapping(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
phone: str
|
||||||
|
search_strategy: str
|
||||||
|
create_payload: dict
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareLineItemMapping(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
quantity: Decimal
|
||||||
|
unit: str
|
||||||
|
unit_price: Decimal
|
||||||
|
tax_rate: Decimal
|
||||||
|
total: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareInvoicePreparationResponse(BaseModel):
|
||||||
|
ready_for_export: bool
|
||||||
|
export_status: AccountingExportStatus
|
||||||
|
payload_summary: dict
|
||||||
|
customer_mapping: LexwareCustomerMapping
|
||||||
|
line_item_mapping: list[LexwareLineItemMapping]
|
||||||
|
tax_mapping: dict
|
||||||
|
warnings: list[str]
|
||||||
|
sync_record_id: int
|
||||||
|
accounting_note: str = ""
|
||||||
|
transferred_at: str | None = None
|
||||||
|
transferred_by_user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingTransferUpdate(BaseModel):
|
||||||
|
accounting_note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@field_validator("accounting_note", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_note(cls, value: object) -> str | None:
|
||||||
|
text = normalize_text(value)
|
||||||
|
return text or None
|
||||||
|
|
@ -3,6 +3,8 @@ from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.schemas.repair_estimate import PublicEstimateResponse
|
||||||
|
|
||||||
RepairStatus = Literal[
|
RepairStatus = Literal[
|
||||||
"new",
|
"new",
|
||||||
"accepted",
|
"accepted",
|
||||||
|
|
@ -19,6 +21,16 @@ RepairStatus = Literal[
|
||||||
]
|
]
|
||||||
RepairPriority = Literal["low", "normal", "high", "urgent"]
|
RepairPriority = Literal["low", "normal", "high", "urgent"]
|
||||||
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
|
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:
|
def normalize_text(value: object) -> str:
|
||||||
|
|
@ -192,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel):
|
||||||
device_model: str
|
device_model: str
|
||||||
status_history_public: list[RepairPublicStatusHistoryItem]
|
status_history_public: list[RepairPublicStatusHistoryItem]
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
estimate: PublicEstimateResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
class RepairNotificationTemplateResponse(BaseModel):
|
class RepairNotificationTemplateResponse(BaseModel):
|
||||||
|
|
@ -222,3 +235,39 @@ class RepairNotificationEventResponse(BaseModel):
|
||||||
class RepairNotificationOverviewResponse(BaseModel):
|
class RepairNotificationOverviewResponse(BaseModel):
|
||||||
templates: list[RepairNotificationTemplateResponse]
|
templates: list[RepairNotificationTemplateResponse]
|
||||||
events: list[RepairNotificationEventResponse]
|
events: list[RepairNotificationEventResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentBase(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
document_type: RepairDocumentType = "other"
|
||||||
|
visibility: RepairDocumentVisibility = "internal"
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
@field_validator("title", "note", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_document_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentUpdate(RepairDocumentBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentResponse(RepairDocumentBase):
|
||||||
|
id: int
|
||||||
|
repair_id: int
|
||||||
|
original_filename: str
|
||||||
|
stored_filename: str
|
||||||
|
storage_path: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
checksum_sha256: str
|
||||||
|
uploaded_by_user_id: int | None
|
||||||
|
uploaded_by_username: str = ""
|
||||||
|
uploaded_by_display_name: str = ""
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
||||||
208
backend/hermes/app/schemas/repair_estimate.py
Normal file
208
backend/hermes/app/schemas/repair_estimate.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
|
||||||
|
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
||||||
|
EstimateActorType = Literal["user", "customer", "system"]
|
||||||
|
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateItemPayload(BaseModel):
|
||||||
|
item_type: EstimateItemType = "other"
|
||||||
|
inventory_item_id: int | None = None
|
||||||
|
inventory_price_overridden: bool = False
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
quantity: Decimal = Field(gt=Decimal("0"))
|
||||||
|
unit: str = Field(default="Stk.", max_length=40)
|
||||||
|
unit_price_cents: int = Field(ge=0)
|
||||||
|
|
||||||
|
@field_validator("title", "description", "unit", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
@field_validator("quantity", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_quantity(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().replace(",", ".")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_inventory_or_title(self):
|
||||||
|
if self.inventory_item_id is None and not self.title.strip():
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimatePayload(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
customer_message: str = ""
|
||||||
|
internal_note: str | None = None
|
||||||
|
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
|
||||||
|
currency: str = Field(default="EUR", max_length=3)
|
||||||
|
valid_until: date | None = None
|
||||||
|
items: list[RepairEstimateItemPayload]
|
||||||
|
|
||||||
|
@field_validator("title", "customer_message", "internal_note", "currency", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def normalize_currency(cls, value: str) -> str:
|
||||||
|
return value.upper() or "EUR"
|
||||||
|
|
||||||
|
@field_validator("tax_rate_percent", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_tax_rate(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().replace(",", ".")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_items(self):
|
||||||
|
if not self.items:
|
||||||
|
raise ValueError("Mindestens eine Position ist erforderlich")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateCreate(RepairEstimatePayload):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateUpdate(RepairEstimatePayload):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateItemResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
estimate_id: int
|
||||||
|
inventory_item_id: int | None
|
||||||
|
inventory_snapshot_name: str
|
||||||
|
inventory_snapshot_sku: str
|
||||||
|
inventory_snapshot_manufacturer: str | None
|
||||||
|
inventory_snapshot_part_number: str | None
|
||||||
|
position: int
|
||||||
|
item_type: EstimateItemType
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
quantity: Decimal
|
||||||
|
unit: str
|
||||||
|
unit_price_cents: int
|
||||||
|
total_cents: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateEventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
estimate_id: int
|
||||||
|
event_type: str
|
||||||
|
actor_type: str
|
||||||
|
actor_user_id: int | None
|
||||||
|
note: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
repair_id: int
|
||||||
|
estimate_number: str
|
||||||
|
status: EstimateStatus
|
||||||
|
title: str
|
||||||
|
customer_message: str
|
||||||
|
internal_note: str | None
|
||||||
|
subtotal_cents: int
|
||||||
|
tax_rate_percent: Decimal
|
||||||
|
tax_cents: int
|
||||||
|
total_cents: int
|
||||||
|
currency: str
|
||||||
|
valid_until: date | None
|
||||||
|
sent_at: datetime | None
|
||||||
|
approved_at: datetime | None
|
||||||
|
declined_at: datetime | None
|
||||||
|
customer_response_message: str | None
|
||||||
|
lexware_invoice_id: str | None
|
||||||
|
lexware_invoice_number: str | None
|
||||||
|
lexware_invoice_status: str | None
|
||||||
|
lexware_synced_at: datetime | None
|
||||||
|
accounting_export_status: str | None
|
||||||
|
accounting_note: str | None
|
||||||
|
accounting_transferred_at: datetime | None
|
||||||
|
accounting_transferred_by_user_id: int | None
|
||||||
|
created_by_user_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
items: list[RepairEstimateItemResponse]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicEstimateItemResponse(BaseModel):
|
||||||
|
item_type: EstimateItemType
|
||||||
|
inventory_snapshot_name: str
|
||||||
|
inventory_snapshot_sku: str
|
||||||
|
inventory_snapshot_manufacturer: str | None
|
||||||
|
inventory_snapshot_part_number: str | None
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
quantity: Decimal
|
||||||
|
unit: str
|
||||||
|
unit_price_cents: int
|
||||||
|
total_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublicEstimateResponse(BaseModel):
|
||||||
|
estimate_number: str
|
||||||
|
status: EstimateStatus
|
||||||
|
status_label: str
|
||||||
|
title: str
|
||||||
|
customer_message: str
|
||||||
|
subtotal_cents: int
|
||||||
|
tax_cents: int
|
||||||
|
total_cents: int
|
||||||
|
currency: str
|
||||||
|
valid_until: date | None
|
||||||
|
items: list[PublicEstimateItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicEstimateDecisionRequest(BaseModel):
|
||||||
|
message: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@field_validator("message", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_message(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = normalize_text(value)
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int:
|
||||||
|
total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int:
|
||||||
|
tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
return int(tax)
|
||||||
|
|
@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "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:
|
||||||
|
|
@ -169,10 +169,59 @@ def action_title(action: str) -> str:
|
||||||
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
||||||
"repairs.status_mail.sent": "Statusmail versendet",
|
"repairs.status_mail.sent": "Statusmail versendet",
|
||||||
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
"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.update": "SMTP-Konfiguration geändert",
|
||||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||||
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
|
"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)
|
||||||
|
|
||||||
|
|
|
||||||
373
backend/hermes/app/services/backup_service.py
Normal file
373
backend/hermes/app/services/backup_service.py
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.backup import (
|
||||||
|
BackupListResponse,
|
||||||
|
BackupManifest,
|
||||||
|
BackupSummary,
|
||||||
|
BackupValidationResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
BACKUP_FILENAME_PREFIX = "olympus-backup-"
|
||||||
|
BACKUP_FILENAME_SUFFIX = ".zip"
|
||||||
|
DATABASE_DUMP_NAME = "database.dump"
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
STORAGE_DIR_NAME = "storage"
|
||||||
|
BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
|
||||||
|
RESTORE_DISABLED_MESSAGE = (
|
||||||
|
"Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
|
||||||
|
"Bitte Restore ueber CLI-Script ausfuehren."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupStats:
|
||||||
|
total_count: int
|
||||||
|
total_size_bytes: int
|
||||||
|
latest_backup_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupService:
|
||||||
|
@staticmethod
|
||||||
|
def get_backup_dir() -> Path:
|
||||||
|
backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return backup_dir
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_backups() -> BackupListResponse:
|
||||||
|
items = [
|
||||||
|
BackupService._read_summary(path)
|
||||||
|
for path in sorted(
|
||||||
|
BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
|
||||||
|
key=lambda item: item.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
|
||||||
|
return BackupListResponse(
|
||||||
|
items=items,
|
||||||
|
total_count=len(items),
|
||||||
|
total_size_bytes=sum(item.size_bytes for item in items),
|
||||||
|
latest_backup_at=latest_backup_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_backup_stats() -> BackupStats:
|
||||||
|
backups = BackupService.list_backups()
|
||||||
|
return BackupStats(
|
||||||
|
total_count=backups.total_count,
|
||||||
|
total_size_bytes=backups.total_size_bytes,
|
||||||
|
latest_backup_at=backups.latest_backup_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_backup(*, actor: User) -> BackupSummary:
|
||||||
|
backup_dir = BackupService.get_backup_dir()
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
|
||||||
|
target_path = backup_dir / filename
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
|
||||||
|
temp_dir = Path(temp_dir_name)
|
||||||
|
dump_path = temp_dir / DATABASE_DUMP_NAME
|
||||||
|
storage_temp_dir = temp_dir / STORAGE_DIR_NAME
|
||||||
|
manifest_path = temp_dir / MANIFEST_NAME
|
||||||
|
archive_path = temp_dir / filename
|
||||||
|
|
||||||
|
BackupService._run_pg_dump(dump_path)
|
||||||
|
file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
|
||||||
|
dump_size = dump_path.stat().st_size
|
||||||
|
checksum_sha256 = BackupService._calculate_archive_checksum(
|
||||||
|
dump_path=dump_path,
|
||||||
|
storage_dir=storage_temp_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = BackupManifest(
|
||||||
|
backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
app_version=settings.app_version,
|
||||||
|
backup_type="full",
|
||||||
|
database_url_host_anonymized=BackupService._anonymized_database_host(),
|
||||||
|
database_name=BackupService._database_name(),
|
||||||
|
storage_base_path=settings.storage_base_path,
|
||||||
|
included_sections=["database", "storage"],
|
||||||
|
file_count=file_count + 1,
|
||||||
|
total_size_bytes=total_size_bytes + dump_size,
|
||||||
|
checksum_sha256=checksum_sha256,
|
||||||
|
created_by_user_id=actor.id,
|
||||||
|
created_by_username=actor.username,
|
||||||
|
)
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupService._write_archive(
|
||||||
|
archive_path=archive_path,
|
||||||
|
manifest_path=manifest_path,
|
||||||
|
dump_path=dump_path,
|
||||||
|
storage_dir=storage_temp_dir,
|
||||||
|
)
|
||||||
|
shutil.move(str(archive_path), target_path)
|
||||||
|
|
||||||
|
return BackupService._read_summary(target_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_backup(filename: str) -> BackupValidationResponse:
|
||||||
|
path = BackupService.resolve_backup_path(filename)
|
||||||
|
issues: list[str] = []
|
||||||
|
manifest: BackupManifest | None = None
|
||||||
|
checksum_valid = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
if MANIFEST_NAME not in names:
|
||||||
|
issues.append("manifest.json fehlt")
|
||||||
|
if DATABASE_DUMP_NAME not in names:
|
||||||
|
issues.append("database.dump fehlt")
|
||||||
|
if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
|
||||||
|
issues.append("storage/ fehlt")
|
||||||
|
|
||||||
|
if MANIFEST_NAME in names:
|
||||||
|
try:
|
||||||
|
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||||
|
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
issues.append("manifest.json ist ungueltig")
|
||||||
|
|
||||||
|
if manifest is not None:
|
||||||
|
checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
|
||||||
|
if not checksum_valid:
|
||||||
|
issues.append("Checksumme ist ungueltig")
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
issues.append("ZIP-Datei ist ungueltig")
|
||||||
|
|
||||||
|
valid = len(issues) == 0
|
||||||
|
return BackupValidationResponse(
|
||||||
|
filename=path.name,
|
||||||
|
valid=valid,
|
||||||
|
message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
|
||||||
|
issues=issues,
|
||||||
|
checksum_valid=checksum_valid,
|
||||||
|
restore_supported=False,
|
||||||
|
requires_cli_restore=True,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
|
||||||
|
if confirm_text != BACKUP_CONFIRM_TEXT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Bestaetigungstext stimmt nicht ueberein",
|
||||||
|
)
|
||||||
|
|
||||||
|
validation = BackupService.validate_backup(filename)
|
||||||
|
if not validation.valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail=RESTORE_DISABLED_MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_backup(filename: str) -> None:
|
||||||
|
path = BackupService.resolve_backup_path(filename)
|
||||||
|
path.unlink(missing_ok=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_backup_path(filename: str) -> Path:
|
||||||
|
if not filename.endswith(BACKUP_FILENAME_SUFFIX):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||||
|
if Path(filename).name != filename or ".." in Path(filename).parts:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||||
|
|
||||||
|
path = (BackupService.get_backup_dir() / filename).resolve()
|
||||||
|
backup_dir = BackupService.get_backup_dir()
|
||||||
|
if backup_dir != path.parent or not path.exists() or not path.is_file():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_pg_dump(dump_path: Path) -> None:
|
||||||
|
pg_dump_url = BackupService._pg_dump_database_url()
|
||||||
|
command = [
|
||||||
|
"pg_dump",
|
||||||
|
"--format=custom",
|
||||||
|
"--no-owner",
|
||||||
|
"--no-privileges",
|
||||||
|
f"--file={dump_path}",
|
||||||
|
f"--dbname={pg_dump_url}",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
command,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=os.environ.copy(),
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="pg_dump ist im Hermes-Container nicht verfuegbar",
|
||||||
|
) from exc
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="PostgreSQL-Dump konnte nicht erstellt werden",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
|
||||||
|
source_dir = Path(settings.storage_base_path).resolve()
|
||||||
|
backup_dir = BackupService.get_backup_dir()
|
||||||
|
source_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_count = 0
|
||||||
|
total_size_bytes = 0
|
||||||
|
|
||||||
|
for source_path in sorted(source_dir.rglob("*")):
|
||||||
|
if source_path == backup_dir or backup_dir in source_path.parents:
|
||||||
|
continue
|
||||||
|
relative_path = source_path.relative_to(source_dir)
|
||||||
|
destination_path = target_dir / relative_path
|
||||||
|
if source_path.is_dir():
|
||||||
|
destination_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
if not source_path.is_file():
|
||||||
|
continue
|
||||||
|
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source_path, destination_path)
|
||||||
|
file_count += 1
|
||||||
|
total_size_bytes += source_path.stat().st_size
|
||||||
|
|
||||||
|
return file_count, total_size_bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_archive(
|
||||||
|
*,
|
||||||
|
archive_path: Path,
|
||||||
|
manifest_path: Path,
|
||||||
|
dump_path: Path,
|
||||||
|
storage_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr(f"{STORAGE_DIR_NAME}/", "")
|
||||||
|
archive.write(manifest_path, MANIFEST_NAME)
|
||||||
|
archive.write(dump_path, DATABASE_DUMP_NAME)
|
||||||
|
for file_path in sorted(storage_dir.rglob("*")):
|
||||||
|
if file_path.is_dir():
|
||||||
|
continue
|
||||||
|
archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_summary(path: Path) -> BackupSummary:
|
||||||
|
default_summary = BackupSummary(
|
||||||
|
filename=path.name,
|
||||||
|
size_bytes=path.stat().st_size,
|
||||||
|
validation_status="warning",
|
||||||
|
validation_message="Manifest konnte nicht gelesen werden",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||||
|
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||||
|
return BackupSummary(
|
||||||
|
filename=path.name,
|
||||||
|
size_bytes=path.stat().st_size,
|
||||||
|
created_at=manifest.created_at,
|
||||||
|
app_version=manifest.app_version,
|
||||||
|
backup_type=manifest.backup_type,
|
||||||
|
database_name=manifest.database_name,
|
||||||
|
storage_base_path=manifest.storage_base_path,
|
||||||
|
file_count=manifest.file_count,
|
||||||
|
total_size_bytes=manifest.total_size_bytes,
|
||||||
|
created_by_user_id=manifest.created_by_user_id,
|
||||||
|
created_by_username=manifest.created_by_username,
|
||||||
|
validation_status="valid",
|
||||||
|
validation_message="Backup ist lesbar",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return default_summary
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||||
|
BackupService._update_digest_from_file(digest, dump_path)
|
||||||
|
|
||||||
|
for file_path in sorted(storage_dir.rglob("*")):
|
||||||
|
if file_path.is_dir():
|
||||||
|
continue
|
||||||
|
digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
|
||||||
|
BackupService._update_digest_from_file(digest, file_path)
|
||||||
|
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
if DATABASE_DUMP_NAME not in archive.namelist():
|
||||||
|
return False
|
||||||
|
|
||||||
|
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||||
|
with archive.open(DATABASE_DUMP_NAME) as dump_file:
|
||||||
|
BackupService._update_digest_from_stream(digest, dump_file)
|
||||||
|
|
||||||
|
for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
|
||||||
|
digest.update(name.encode("utf-8"))
|
||||||
|
with archive.open(name) as storage_file:
|
||||||
|
BackupService._update_digest_from_stream(digest, storage_file)
|
||||||
|
|
||||||
|
return digest.hexdigest() == expected_checksum
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _anonymized_database_host() -> str:
|
||||||
|
parsed = make_url(settings.database_url)
|
||||||
|
host = parsed.host or "unknown"
|
||||||
|
digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
|
||||||
|
return f"sha256:{digest}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _database_name() -> str:
|
||||||
|
parsed = make_url(settings.database_url)
|
||||||
|
return parsed.database or "unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pg_dump_database_url() -> str:
|
||||||
|
parsed = make_url(settings.database_url)
|
||||||
|
normalized = parsed.set(drivername="postgresql")
|
||||||
|
return normalized.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_digest_from_file(digest, file_path: Path) -> None:
|
||||||
|
with file_path.open("rb") as file_handle:
|
||||||
|
BackupService._update_digest_from_stream(digest, file_handle)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_digest_from_stream(digest, stream) -> None:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
415
backend/hermes/app/services/inventory_service.py
Normal file
415
backend/hermes/app/services/inventory_service.py
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||||
|
from app.models.repair_estimate import RepairEstimate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
|
from app.schemas.inventory import (
|
||||||
|
InventoryCategoryPayload,
|
||||||
|
InventoryItemCreate,
|
||||||
|
InventoryItemUpdate,
|
||||||
|
InventoryLocationPayload,
|
||||||
|
InventoryStockAction,
|
||||||
|
InventorySupplierPayload,
|
||||||
|
)
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
|
||||||
|
MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||||
|
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
|
||||||
|
return slug or "eintrag"
|
||||||
|
|
||||||
|
|
||||||
|
def _item_label(item: InventoryItem) -> str:
|
||||||
|
return f"{item.sku} · {item.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_item_data(item: InventoryItem) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"sku": item.sku,
|
||||||
|
"name": item.name,
|
||||||
|
"category_id": item.category_id,
|
||||||
|
"supplier_id": item.supplier_id,
|
||||||
|
"location_id": item.location_id,
|
||||||
|
"quantity_on_hand": item.quantity_on_hand,
|
||||||
|
"quantity_reserved": item.quantity_reserved,
|
||||||
|
"quantity_available": item.quantity_available,
|
||||||
|
"reorder_level": item.reorder_level,
|
||||||
|
"selling_price_cents": item.selling_price_cents,
|
||||||
|
"currency": item.currency,
|
||||||
|
"is_active": item.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryService:
|
||||||
|
@staticmethod
|
||||||
|
def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
|
||||||
|
InventoryService._validate_sku_available(db, sku)
|
||||||
|
item = InventoryItem(sku=sku)
|
||||||
|
InventoryService._apply_item_payload(item, payload)
|
||||||
|
try:
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
if item.quantity_on_hand > 0:
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="initial",
|
||||||
|
quantity=item.quantity_on_hand,
|
||||||
|
reason="Anfangsbestand",
|
||||||
|
reference_type=None,
|
||||||
|
reference_id=None,
|
||||||
|
note=None,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||||
|
db.refresh(item)
|
||||||
|
item = InventoryRepository.get_item(db, item.id) or item
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.items.create",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=item.id,
|
||||||
|
entity_label=_item_label(item),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
after_data=_audit_item_data(item),
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
sku = (payload.sku or "").upper() or item.sku
|
||||||
|
InventoryService._validate_sku_available(db, sku, item_id=item.id)
|
||||||
|
item.sku = sku
|
||||||
|
InventoryService._apply_item_payload(item, payload)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||||
|
db.refresh(item)
|
||||||
|
updated = InventoryRepository.get_item(db, item.id) or item
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.items.update",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_item_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_item_data(updated),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
label = _item_label(item)
|
||||||
|
if InventoryRepository.has_movements(db, item.id):
|
||||||
|
item.is_active = False
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.items.deactivate",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=item.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_item_data(item),
|
||||||
|
)
|
||||||
|
return InventoryRepository.get_item(db, item.id) or item
|
||||||
|
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.items.delete",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=item.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
item.quantity_on_hand = payload.quantity
|
||||||
|
if item.quantity_reserved > item.quantity_on_hand:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||||
|
InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
|
||||||
|
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
InventoryService._validate_positive_stock_quantity(payload)
|
||||||
|
if payload.quantity > item.quantity_available:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
item.quantity_reserved += payload.quantity
|
||||||
|
InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
|
||||||
|
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
InventoryService._validate_positive_stock_quantity(payload)
|
||||||
|
if payload.quantity > item.quantity_reserved:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
item.quantity_reserved -= payload.quantity
|
||||||
|
InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
|
||||||
|
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||||
|
InventoryService._validate_positive_stock_quantity(payload)
|
||||||
|
if payload.quantity > item.quantity_on_hand:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
|
||||||
|
before_data = _audit_item_data(item)
|
||||||
|
item.quantity_on_hand -= payload.quantity
|
||||||
|
item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
|
||||||
|
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
||||||
|
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None or not item.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
if quantity > item.quantity_available:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
|
||||||
|
)
|
||||||
|
item.quantity_reserved += quantity
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="reservation",
|
||||||
|
quantity=quantity,
|
||||||
|
reason="estimate_reserved",
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_estimate_reservation(
|
||||||
|
db: Session,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
*,
|
||||||
|
actor_user_id: int | None,
|
||||||
|
reason: str = "estimate_released",
|
||||||
|
) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
item.quantity_reserved = max(0, item.quantity_reserved - quantity)
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="release",
|
||||||
|
quantity=quantity,
|
||||||
|
reason=reason,
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
|
||||||
|
item.quantity_reserved -= quantity
|
||||||
|
item.quantity_on_hand -= quantity
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="consumption",
|
||||||
|
quantity=quantity,
|
||||||
|
reason="estimate_consumed",
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||||
|
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||||
|
return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||||
|
before_data = {"id": category.id, "name": category.name, "slug": category.slug}
|
||||||
|
category.name = payload.name
|
||||||
|
category.slug = _slugify(payload.name)
|
||||||
|
category.description = payload.description
|
||||||
|
return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||||
|
location = InventoryLocation(name=payload.name, description=payload.description)
|
||||||
|
return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||||
|
before_data = {"id": location.id, "name": location.name}
|
||||||
|
location.name = payload.name
|
||||||
|
location.description = payload.description
|
||||||
|
return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||||
|
supplier = InventorySupplier(**payload.model_dump())
|
||||||
|
return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||||
|
before_data = {"id": supplier.id, "name": supplier.name}
|
||||||
|
for key, value in payload.model_dump().items():
|
||||||
|
setattr(supplier, key, value)
|
||||||
|
return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
|
||||||
|
label = getattr(entity, "name", "")
|
||||||
|
before_data = {"id": entity.id, "name": label}
|
||||||
|
db.delete(entity)
|
||||||
|
db.commit()
|
||||||
|
write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
|
||||||
|
for key, value in payload.model_dump(exclude={"sku"}).items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recalculate_available(item: InventoryItem) -> None:
|
||||||
|
item.quantity_available = item.quantity_on_hand - item.quantity_reserved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
|
||||||
|
existing = InventoryRepository.get_item_by_sku(db, sku)
|
||||||
|
if existing is not None and existing.id != item_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
|
||||||
|
if payload.quantity <= 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
|
||||||
|
if quantity != quantity.to_integral_value():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
|
||||||
|
)
|
||||||
|
normalized = int(quantity)
|
||||||
|
if normalized <= 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type=movement_type,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
reason=payload.reason,
|
||||||
|
reference_type=payload.reference_type,
|
||||||
|
reference_id=payload.reference_id,
|
||||||
|
note=payload.note,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
updated = InventoryRepository.get_item(db, item.id) or item
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action=action,
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_item_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_item_data(updated),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
|
||||||
|
try:
|
||||||
|
db.add(entity)
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
|
||||||
|
db.refresh(entity)
|
||||||
|
label = getattr(entity, "name", "")
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action=action,
|
||||||
|
entity_type=entity.__tablename__,
|
||||||
|
entity_id=entity.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data={"id": entity.id, "name": label},
|
||||||
|
)
|
||||||
|
return entity
|
||||||
518
backend/hermes/app/services/lexware_service.py
Normal file
518
backend/hermes/app/services/lexware_service.py
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request as UrlRequest
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.lexware import LexwareSyncRecord
|
||||||
|
from app.models.repair import Repair
|
||||||
|
from app.models.repair_estimate import RepairEstimate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||||
|
from app.schemas.lexware import (
|
||||||
|
AccountingTransferUpdate,
|
||||||
|
LexwareCustomerMapping,
|
||||||
|
LexwareInvoicePreparationResponse,
|
||||||
|
LexwareLineItemMapping,
|
||||||
|
LexwareSettingsResponse,
|
||||||
|
LexwareSettingsUpdate,
|
||||||
|
LexwareTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.system_setting import SettingsSource
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.services.system_settings_service import parse_bool
|
||||||
|
|
||||||
|
|
||||||
|
LEXWARE_KEYS = (
|
||||||
|
"lexware.enabled",
|
||||||
|
"lexware.api_base_url",
|
||||||
|
"lexware.api_key",
|
||||||
|
"lexware.organization_name",
|
||||||
|
"lexware.default_tax_rate",
|
||||||
|
"lexware.default_payment_terms_days",
|
||||||
|
)
|
||||||
|
|
||||||
|
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
|
||||||
|
DEFAULT_API_BASE_URL = "https://api.lexware.io"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LexwareRuntimeConfig:
|
||||||
|
enabled: bool
|
||||||
|
api_base_url: str
|
||||||
|
api_key: str
|
||||||
|
organization_name: str
|
||||||
|
default_tax_rate: Decimal
|
||||||
|
default_payment_terms_days: int
|
||||||
|
source: SettingsSource
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_key_is_set(self) -> bool:
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return self.enabled and bool(self.api_base_url and self.api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal(value: object, *, default: Decimal) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(str(value or "").replace(",", "."))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: object, *, default: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(str(value or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return parsed if 0 <= parsed <= 365 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _euros(cents: int) -> Decimal:
|
||||||
|
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_error_message(error: Exception) -> str:
|
||||||
|
if isinstance(error, HTTPError):
|
||||||
|
if error.code in {401, 403}:
|
||||||
|
return "Lexware hat den API-Key abgelehnt."
|
||||||
|
if error.code == 404:
|
||||||
|
return "Lexware-Endpunkt wurde nicht gefunden."
|
||||||
|
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
|
||||||
|
if isinstance(error, URLError):
|
||||||
|
return "Lexware ist momentan nicht erreichbar."
|
||||||
|
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
|
||||||
|
|
||||||
|
|
||||||
|
class LexwareService:
|
||||||
|
@staticmethod
|
||||||
|
def get_settings(db: Session) -> LexwareSettingsResponse:
|
||||||
|
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
|
||||||
|
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||||
|
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
|
||||||
|
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
|
||||||
|
db_api_key = values.get("lexware.api_key") or ""
|
||||||
|
db_has_config = db_enabled or db_base_url or db_api_key
|
||||||
|
|
||||||
|
if db_has_config:
|
||||||
|
return LexwareRuntimeConfig(
|
||||||
|
enabled=db_enabled,
|
||||||
|
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
|
||||||
|
api_key=db_api_key,
|
||||||
|
organization_name=values.get("lexware.organization_name", ""),
|
||||||
|
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
|
||||||
|
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
|
||||||
|
source="database",
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.lexware_enabled or settings.lexware_api_key:
|
||||||
|
return LexwareRuntimeConfig(
|
||||||
|
enabled=settings.lexware_enabled,
|
||||||
|
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
|
||||||
|
api_key=settings.lexware_api_key or "",
|
||||||
|
organization_name="",
|
||||||
|
default_tax_rate=Decimal("19.00"),
|
||||||
|
default_payment_terms_days=14,
|
||||||
|
source="environment",
|
||||||
|
)
|
||||||
|
|
||||||
|
return LexwareRuntimeConfig(
|
||||||
|
enabled=False,
|
||||||
|
api_base_url=DEFAULT_API_BASE_URL,
|
||||||
|
api_key="",
|
||||||
|
organization_name="",
|
||||||
|
default_tax_rate=Decimal("19.00"),
|
||||||
|
default_payment_terms_days=14,
|
||||||
|
source="missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
|
||||||
|
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||||
|
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
|
||||||
|
updates = {
|
||||||
|
"lexware.enabled": "true" if payload.enabled else "false",
|
||||||
|
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
|
||||||
|
"lexware.api_key": api_key,
|
||||||
|
"lexware.organization_name": payload.organization_name,
|
||||||
|
"lexware.default_tax_rate": str(payload.default_tax_rate),
|
||||||
|
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
|
||||||
|
}
|
||||||
|
for key, value in updates.items():
|
||||||
|
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
|
||||||
|
db.commit()
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="lexware.settings.update",
|
||||||
|
entity_type="system_settings",
|
||||||
|
entity_label="Lexware Office",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"enabled": payload.enabled,
|
||||||
|
"api_base_url": payload.api_base_url,
|
||||||
|
"organization_name": payload.organization_name,
|
||||||
|
"default_tax_rate": str(payload.default_tax_rate),
|
||||||
|
"default_payment_terms_days": payload.default_payment_terms_days,
|
||||||
|
"api_key_changed": bool(payload.api_key),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return LexwareService.get_settings(db)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
|
||||||
|
config = LexwareService.get_runtime_config(db)
|
||||||
|
if not config.is_configured:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="lexware.connection.test_failed",
|
||||||
|
entity_type="system_settings",
|
||||||
|
entity_label="Lexware Office",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "lexware_not_configured", "source": config.source},
|
||||||
|
)
|
||||||
|
return LexwareTestConnectionResponse(
|
||||||
|
success=False,
|
||||||
|
message="Lexware ist nicht vollständig konfiguriert.",
|
||||||
|
source=config.source,
|
||||||
|
api_base_url=config.api_base_url,
|
||||||
|
organization_name=config.organization_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = LexwareService._get_profile(config)
|
||||||
|
except Exception as exc:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="lexware.connection.test_failed",
|
||||||
|
entity_type="system_settings",
|
||||||
|
entity_label="Lexware Office",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
|
||||||
|
)
|
||||||
|
return LexwareTestConnectionResponse(
|
||||||
|
success=False,
|
||||||
|
message=_safe_error_message(exc),
|
||||||
|
source=config.source,
|
||||||
|
api_base_url=config.api_base_url,
|
||||||
|
organization_name=config.organization_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="lexware.connection.test_success",
|
||||||
|
entity_type="system_settings",
|
||||||
|
entity_label="Lexware Office",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
|
||||||
|
)
|
||||||
|
return LexwareTestConnectionResponse(
|
||||||
|
success=True,
|
||||||
|
message="Lexware-Verbindung erfolgreich geprüft.",
|
||||||
|
source=config.source,
|
||||||
|
api_base_url=config.api_base_url,
|
||||||
|
organization_name=organization_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prepare_invoice(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> LexwareInvoicePreparationResponse:
|
||||||
|
config = LexwareService.get_runtime_config(db)
|
||||||
|
if estimate.status != "approved":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if not config.enabled:
|
||||||
|
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
|
||||||
|
if not config.api_key_is_set:
|
||||||
|
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
|
||||||
|
if not repair.customer_email:
|
||||||
|
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
|
||||||
|
if not estimate.items:
|
||||||
|
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
|
||||||
|
|
||||||
|
customer_payload = {
|
||||||
|
"roles": {"customer": {}},
|
||||||
|
"company": {"name": repair.customer_name},
|
||||||
|
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||||
|
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||||
|
}
|
||||||
|
customer_mapping = LexwareCustomerMapping(
|
||||||
|
name=repair.customer_name,
|
||||||
|
email=repair.customer_email,
|
||||||
|
phone=repair.customer_phone,
|
||||||
|
search_strategy="email" if repair.customer_email else "name",
|
||||||
|
create_payload=customer_payload,
|
||||||
|
)
|
||||||
|
line_items = [
|
||||||
|
LexwareLineItemMapping(
|
||||||
|
title=item.title,
|
||||||
|
description=item.description,
|
||||||
|
quantity=item.quantity,
|
||||||
|
unit=item.unit,
|
||||||
|
unit_price=_euros(item.unit_price_cents),
|
||||||
|
tax_rate=estimate.tax_rate_percent,
|
||||||
|
total=_euros(item.total_cents),
|
||||||
|
)
|
||||||
|
for item in estimate.items
|
||||||
|
]
|
||||||
|
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||||
|
record = LexwareSyncRecord(
|
||||||
|
entity_type="repair_estimate",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
lexware_resource_type="invoice",
|
||||||
|
status="pending" if not warnings else "skipped",
|
||||||
|
direction="push",
|
||||||
|
export_status="prepared",
|
||||||
|
accounting_note=estimate.accounting_note,
|
||||||
|
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||||
|
error_message="; ".join(warnings) if warnings else None,
|
||||||
|
)
|
||||||
|
estimate.accounting_export_status = "prepared"
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
db.refresh(estimate)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="accounting.invoice.handoff",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"repair_id": repair.id,
|
||||||
|
"repair_number": repair.repair_number,
|
||||||
|
"ready_for_export": not warnings,
|
||||||
|
"sync_record_id": record.id,
|
||||||
|
"export_status": record.export_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return LexwareService._invoice_preparation_response(
|
||||||
|
record=record,
|
||||||
|
payload_summary=payload_summary,
|
||||||
|
customer_mapping=customer_mapping,
|
||||||
|
line_items=line_items,
|
||||||
|
estimate=estimate,
|
||||||
|
config=config,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_transferred(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
payload: AccountingTransferUpdate,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> LexwareInvoicePreparationResponse:
|
||||||
|
record = LexwareService._latest_invoice_record(db, estimate.id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
|
||||||
|
if record.export_status not in {"prepared", "transferred"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
|
||||||
|
|
||||||
|
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
record.export_status = "transferred"
|
||||||
|
record.status = "success"
|
||||||
|
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
|
||||||
|
record.transferred_at = now
|
||||||
|
record.transferred_by_user_id = actor.id
|
||||||
|
record.synced_at = now
|
||||||
|
estimate.accounting_export_status = "transferred"
|
||||||
|
estimate.accounting_note = record.accounting_note
|
||||||
|
estimate.accounting_transferred_at = now
|
||||||
|
estimate.accounting_transferred_by_user_id = actor.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
db.refresh(estimate)
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="accounting.invoice.mark_transferred",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"repair_id": repair.id,
|
||||||
|
"repair_number": repair.repair_number,
|
||||||
|
"sync_record_id": record.id,
|
||||||
|
"export_status": record.export_status,
|
||||||
|
"transferred_at": record.transferred_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if note_changed:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="accounting.invoice.note_update",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
config = LexwareService.get_runtime_config(db)
|
||||||
|
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
|
||||||
|
warnings: list[str] = []
|
||||||
|
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||||
|
return LexwareService._invoice_preparation_response(
|
||||||
|
record=record,
|
||||||
|
payload_summary=payload_summary,
|
||||||
|
customer_mapping=customer_mapping,
|
||||||
|
line_items=line_items,
|
||||||
|
estimate=estimate,
|
||||||
|
config=config,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
|
||||||
|
customer_payload = {
|
||||||
|
"roles": {"customer": {}},
|
||||||
|
"company": {"name": repair.customer_name},
|
||||||
|
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||||
|
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||||
|
}
|
||||||
|
customer_mapping = LexwareCustomerMapping(
|
||||||
|
name=repair.customer_name,
|
||||||
|
email=repair.customer_email,
|
||||||
|
phone=repair.customer_phone,
|
||||||
|
search_strategy="email" if repair.customer_email else "name",
|
||||||
|
create_payload=customer_payload,
|
||||||
|
)
|
||||||
|
line_items = [
|
||||||
|
LexwareLineItemMapping(
|
||||||
|
title=item.title,
|
||||||
|
description=item.description,
|
||||||
|
quantity=item.quantity,
|
||||||
|
unit=item.unit,
|
||||||
|
unit_price=_euros(item.unit_price_cents),
|
||||||
|
tax_rate=estimate.tax_rate_percent,
|
||||||
|
total=_euros(item.total_cents),
|
||||||
|
)
|
||||||
|
for item in estimate.items
|
||||||
|
]
|
||||||
|
return customer_mapping, line_items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invoice_payload_summary(
|
||||||
|
config: LexwareRuntimeConfig,
|
||||||
|
repair: Repair,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
line_items: list[LexwareLineItemMapping],
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "invoice",
|
||||||
|
"title": f"Rechnung zu Reparatur {repair.repair_number}",
|
||||||
|
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
|
||||||
|
"repair_number": repair.repair_number,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"currency": estimate.currency,
|
||||||
|
"payment_terms_days": config.default_payment_terms_days,
|
||||||
|
"subtotal": str(_euros(estimate.subtotal_cents)),
|
||||||
|
"tax": str(_euros(estimate.tax_cents)),
|
||||||
|
"total": str(_euros(estimate.total_cents)),
|
||||||
|
"line_item_count": len(line_items),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invoice_preparation_response(
|
||||||
|
*,
|
||||||
|
record: LexwareSyncRecord,
|
||||||
|
payload_summary: dict,
|
||||||
|
customer_mapping: LexwareCustomerMapping,
|
||||||
|
line_items: list[LexwareLineItemMapping],
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
config: LexwareRuntimeConfig,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> LexwareInvoicePreparationResponse:
|
||||||
|
return LexwareInvoicePreparationResponse(
|
||||||
|
ready_for_export=not warnings,
|
||||||
|
export_status=record.export_status,
|
||||||
|
payload_summary=payload_summary,
|
||||||
|
customer_mapping=customer_mapping,
|
||||||
|
line_item_mapping=line_items,
|
||||||
|
tax_mapping={
|
||||||
|
"source": "repair_estimate",
|
||||||
|
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
|
||||||
|
"default_tax_rate": str(config.default_tax_rate),
|
||||||
|
"tax_amount": str(_euros(estimate.tax_cents)),
|
||||||
|
},
|
||||||
|
warnings=warnings,
|
||||||
|
sync_record_id=record.id,
|
||||||
|
accounting_note=record.accounting_note or "",
|
||||||
|
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
|
||||||
|
transferred_by_user_id=record.transferred_by_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
return db.scalar(
|
||||||
|
select(LexwareSyncRecord)
|
||||||
|
.where(LexwareSyncRecord.entity_type == "repair_estimate")
|
||||||
|
.where(LexwareSyncRecord.entity_id == estimate_id)
|
||||||
|
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||||
|
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
|
||||||
|
return LexwareSettingsResponse(
|
||||||
|
enabled=config.enabled,
|
||||||
|
api_base_url=config.api_base_url,
|
||||||
|
api_key_is_set=config.api_key_is_set,
|
||||||
|
organization_name=config.organization_name,
|
||||||
|
default_tax_rate=config.default_tax_rate,
|
||||||
|
default_payment_terms_days=config.default_payment_terms_days,
|
||||||
|
source=config.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_profile(config: LexwareRuntimeConfig) -> dict:
|
||||||
|
request = UrlRequest(
|
||||||
|
f"{config.api_base_url}/v1/profile",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {config.api_key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
if not body:
|
||||||
|
return {}
|
||||||
|
data = json.loads(body)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
225
backend/hermes/app/services/repair_document_service.py
Normal file
225
backend/hermes/app/services/repair_document_service.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.models.repair import Repair, RepairDocument
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.schemas.repair import RepairDocumentUpdate
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.storage import get_storage_service
|
||||||
|
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||||
|
|
||||||
|
ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
||||||
|
ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENT_TYPE_LABELS = {
|
||||||
|
"device_photo": "Gerätefoto",
|
||||||
|
"fault_photo": "Fehlerbild",
|
||||||
|
"measurement": "Messbild",
|
||||||
|
"estimate": "Kostenvoranschlag",
|
||||||
|
"repair_report": "Reparaturbericht",
|
||||||
|
"shipping": "Versandbeleg",
|
||||||
|
"other": "Dokument",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
|
||||||
|
detail = str(exc) or "Ungültige Datei"
|
||||||
|
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
|
||||||
|
return HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_label(repair: Repair) -> str:
|
||||||
|
return f"{repair.repair_number} · {repair.customer_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_label(document: RepairDocument) -> str:
|
||||||
|
return f"{document.title} · {document.original_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_document_data(document: RepairDocument) -> dict:
|
||||||
|
return {
|
||||||
|
"id": document.id,
|
||||||
|
"repair_id": document.repair_id,
|
||||||
|
"title": document.title,
|
||||||
|
"document_type": document.document_type,
|
||||||
|
"original_filename": document.original_filename,
|
||||||
|
"mime_type": document.mime_type,
|
||||||
|
"size_bytes": document.size_bytes,
|
||||||
|
"visibility": document.visibility,
|
||||||
|
"uploaded_by_user_id": document.uploaded_by_user_id,
|
||||||
|
"created_at": document.created_at,
|
||||||
|
"updated_at": document.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentService:
|
||||||
|
@staticmethod
|
||||||
|
async def upload_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
*,
|
||||||
|
file: UploadFile,
|
||||||
|
title: str,
|
||||||
|
document_type: str,
|
||||||
|
visibility: str,
|
||||||
|
note: str | None,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> RepairDocument:
|
||||||
|
storage_service = get_storage_service()
|
||||||
|
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
||||||
|
content = await file.read(max_bytes + 1)
|
||||||
|
original_filename = file.filename or ""
|
||||||
|
mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type)
|
||||||
|
|
||||||
|
RepairDocumentService._validate_repair_document_type(original_filename, mime_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = storage_service.save_file(
|
||||||
|
namespace=f"repairs/{repair.id}/documents",
|
||||||
|
content=content,
|
||||||
|
original_filename=original_filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise storage_validation_error(exc) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = RepairRepository.create_document(
|
||||||
|
db,
|
||||||
|
repair_id=repair.id,
|
||||||
|
title=title,
|
||||||
|
document_type=document_type,
|
||||||
|
original_filename=metadata.original_filename,
|
||||||
|
stored_filename=metadata.stored_filename,
|
||||||
|
storage_path=metadata.storage_key,
|
||||||
|
mime_type=metadata.mime_type,
|
||||||
|
size_bytes=metadata.size,
|
||||||
|
checksum_sha256=metadata.checksum_sha256,
|
||||||
|
visibility=visibility,
|
||||||
|
note=note,
|
||||||
|
uploaded_by_user_id=actor.id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
storage_service.delete_file(metadata.storage_key)
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.upload",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=document.id,
|
||||||
|
entity_label=_document_label(document),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"repair_id": repair.id,
|
||||||
|
"repair_number": repair.repair_number,
|
||||||
|
"document_type": document.document_type,
|
||||||
|
"visibility": document.visibility,
|
||||||
|
"mime_type": document.mime_type,
|
||||||
|
"size_bytes": document.size_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
document: RepairDocument,
|
||||||
|
payload: RepairDocumentUpdate,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> RepairDocument:
|
||||||
|
before_data = _audit_document_data(document)
|
||||||
|
updated = RepairRepository.update_document(db, document, payload)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.update",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_document_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_document_data(updated),
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
document: RepairDocument,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> None:
|
||||||
|
before_data = _audit_document_data(document)
|
||||||
|
storage_path = document.storage_path
|
||||||
|
label = _document_label(document)
|
||||||
|
try:
|
||||||
|
get_storage_service().delete_file(storage_path)
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||||
|
|
||||||
|
RepairRepository.delete_document(db, document)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.delete",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=document.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def open_document_file(document: RepairDocument) -> Path:
|
||||||
|
if not document.storage_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
|
||||||
|
try:
|
||||||
|
return get_storage_service().open_file(document.storage_path)
|
||||||
|
except StorageFileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str:
|
||||||
|
filename = document.original_filename or document.stored_filename or "reparatur-dokument"
|
||||||
|
safe_filename = filename.replace('"', "")
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_mime_type(original_filename: str, content_type: str | None) -> str:
|
||||||
|
if content_type and content_type != "application/octet-stream":
|
||||||
|
return content_type
|
||||||
|
return mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_repair_document_type(original_filename: str, mime_type: str) -> None:
|
||||||
|
extension = Path(original_filename or "").suffix.lower()
|
||||||
|
if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
|
||||||
|
if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
|
||||||
662
backend/hermes/app/services/repair_estimate_service.py
Normal file
662
backend/hermes/app/services/repair_estimate_service.py
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.models.repair import Repair
|
||||||
|
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.schemas.repair import RepairStatusUpdate
|
||||||
|
from app.schemas.repair_estimate import (
|
||||||
|
PublicEstimateDecisionRequest,
|
||||||
|
PublicEstimateItemResponse,
|
||||||
|
PublicEstimateResponse,
|
||||||
|
RepairEstimateCreate,
|
||||||
|
RepairEstimateItemPayload,
|
||||||
|
RepairEstimateUpdate,
|
||||||
|
calculate_item_total,
|
||||||
|
calculate_tax,
|
||||||
|
)
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.services.inventory_service import InventoryService
|
||||||
|
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||||
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_label(repair: Repair) -> str:
|
||||||
|
return f"{repair.repair_number} · {repair.customer_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_label(estimate: RepairEstimate) -> str:
|
||||||
|
return f"{estimate.estimate_number} · {estimate.title}"
|
||||||
|
|
||||||
|
|
||||||
|
def _money(cents: int, currency: str = "EUR") -> str:
|
||||||
|
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": estimate.id,
|
||||||
|
"repair_id": estimate.repair_id,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"status": estimate.status,
|
||||||
|
"title": estimate.title,
|
||||||
|
"currency": estimate.currency,
|
||||||
|
"valid_until": estimate.valid_until,
|
||||||
|
"sent_at": estimate.sent_at,
|
||||||
|
"approved_at": estimate.approved_at,
|
||||||
|
"declined_at": estimate.declined_at,
|
||||||
|
"created_by_user_id": estimate.created_by_user_id,
|
||||||
|
"inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ESTIMATE_STATUS_LABELS = {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"sent": "Wartet auf Freigabe",
|
||||||
|
"approved": "Freigegeben",
|
||||||
|
"declined": "Abgelehnt",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"cancelled": "Storniert",
|
||||||
|
"revoked": "Kostenvoranschlag wird überarbeitet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RepairEstimateService:
|
||||||
|
@staticmethod
|
||||||
|
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
|
||||||
|
estimate = RepairEstimate(
|
||||||
|
repair_id=repair.id,
|
||||||
|
estimate_number=estimate_number,
|
||||||
|
status="draft",
|
||||||
|
created_by_user_id=actor.id,
|
||||||
|
)
|
||||||
|
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||||
|
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||||
|
try:
|
||||||
|
db.add(estimate)
|
||||||
|
db.flush()
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
|
||||||
|
db.add(estimate)
|
||||||
|
db.flush()
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.create",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
after_data=_audit_estimate_data(estimate),
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request)
|
||||||
|
return estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
if estimate.status not in {"draft", "sent"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
||||||
|
before_data = _audit_estimate_data(estimate)
|
||||||
|
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
|
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.reserve",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.update",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_estimate_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_estimate_data(updated),
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||||
|
if estimate.status not in {"draft", "sent", "cancelled"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
|
||||||
|
before_data = _audit_estimate_data(estimate)
|
||||||
|
label = _estimate_label(estimate)
|
||||||
|
if estimate.status == "sent":
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateRepository.delete(db, estimate)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.delete",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
if estimate.status not in {"draft", "sent"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
|
||||||
|
|
||||||
|
if not repair.customer_email:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
|
||||||
|
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if not was_sent:
|
||||||
|
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||||
|
|
||||||
|
link = RepairPublicLinkService.create_with_audit(
|
||||||
|
db,
|
||||||
|
repair,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
audit_action="repairs.public_link.regenerate",
|
||||||
|
)
|
||||||
|
public_status_url = link.public_status_path
|
||||||
|
estimate = RepairEstimateRepository.mark_sent(db, estimate)
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
|
||||||
|
if not was_sent:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.reserve",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||||
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
|
mail_sent = False
|
||||||
|
if smtp_config.is_configured:
|
||||||
|
try:
|
||||||
|
SystemSettingsService.send_email(
|
||||||
|
smtp_config,
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
|
||||||
|
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
|
||||||
|
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
|
||||||
|
)
|
||||||
|
mail_sent = True
|
||||||
|
except Exception:
|
||||||
|
mail_sent = False
|
||||||
|
|
||||||
|
RepairRepository.create_notification_event(
|
||||||
|
db,
|
||||||
|
repair_id=repair.id,
|
||||||
|
event_type="repair_estimate_mail",
|
||||||
|
channel="email",
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
|
||||||
|
template="repair_estimate",
|
||||||
|
status="sent" if mail_sent else "failed",
|
||||||
|
success=mail_sent,
|
||||||
|
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
|
||||||
|
sent_at=datetime.now(UTC) if mail_sent else None,
|
||||||
|
)
|
||||||
|
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.send",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
|
||||||
|
)
|
||||||
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
|
estimate.status = "cancelled"
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.cancel",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
if was_sent:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
if estimate.status != "approved":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
|
||||||
|
|
||||||
|
before_data = _audit_estimate_data(estimate)
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
|
||||||
|
estimate.status = "revoked"
|
||||||
|
RepairEstimateRepository.add_event(
|
||||||
|
db,
|
||||||
|
estimate_id=estimate.id,
|
||||||
|
event_type="revoked",
|
||||||
|
actor_type="user",
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
note="Freigabe zurückgenommen",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
|
||||||
|
if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
|
||||||
|
RepairRepository.update_status(
|
||||||
|
db,
|
||||||
|
repair,
|
||||||
|
RepairStatusUpdate(
|
||||||
|
status="waiting_for_customer",
|
||||||
|
note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
|
||||||
|
),
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
link = RepairPublicLinkService.create_with_audit(
|
||||||
|
db,
|
||||||
|
repair,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
audit_action="repairs.public_link.regenerate",
|
||||||
|
)
|
||||||
|
subject = "Kostenvoranschlag wurde zurückgenommen"
|
||||||
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
|
mail_sent = False
|
||||||
|
if smtp_config.is_configured and repair.customer_email:
|
||||||
|
try:
|
||||||
|
SystemSettingsService.send_email(
|
||||||
|
smtp_config,
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=subject,
|
||||||
|
text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
|
||||||
|
html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
|
||||||
|
)
|
||||||
|
mail_sent = True
|
||||||
|
except Exception:
|
||||||
|
mail_sent = False
|
||||||
|
|
||||||
|
RepairRepository.create_notification_event(
|
||||||
|
db,
|
||||||
|
repair_id=repair.id,
|
||||||
|
event_type="repair_estimate_revoked_mail",
|
||||||
|
channel="email",
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=subject,
|
||||||
|
template="repair_estimate_revoked",
|
||||||
|
status="sent" if mail_sent else "failed",
|
||||||
|
success=mail_sent,
|
||||||
|
error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
|
||||||
|
sent_at=datetime.now(UTC) if mail_sent else None,
|
||||||
|
)
|
||||||
|
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.revoke",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_estimate_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_estimate_data(updated),
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
|
||||||
|
)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_estimate_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
||||||
|
if estimate is None:
|
||||||
|
return None
|
||||||
|
return PublicEstimateResponse(
|
||||||
|
estimate_number=estimate.estimate_number,
|
||||||
|
status=estimate.status,
|
||||||
|
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
|
||||||
|
title=estimate.title,
|
||||||
|
customer_message=estimate.customer_message,
|
||||||
|
subtotal_cents=estimate.subtotal_cents,
|
||||||
|
tax_cents=estimate.tax_cents,
|
||||||
|
total_cents=estimate.total_cents,
|
||||||
|
currency=estimate.currency,
|
||||||
|
valid_until=estimate.valid_until,
|
||||||
|
items=[
|
||||||
|
PublicEstimateItemResponse(
|
||||||
|
item_type=item.item_type,
|
||||||
|
inventory_snapshot_name=item.inventory_snapshot_name,
|
||||||
|
inventory_snapshot_sku=item.inventory_snapshot_sku,
|
||||||
|
inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer,
|
||||||
|
inventory_snapshot_part_number=item.inventory_snapshot_part_number,
|
||||||
|
title=item.title,
|
||||||
|
description=item.description,
|
||||||
|
quantity=item.quantity,
|
||||||
|
unit=item.unit,
|
||||||
|
unit_price_cents=item.unit_price_cents,
|
||||||
|
total_cents=item.total_cents,
|
||||||
|
)
|
||||||
|
for item in estimate.items
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def customer_decision(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
decision: str,
|
||||||
|
payload: PublicEstimateDecisionRequest,
|
||||||
|
*,
|
||||||
|
request: Request | None,
|
||||||
|
) -> RepairEstimate:
|
||||||
|
if estimate.status != "sent":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
estimate.customer_response_message = payload.message
|
||||||
|
if decision == "approve":
|
||||||
|
estimate.status = "approved"
|
||||||
|
estimate.approved_at = now
|
||||||
|
event_type = "approved"
|
||||||
|
repair_status = "approved"
|
||||||
|
note = "Kunde hat den Kostenvoranschlag freigegeben"
|
||||||
|
audit_action = "repair_estimates.approve"
|
||||||
|
elif decision == "decline":
|
||||||
|
estimate.status = "declined"
|
||||||
|
estimate.declined_at = now
|
||||||
|
event_type = "declined"
|
||||||
|
repair_status = "waiting_for_customer"
|
||||||
|
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
||||||
|
audit_action = "repair_estimates.decline"
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
|
||||||
|
else:
|
||||||
|
event_type = "question"
|
||||||
|
repair_status = "waiting_for_customer"
|
||||||
|
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
|
||||||
|
audit_action = "repair_estimates.question"
|
||||||
|
|
||||||
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
|
||||||
|
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action=audit_action,
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||||
|
)
|
||||||
|
if decision == "decline":
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||||
|
)
|
||||||
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
||||||
|
estimate.title = payload.title
|
||||||
|
estimate.customer_message = payload.customer_message
|
||||||
|
estimate.internal_note = payload.internal_note
|
||||||
|
estimate.tax_rate_percent = payload.tax_rate_percent
|
||||||
|
estimate.currency = payload.currency
|
||||||
|
estimate.valid_until = payload.valid_until
|
||||||
|
items, subtotal = RepairEstimateService._build_items(db, payload.items)
|
||||||
|
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
||||||
|
estimate.subtotal_cents = subtotal
|
||||||
|
estimate.tax_cents = tax_cents
|
||||||
|
estimate.total_cents = subtotal + tax_cents
|
||||||
|
estimate.items = items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_items(
|
||||||
|
db: Session,
|
||||||
|
payload_items: list[RepairEstimateItemPayload],
|
||||||
|
) -> tuple[list[RepairEstimateItem], int]:
|
||||||
|
items: list[RepairEstimateItem] = []
|
||||||
|
subtotal = 0
|
||||||
|
for index, payload in enumerate(payload_items, start=1):
|
||||||
|
item_type = payload.item_type
|
||||||
|
title = payload.title
|
||||||
|
description = payload.description
|
||||||
|
unit = payload.unit
|
||||||
|
unit_price_cents = payload.unit_price_cents
|
||||||
|
inventory_item_id = payload.inventory_item_id
|
||||||
|
inventory_snapshot_name = ""
|
||||||
|
inventory_snapshot_sku = ""
|
||||||
|
inventory_snapshot_manufacturer = None
|
||||||
|
inventory_snapshot_part_number = None
|
||||||
|
if inventory_item_id is not None:
|
||||||
|
inventory_item = InventoryRepository.get_item(db, inventory_item_id)
|
||||||
|
if inventory_item is None or not inventory_item.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden")
|
||||||
|
item_type = "part"
|
||||||
|
title = inventory_item.name
|
||||||
|
unit = inventory_item.unit
|
||||||
|
inventory_snapshot_name = inventory_item.name
|
||||||
|
inventory_snapshot_sku = inventory_item.sku
|
||||||
|
inventory_snapshot_manufacturer = inventory_item.manufacturer
|
||||||
|
inventory_snapshot_part_number = inventory_item.manufacturer_part_number
|
||||||
|
if not payload.inventory_price_overridden:
|
||||||
|
unit_price_cents = inventory_item.selling_price_cents or 0
|
||||||
|
total = calculate_item_total(payload.quantity, unit_price_cents)
|
||||||
|
subtotal += total
|
||||||
|
items.append(
|
||||||
|
RepairEstimateItem(
|
||||||
|
inventory_item_id=inventory_item_id,
|
||||||
|
inventory_snapshot_name=inventory_snapshot_name,
|
||||||
|
inventory_snapshot_sku=inventory_snapshot_sku,
|
||||||
|
inventory_snapshot_manufacturer=inventory_snapshot_manufacturer,
|
||||||
|
inventory_snapshot_part_number=inventory_snapshot_part_number,
|
||||||
|
position=index,
|
||||||
|
item_type=item_type,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
unit=unit,
|
||||||
|
unit_price_cents=unit_price_cents,
|
||||||
|
total_cents=total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items, subtotal
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]:
|
||||||
|
overrides: list[dict] = []
|
||||||
|
for payload in payload_items:
|
||||||
|
if payload.inventory_item_id is None or not payload.inventory_price_overridden:
|
||||||
|
continue
|
||||||
|
inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id)
|
||||||
|
if inventory_item is None:
|
||||||
|
continue
|
||||||
|
default_price = inventory_item.selling_price_cents or 0
|
||||||
|
if payload.unit_price_cents != default_price:
|
||||||
|
overrides.append({
|
||||||
|
"inventory_item_id": inventory_item.id,
|
||||||
|
"label": f"{inventory_item.sku} · {inventory_item.name}",
|
||||||
|
"default_price_cents": default_price,
|
||||||
|
"override_price_cents": payload.unit_price_cents,
|
||||||
|
})
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None:
|
||||||
|
for override in overrides:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.price_override",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=override["inventory_item_id"],
|
||||||
|
entity_label=override["label"],
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"estimate_id": estimate.id,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"default_price_cents": override["default_price_cents"],
|
||||||
|
"override_price_cents": override["override_price_cents"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||||
|
seen_item_ids: set[int] = set()
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids:
|
||||||
|
continue
|
||||||
|
seen_item_ids.add(estimate_item.inventory_item_id)
|
||||||
|
inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level:
|
||||||
|
continue
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.stock.low",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=inventory_item.id,
|
||||||
|
entity_label=f"{inventory_item.sku} · {inventory_item.name}",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"estimate_id": estimate.id,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"quantity_available": inventory_item.quantity_available,
|
||||||
|
"reorder_level": inventory_item.reorder_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
|
return (
|
||||||
|
f"Hallo {repair.customer_name},\n\n"
|
||||||
|
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
|
||||||
|
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
|
||||||
|
f"Kostenvoranschlag: {estimate.estimate_number}\n"
|
||||||
|
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
|
||||||
|
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
|
||||||
|
"Funktechnik Schubert"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
|
||||||
|
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
|
||||||
|
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
|
||||||
|
<tr><td style="padding:28px;">
|
||||||
|
<p>Hallo {escape(repair.customer_name)},</p>
|
||||||
|
<p>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
|
||||||
|
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
|
||||||
|
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
||||||
|
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
|
||||||
|
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
|
||||||
|
</td></tr></table></body></html>"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
|
return (
|
||||||
|
f"Hallo {repair.customer_name},\n\n"
|
||||||
|
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
|
||||||
|
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
|
||||||
|
f"Reparatur: {repair.repair_number}\n"
|
||||||
|
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
|
||||||
|
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
|
||||||
|
"Funktechnik Schubert"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _revoked_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
|
||||||
|
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
|
||||||
|
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
|
||||||
|
<tr><td style="padding:28px;">
|
||||||
|
<p>Hallo {escape(repair.customer_name)},</p>
|
||||||
|
<p>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
|
||||||
|
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
|
||||||
|
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</p>
|
||||||
|
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Status ansehen</a></p>
|
||||||
|
</td></tr></table></body></html>"""
|
||||||
|
|
@ -11,6 +11,7 @@ from app.core.config import settings
|
||||||
from app.models.repair import Repair, RepairPublicAccessToken
|
from app.models.repair import Repair, RepairPublicAccessToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
from app.schemas.repair import (
|
from app.schemas.repair import (
|
||||||
RepairPublicLinkCreateResponse,
|
RepairPublicLinkCreateResponse,
|
||||||
RepairPublicLinkResponse,
|
RepairPublicLinkResponse,
|
||||||
|
|
@ -131,16 +132,12 @@ class RepairPublicLinkService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
|
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
|
||||||
normalized_token = normalize_token(token)
|
public_link = RepairPublicLinkService.get_public_link_or_404(db, 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")
|
|
||||||
|
|
||||||
repair = public_link.repair
|
repair = public_link.repair
|
||||||
history = RepairRepository.get_history_public(db, repair.id)
|
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(
|
response = RepairPublicStatusResponse(
|
||||||
repair_number=repair.repair_number,
|
repair_number=repair.repair_number,
|
||||||
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
|
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
|
||||||
|
|
@ -155,6 +152,18 @@ class RepairPublicLinkService:
|
||||||
for item in history
|
for item in history
|
||||||
],
|
],
|
||||||
updated_at=repair.updated_at,
|
updated_at=repair.updated_at,
|
||||||
|
estimate=RepairEstimateService.public_response(estimate),
|
||||||
)
|
)
|
||||||
RepairRepository.mark_public_link_used(db, public_link)
|
RepairRepository.mark_public_link_used(db, public_link)
|
||||||
return response
|
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
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider):
|
||||||
"customers",
|
"customers",
|
||||||
"projects",
|
"projects",
|
||||||
"tickets",
|
"tickets",
|
||||||
|
"repairs",
|
||||||
"imports",
|
"imports",
|
||||||
"temp",
|
"temp",
|
||||||
]:
|
]:
|
||||||
|
|
|
||||||
|
|
@ -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:-}
|
||||||
|
|
@ -22,6 +22,9 @@ services:
|
||||||
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
||||||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
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:
|
volumes:
|
||||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -35,6 +35,9 @@ services:
|
||||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
|
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
|
||||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
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:
|
volumes:
|
||||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||||
|
|
|
||||||
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
const { filename } = await context.params;
|
||||||
|
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`);
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
const { filename } = await context.params;
|
||||||
|
return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`);
|
||||||
|
}
|
||||||
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
const { filename } = await context.params;
|
||||||
|
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`);
|
||||||
|
}
|
||||||
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
const { filename } = await context.params;
|
||||||
|
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`);
|
||||||
|
}
|
||||||
12
frontend/athena/app/api/backups/create/route.ts
Normal file
12
frontend/athena/app/api/backups/create/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
return proxyHermesRequest(request, "/backups/create");
|
||||||
|
}
|
||||||
7
frontend/athena/app/api/backups/route.ts
Normal file
7
frontend/athena/app/api/backups/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/backups");
|
||||||
|
}
|
||||||
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyCategoryRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/categories/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyCategoryRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyCategoryRequest(request, context);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/inventory/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/inventory/categories");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}/movements`);
|
||||||
|
}
|
||||||
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyItemRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
return proxyItemRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyItemRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyItemRequest(request, context);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/inventory/items");
|
||||||
|
}
|
||||||
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`);
|
||||||
|
}
|
||||||
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyLocationRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/locations/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyLocationRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyLocationRequest(request, context);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/inventory/locations");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/inventory/locations");
|
||||||
|
}
|
||||||
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxySupplierRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/inventory/suppliers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxySupplierRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxySupplierRequest(request, context);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/lexware/settings/route.ts
Normal file
18
frontend/athena/app/api/lexware/settings/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/lexware/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/lexware/settings");
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/lexware/test-connection/route.ts
Normal file
14
frontend/athena/app/api/lexware/test-connection/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/lexware/test-connection");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, documentId } = await params;
|
||||||
|
return proxyHermesStreamRequest(
|
||||||
|
request,
|
||||||
|
`/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyDocumentRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, documentId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents/upload`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyEstimateRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
return proxyEstimateRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyEstimateRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyEstimateRequest(request, context);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; estimateId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, estimateId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`);
|
||||||
|
}
|
||||||
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal file
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
|
||||||
|
}
|
||||||
415
frontend/athena/app/backups/page.tsx
Normal file
415
frontend/athena/app/backups/page.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import SummaryCard from "@/components/common/SummaryCard";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import type { ApiSuccess } from "@/types/audit";
|
||||||
|
import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup";
|
||||||
|
import type { CurrentUser } from "@/types/rbac";
|
||||||
|
|
||||||
|
const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO";
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? response?.data?.message ?? fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "Unbekannt";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [data, setData] = useState<BackupListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<BackupSummary | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [validationByFile, setValidationByFile] = useState<Record<string, BackupValidationResponse>>({});
|
||||||
|
const [validatingFile, setValidatingFile] = useState("");
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<BackupSummary | null>(null);
|
||||||
|
const [restoreConfirm, setRestoreConfirm] = useState("");
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
|
||||||
|
const loadBackups = useCallback(async () => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await api.get<ApiSuccess<BackupListResponse>>("/backups");
|
||||||
|
setData(response.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err, "Backups konnten nicht geladen werden."));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadBackups();
|
||||||
|
});
|
||||||
|
}, [loadBackups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CurrentUser>("/me");
|
||||||
|
setCurrentUser(response.data);
|
||||||
|
} catch {
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cards = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: "Backups", value: data.total_count },
|
||||||
|
{ label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) },
|
||||||
|
{ label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" },
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const canCreate = hasPermission(currentUser, "backup.create");
|
||||||
|
const canDownload = hasPermission(currentUser, "backup.download");
|
||||||
|
const canDelete = hasPermission(currentUser, "backup.delete");
|
||||||
|
const canRestore = hasPermission(currentUser, "backup.restore");
|
||||||
|
|
||||||
|
async function createBackup() {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await api.post<ApiSuccess<BackupSummary>>("/backups/create");
|
||||||
|
await loadBackups();
|
||||||
|
showToast({ type: "success", title: "Backup erstellt" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Backup konnte nicht erstellt werden",
|
||||||
|
description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateBackup(filename: string) {
|
||||||
|
setValidatingFile(filename);
|
||||||
|
try {
|
||||||
|
const response = await api.post<ApiSuccess<BackupValidationResponse>>(`/backups/${encodeURIComponent(filename)}/validate`);
|
||||||
|
setValidationByFile((current) => ({ ...current, [filename]: response.data.data }));
|
||||||
|
showToast({
|
||||||
|
type: response.data.data.valid ? "success" : "error",
|
||||||
|
title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen",
|
||||||
|
description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Backup konnte nicht validiert werden",
|
||||||
|
description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setValidatingFile("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup() {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadBackups();
|
||||||
|
showToast({ type: "success", title: "Backup geloescht" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Backup konnte nicht geloescht werden",
|
||||||
|
description: getErrorMessage(err, "Bitte versuche es erneut."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup() {
|
||||||
|
if (!restoreTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, {
|
||||||
|
confirm_text: restoreConfirm,
|
||||||
|
});
|
||||||
|
showToast({ type: "success", title: "Restore abgeschlossen" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "info",
|
||||||
|
title: "CLI-Restore erforderlich",
|
||||||
|
description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Backups werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Backups verfuegbar"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Backups</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => void loadBackups()}>
|
||||||
|
<RefreshCcw />
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
{canCreate && (
|
||||||
|
<Button type="button" onClick={() => void createBackup()} disabled={creating}>
|
||||||
|
<Archive />
|
||||||
|
{creating ? "Backup wird erstellt..." : "Backup erstellen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SummaryCard key={card.label} label={card.label} value={card.value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white">
|
||||||
|
<div className="border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Verfuegbare Backup-Dateien</h2>
|
||||||
|
</div>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<div className="px-6 py-10 text-sm text-slate-500">Noch keine Backup-Dateien vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{data.items.map((backup) => {
|
||||||
|
const validation = validationByFile[backup.filename];
|
||||||
|
return (
|
||||||
|
<div key={backup.filename} className="px-6 py-5">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="truncate font-semibold text-slate-950">{backup.filename}</p>
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
backup.validation_status === "valid"
|
||||||
|
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
|
||||||
|
: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20"
|
||||||
|
}`}>
|
||||||
|
{backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Erstellt</p>
|
||||||
|
<p>{formatDate(backup.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Groesse</p>
|
||||||
|
<p>{formatBytes(backup.size_bytes)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Version</p>
|
||||||
|
<p>{backup.app_version || "Unbekannt"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Ersteller</p>
|
||||||
|
<p>{backup.created_by_username || "System"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Datenbank</p>
|
||||||
|
<p>{backup.database_name || "Unbekannt"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Dateien</p>
|
||||||
|
<p>{backup.file_count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Storage-Pfad</p>
|
||||||
|
<p className="break-all">{backup.storage_base_path || "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{validation && (
|
||||||
|
<div className={`mt-4 rounded-lg border p-4 text-sm ${
|
||||||
|
validation.valid
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-950"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-950"
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{validation.valid ? <RotateCcw className="mt-0.5 h-4 w-4" /> : <AlertTriangle className="mt-0.5 h-4 w-4" />}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{validation.message}</p>
|
||||||
|
<p>Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}</p>
|
||||||
|
{!validation.valid && validation.issues.length > 0 && (
|
||||||
|
<p>{validation.issues.join(" | ")}</p>
|
||||||
|
)}
|
||||||
|
{validation.requires_cli_restore && (
|
||||||
|
<p>Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
{canDownload && (
|
||||||
|
<a
|
||||||
|
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{canRestore && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void validateBackup(backup.filename)}
|
||||||
|
disabled={validatingFile === backup.filename}
|
||||||
|
>
|
||||||
|
<ShieldAlert />
|
||||||
|
{validatingFile === backup.filename ? "Prueft..." : "Validieren"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setRestoreTarget(backup);
|
||||||
|
setRestoreConfirm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
Restore vorbereiten
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button type="button" variant="destructive" onClick={() => setDeleteTarget(backup)}>
|
||||||
|
<Trash2 />
|
||||||
|
Loeschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="Backup loeschen"
|
||||||
|
description="Die Backup-Datei wird dauerhaft entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden."
|
||||||
|
confirmLabel="Backup loeschen"
|
||||||
|
pending={deleting}
|
||||||
|
pendingLabel="Backup wird geloescht..."
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={() => void deleteBackup()}
|
||||||
|
>
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="space-y-1 text-sm text-slate-700">
|
||||||
|
<p className="font-medium">{deleteTarget.filename}</p>
|
||||||
|
<p>{formatBytes(deleteTarget.size_bytes)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={restoreTarget !== null}
|
||||||
|
title="Restore vorbereiten"
|
||||||
|
description="Restore ist ein Hochrisiko-Vorgang. Bitte bestaetige den Text exakt, bevor Olympus den CLI-Restore vorbereitet."
|
||||||
|
confirmLabel="Restore starten"
|
||||||
|
pending={restoring}
|
||||||
|
pendingLabel="Restore wird vorbereitet..."
|
||||||
|
confirmDisabled={restoreConfirm !== RESTORE_CONFIRM_TEXT}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRestoreTarget(null);
|
||||||
|
setRestoreConfirm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={() => void restoreBackup()}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p>Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-slate-700">Bestaetigungstext</p>
|
||||||
|
<Input
|
||||||
|
value={restoreConfirm}
|
||||||
|
onChange={(event) => setRestoreConfirm(event.target.value)}
|
||||||
|
placeholder={RESTORE_CONFIRM_TEXT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{restoreTarget && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Ziel-Backup: <span className="font-medium text-slate-900">{restoreTarget.filename}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export default function DashboardPage() {
|
||||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
|
const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { formatDate, formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { InventoryItem, InventoryStockMovement } from "@/types/inventory";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const movementLabels: Record<string, string> = {
|
||||||
|
initial: "Anfangsbestand",
|
||||||
|
purchase: "Einkauf",
|
||||||
|
adjustment: "Anpassung",
|
||||||
|
reservation: "Reservierung",
|
||||||
|
release: "Freigabe",
|
||||||
|
consumption: "Verbrauch",
|
||||||
|
return: "Rückgabe",
|
||||||
|
correction: "Korrektur",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InventoryItemDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [item, setItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [movements, setMovements] = useState<InventoryStockMovement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const loadItem = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [itemResponse, movementResponse] = await Promise.all([
|
||||||
|
api.get<InventoryItem>(`/inventory/items/${id}`),
|
||||||
|
api.get<InventoryStockMovement[]>(`/inventory/items/${id}/movements`),
|
||||||
|
]);
|
||||||
|
setItem(itemResponse.data);
|
||||||
|
setMovements(movementResponse.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadItem();
|
||||||
|
});
|
||||||
|
}, [loadItem]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Artikel wird geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !item) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Artikel nicht gefunden"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">{item.sku}</p>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">{item.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/inventory"><Button type="button" variant="outline">Zurück zum Lager</Button></Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
|
||||||
|
<h2 className="font-semibold text-slate-950">Stammdaten</h2>
|
||||||
|
<dl className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<Info label="Beschreibung" value={item.description ?? "Keine Angabe"} />
|
||||||
|
<Info label="Hersteller" value={item.manufacturer ?? "Keine Angabe"} />
|
||||||
|
<Info label="Hersteller-Teilenummer" value={item.manufacturer_part_number ?? "Keine Angabe"} />
|
||||||
|
<Info label="Lieferanten-Teilenummer" value={item.supplier_part_number ?? "Keine Angabe"} />
|
||||||
|
<Info label="Lieferant" value={item.supplier?.name ?? "Kein Lieferant"} />
|
||||||
|
<Info label="Lagerort" value={item.location?.name ?? "Kein Lagerort"} />
|
||||||
|
<Info label="Kompatible Geräte" value={item.compatible_devices ?? "Keine Angabe"} />
|
||||||
|
<Info label="Notizen" value={item.notes ?? "Keine Notizen"} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="font-semibold text-slate-950">Bestand</h2>
|
||||||
|
<dl className="mt-4 grid gap-3">
|
||||||
|
<Metric label="Bestand" value={`${item.quantity_on_hand} ${item.unit}`} />
|
||||||
|
<Metric label="Reserviert" value={`${item.quantity_reserved} ${item.unit}`} />
|
||||||
|
<Metric label="Verfügbar" value={`${item.quantity_available} ${item.unit}`} />
|
||||||
|
<Metric label="Mindestbestand" value={`${item.reorder_level} ${item.unit}`} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="font-semibold text-slate-950">Preise</h2>
|
||||||
|
<dl className="mt-4 grid gap-3">
|
||||||
|
<Metric label="Einkaufspreis" value={formatMoney(item.purchase_price_cents, item.currency)} />
|
||||||
|
<Metric label="Verkaufspreis" value={formatMoney(item.selling_price_cents, item.currency)} />
|
||||||
|
<Metric label="MwSt." value={`${item.tax_rate_percent} %`} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
|
||||||
|
<h2 className="font-semibold text-slate-950">Bewegungen</h2>
|
||||||
|
{movements.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-slate-500">Noch keine Bewegungen vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 divide-y">
|
||||||
|
{movements.map((movement) => (
|
||||||
|
<div key={movement.id} className="py-3">
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="font-medium text-slate-950">{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}</p>
|
||||||
|
<time className="text-xs text-slate-500" dateTime={movement.created_at}>{formatDate(movement.created_at)}</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{movement.reason}{movement.note ? ` · ${movement.note}` : ""}</p>
|
||||||
|
{movement.actor_username && <p className="mt-1 text-xs text-slate-400">Gebucht von {movement.actor_username}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3">
|
||||||
|
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 text-lg font-semibold text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
frontend/athena/app/inventory/page.tsx
Normal file
322
frontend/athena/app/inventory/page.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import InventoryItemDialog from "@/components/inventory/InventoryItemDialog";
|
||||||
|
import MasterDataSection from "@/components/inventory/MasterDataSection";
|
||||||
|
import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog";
|
||||||
|
import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import type {
|
||||||
|
InventoryCategory,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemListResponse,
|
||||||
|
InventoryItemPayload,
|
||||||
|
InventoryLocation,
|
||||||
|
InventoryStockPayload,
|
||||||
|
InventorySupplier,
|
||||||
|
} from "@/types/inventory";
|
||||||
|
import type { CurrentUser } from "@/types/rbac";
|
||||||
|
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
export default function InventoryPage() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
|
const [categories, setCategories] = useState<InventoryCategory[]>([]);
|
||||||
|
const [locations, setLocations] = useState<InventoryLocation[]>([]);
|
||||||
|
const [suppliers, setSuppliers] = useState<InventorySupplier[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [stockError, setStockError] = useState("");
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [locationFilter, setLocationFilter] = useState("all");
|
||||||
|
const [supplierFilter, setSupplierFilter] = useState("all");
|
||||||
|
const [stockFilter, setStockFilter] = useState<"all" | "low">("all");
|
||||||
|
const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortKey, setSortKey] = useState("name");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [stockOpen, setStockOpen] = useState(false);
|
||||||
|
const [stockAction, setStockAction] = useState<StockAction>("adjust");
|
||||||
|
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<InventoryItem | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", String(pageSize));
|
||||||
|
params.set("offset", String((page - 1) * pageSize));
|
||||||
|
if (search.trim()) params.set("q", search.trim());
|
||||||
|
if (categoryFilter !== "all") params.set("category_id", categoryFilter);
|
||||||
|
if (locationFilter !== "all") params.set("location_id", locationFilter);
|
||||||
|
if (supplierFilter !== "all") params.set("supplier_id", supplierFilter);
|
||||||
|
if (stockFilter === "low") params.set("low_stock", "true");
|
||||||
|
if (activeFilter !== "all") params.set("active", activeFilter);
|
||||||
|
const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([
|
||||||
|
api.get<InventoryItemListResponse>(`/inventory/items?${params.toString()}`),
|
||||||
|
api.get<InventoryCategory[]>("/inventory/categories"),
|
||||||
|
api.get<InventoryLocation[]>("/inventory/locations"),
|
||||||
|
api.get<InventorySupplier[]>("/inventory/suppliers"),
|
||||||
|
api.get<CurrentUser>("/me"),
|
||||||
|
]);
|
||||||
|
setItems(itemResponse.data.items);
|
||||||
|
setTotal(itemResponse.data.total);
|
||||||
|
setCategories(categoryResponse.data);
|
||||||
|
setLocations(locationResponse.data);
|
||||||
|
setSuppliers(supplierResponse.data);
|
||||||
|
setCurrentUser(meResponse.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadData();
|
||||||
|
});
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const canCreate = hasPermission(currentUser, "inventory.create");
|
||||||
|
const canUpdate = hasPermission(currentUser, "inventory.update");
|
||||||
|
const canDelete = hasPermission(currentUser, "inventory.delete");
|
||||||
|
const canAdjust = hasPermission(currentUser, "inventory.stock.adjust");
|
||||||
|
const canReserve = hasPermission(currentUser, "inventory.stock.reserve");
|
||||||
|
const canConsume = hasPermission(currentUser, "inventory.stock.consume");
|
||||||
|
const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata");
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const currentPage = Math.min(page, pageCount);
|
||||||
|
|
||||||
|
const sortedItems = useMemo(() => [...items].sort((first, second) => {
|
||||||
|
const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase();
|
||||||
|
const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase();
|
||||||
|
if (firstValue === secondValue) return 0;
|
||||||
|
const result = firstValue > secondValue ? 1 : -1;
|
||||||
|
return sortDirection === "asc" ? result : -result;
|
||||||
|
}), [items, sortDirection, sortKey]);
|
||||||
|
|
||||||
|
const columns: DataTableColumn<InventoryItem>[] = [
|
||||||
|
{ key: "sku", label: "SKU", sortable: true, render: (item) => <span className="font-medium text-slate-950">{item.sku}</span> },
|
||||||
|
{ key: "name", label: "Name", sortable: true, render: (item) => item.name },
|
||||||
|
{ key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" },
|
||||||
|
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" },
|
||||||
|
{ key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand },
|
||||||
|
{ key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved },
|
||||||
|
{ key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => <span className={item.quantity_available <= item.reorder_level ? "font-semibold text-amber-700" : ""}>{item.quantity_available}</span> },
|
||||||
|
{ key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level },
|
||||||
|
{ key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" },
|
||||||
|
{ key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) },
|
||||||
|
{ key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Aktionen",
|
||||||
|
className: "px-4 py-3 text-right",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/inventory/items/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||||
|
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedItem(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
|
||||||
|
{canAdjust && <Button type="button" variant="ghost" size="icon-sm" title="Bestand anpassen" onClick={() => openStock(item, "adjust")}><Wrench size={16} /></Button>}
|
||||||
|
{canReserve && <Button type="button" variant="ghost" size="icon-sm" title="Reservieren" onClick={() => openStock(item, "reserve")}><PackagePlus size={16} /></Button>}
|
||||||
|
{canConsume && <Button type="button" variant="ghost" size="icon-sm" title="Verbrauch buchen" onClick={() => openStock(item, "consume")}><RotateCcw size={16} /></Button>}
|
||||||
|
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Deaktivieren" onClick={() => { setDeleteItem(item); setDeleteError(""); }}><Trash2 size={16} /></Button>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function openStock(item: InventoryItem, action: StockAction) {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setStockAction(action);
|
||||||
|
setStockError("");
|
||||||
|
setStockOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSort(key: string) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItem(payload: InventoryItemPayload) {
|
||||||
|
setPending(true);
|
||||||
|
setFormError("");
|
||||||
|
try {
|
||||||
|
if (selectedItem) {
|
||||||
|
await api.put<InventoryItem>(`/inventory/items/${selectedItem.id}`, payload);
|
||||||
|
showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku });
|
||||||
|
} else {
|
||||||
|
const response = await api.post<InventoryItem>("/inventory/items", payload);
|
||||||
|
showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku });
|
||||||
|
}
|
||||||
|
setFormOpen(false);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setFormError(message);
|
||||||
|
showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStock(payload: InventoryStockPayload) {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setPending(true);
|
||||||
|
setStockError("");
|
||||||
|
try {
|
||||||
|
await api.post<InventoryItem>(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload);
|
||||||
|
showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku });
|
||||||
|
setStockOpen(false);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setStockError(message);
|
||||||
|
showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteItem) return;
|
||||||
|
setPending(true);
|
||||||
|
setDeleteError("");
|
||||||
|
try {
|
||||||
|
await api.delete(`/inventory/items/${deleteItem.id}`);
|
||||||
|
showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku });
|
||||||
|
setDeleteItem(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setDeleteError(message);
|
||||||
|
showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Lager / Ersatzteile</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{total} Artikel im aktuellen Filter</p>
|
||||||
|
</div>
|
||||||
|
{canCreate && <Button type="button" onClick={() => { setSelectedItem(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Artikel anlegen</Button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["items", "categories", "locations", "suppliers"] as const).map((value) => (
|
||||||
|
<Button key={value} type="button" variant={tab === value ? "default" : "outline"} onClick={() => setTab(value)}>
|
||||||
|
{{ items: "Artikel", categories: "Kategorien", locations: "Lagerorte", suppliers: "Lieferanten" }[value]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "items" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
|
||||||
|
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Artikel suchen" />
|
||||||
|
<select value={categoryFilter} onChange={(event) => { setCategoryFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Kategorien</option>
|
||||||
|
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={locationFilter} onChange={(event) => { setLocationFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Lagerorte</option>
|
||||||
|
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={supplierFilter} onChange={(event) => { setSupplierFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Lieferanten</option>
|
||||||
|
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={stockFilter} onChange={(event) => { setStockFilter(event.target.value as "all" | "low"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Bestände</option>
|
||||||
|
<option value="low">Niedriger Bestand</option>
|
||||||
|
</select>
|
||||||
|
<select value={activeFilter} onChange={(event) => { setActiveFilter(event.target.value as "true" | "false" | "all"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="true">Aktiv</option>
|
||||||
|
<option value="false">Inaktiv</option>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && total === 0 && !search ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Noch keine Ersatzteile vorhanden."
|
||||||
|
description="Lege den ersten Artikel an, um Bestand, Preise und Lieferanten zu pflegen."
|
||||||
|
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Artikel anlegen</Button> : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={sortedItems}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
emptyTitle="Keine Artikel gefunden."
|
||||||
|
emptyDescription="Passe Suche oder Filter an."
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||||
|
<span>Seite {currentPage} von {pageCount}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
{tab === "categories" && <MasterDataSection title="Kategorien" kind="categories" entries={categories} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
{tab === "locations" && <MasterDataSection title="Lagerorte" kind="locations" entries={locations} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
{tab === "suppliers" && <MasterDataSection title="Lieferanten" kind="suppliers" entries={suppliers} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InventoryItemDialog key={`item-${selectedItem?.id ?? "new"}-${formOpen}`} open={formOpen} item={selectedItem} categories={categories} locations={locations} suppliers={suppliers} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveItem} />
|
||||||
|
<StockActionDialog key={`stock-${selectedItem?.id ?? "none"}-${stockAction}-${stockOpen}`} open={stockOpen} item={selectedItem} action={stockAction} pending={pending} serverError={stockError} onOpenChange={setStockOpen} onSubmit={saveStock} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteItem)}
|
||||||
|
title="Artikel deaktivieren"
|
||||||
|
description="Artikel mit Bewegungen werden nicht hart gelöscht, sondern deaktiviert."
|
||||||
|
confirmLabel="Deaktivieren"
|
||||||
|
pending={pending}
|
||||||
|
onOpenChange={(open) => !open && setDeleteItem(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{deleteItem?.sku} · {deleteItem?.name}</p>
|
||||||
|
{deleteError && <p className="text-sm text-red-600">{deleteError}</p>}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr
|
||||||
|
|
||||||
import DetailSection from "@/components/common/DetailSection";
|
import DetailSection from "@/components/common/DetailSection";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection";
|
||||||
|
import RepairEstimatesSection from "@/components/repairs/RepairEstimatesSection";
|
||||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||||
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||||
|
|
@ -168,6 +170,13 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
const canUpdate = hasPermission(currentUser, "repairs.update");
|
const canUpdate = hasPermission(currentUser, "repairs.update");
|
||||||
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
||||||
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
|
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
|
||||||
|
const canReadEstimates = hasPermission(currentUser, "repair_estimates.read");
|
||||||
|
const canCreateEstimates = hasPermission(currentUser, "repair_estimates.create");
|
||||||
|
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
|
||||||
|
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
|
||||||
|
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
|
||||||
|
const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke");
|
||||||
|
const canLexwareExport = hasPermission(currentUser, "lexware.export");
|
||||||
|
|
||||||
async function createPublicLink() {
|
async function createPublicLink() {
|
||||||
if (!repair || !canManagePublicLink) return;
|
if (!repair || !canManagePublicLink) return;
|
||||||
|
|
@ -426,6 +435,24 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
})()}
|
})()}
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Kostenvoranschläge">
|
||||||
|
<RepairEstimatesSection
|
||||||
|
repairId={repair.id}
|
||||||
|
customerEmail={repair.customer_email}
|
||||||
|
canRead={canReadEstimates}
|
||||||
|
canCreate={canCreateEstimates}
|
||||||
|
canUpdate={canUpdateEstimates}
|
||||||
|
canDelete={canDeleteEstimates}
|
||||||
|
canSend={canSendEstimates}
|
||||||
|
canRevoke={canRevokeEstimates}
|
||||||
|
canLexwareExport={canLexwareExport}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Dokumente & Bilder">
|
||||||
|
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||||
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
||||||
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-react";
|
import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
LexwareSettings,
|
||||||
|
LexwareSettingsPayload,
|
||||||
|
LexwareTestConnectionResponse,
|
||||||
|
} from "@/types/lexware";
|
||||||
import type {
|
import type {
|
||||||
PublicLinksSettings,
|
PublicLinksSettings,
|
||||||
SettingsSource,
|
SettingsSource,
|
||||||
|
|
@ -16,7 +21,7 @@ import type {
|
||||||
SmtpTestResponse,
|
SmtpTestResponse,
|
||||||
} from "@/types/system-settings";
|
} from "@/types/system-settings";
|
||||||
|
|
||||||
type SettingsTab = "smtp" | "public-links";
|
type SettingsTab = "smtp" | "public-links" | "lexware";
|
||||||
|
|
||||||
const sourceLabels: Record<SettingsSource, string> = {
|
const sourceLabels: Record<SettingsSource, string> = {
|
||||||
database: "Admin-Konfiguration",
|
database: "Admin-Konfiguration",
|
||||||
|
|
@ -46,6 +51,18 @@ function emptySmtpSettings(): SmtpSettings {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyLexwareSettings(): LexwareSettings {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
api_base_url: "https://api.lexware.io",
|
||||||
|
api_key_is_set: false,
|
||||||
|
organization_name: "",
|
||||||
|
default_tax_rate: "19.00",
|
||||||
|
default_payment_terms_days: 14,
|
||||||
|
source: "missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
|
||||||
|
|
@ -56,22 +73,28 @@ export default function SettingsPage() {
|
||||||
repair_status_base_url: "",
|
repair_status_base_url: "",
|
||||||
source: "missing",
|
source: "missing",
|
||||||
});
|
});
|
||||||
|
const [lexware, setLexware] = useState<LexwareSettings>(emptyLexwareSettings());
|
||||||
|
const [newLexwareApiKey, setNewLexwareApiKey] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingSmtp, setSavingSmtp] = useState(false);
|
const [savingSmtp, setSavingSmtp] = useState(false);
|
||||||
const [sendingTest, setSendingTest] = useState(false);
|
const [sendingTest, setSendingTest] = useState(false);
|
||||||
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
|
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
|
||||||
|
const [savingLexware, setSavingLexware] = useState(false);
|
||||||
|
const [testingLexware, setTestingLexware] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [smtpResponse, publicLinksResponse] = await Promise.all([
|
const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([
|
||||||
api.get<SmtpSettings>("/system-settings/smtp"),
|
api.get<SmtpSettings>("/system-settings/smtp"),
|
||||||
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
||||||
|
api.get<LexwareSettings>("/lexware/settings"),
|
||||||
]);
|
]);
|
||||||
setSmtp(smtpResponse.data);
|
setSmtp(smtpResponse.data);
|
||||||
setPublicLinks(publicLinksResponse.data);
|
setPublicLinks(publicLinksResponse.data);
|
||||||
|
setLexware(lexwareResponse.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
|
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -158,6 +181,56 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveLexware() {
|
||||||
|
setSavingLexware(true);
|
||||||
|
try {
|
||||||
|
const payload: LexwareSettingsPayload = {
|
||||||
|
enabled: lexware.enabled,
|
||||||
|
api_base_url: lexware.api_base_url,
|
||||||
|
organization_name: lexware.organization_name,
|
||||||
|
default_tax_rate: lexware.default_tax_rate,
|
||||||
|
default_payment_terms_days: lexware.default_payment_terms_days,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newLexwareApiKey) {
|
||||||
|
payload.api_key = newLexwareApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.put<LexwareSettings>("/lexware/settings", payload);
|
||||||
|
setLexware(response.data);
|
||||||
|
setNewLexwareApiKey("");
|
||||||
|
showToast({ type: "success", title: "Lexware-Konfiguration gespeichert" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Lexware konnte nicht gespeichert werden",
|
||||||
|
description: getErrorMessage(err, "Bitte prüfe die Lexware-Einstellungen."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingLexware(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testLexwareConnection() {
|
||||||
|
setTestingLexware(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post<LexwareTestConnectionResponse>("/lexware/test-connection");
|
||||||
|
showToast({
|
||||||
|
type: response.data.success ? "success" : "error",
|
||||||
|
title: response.data.success ? "Lexware-Verbindung erfolgreich" : "Lexware-Verbindung fehlgeschlagen",
|
||||||
|
description: response.data.message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Lexware-Verbindung fehlgeschlagen",
|
||||||
|
description: getErrorMessage(err, "Die Verbindung konnte nicht geprüft werden."),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingLexware(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +244,7 @@ export default function SettingsPage() {
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">SMTP-Versand und öffentliche Statuslinks verwalten</p>
|
<p className="mt-1 text-sm text-slate-500">SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
||||||
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
||||||
|
|
@ -180,6 +253,9 @@ export default function SettingsPage() {
|
||||||
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
||||||
Öffentliche Links
|
Öffentliche Links
|
||||||
</TabButton>
|
</TabButton>
|
||||||
|
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
|
||||||
|
Lexware Office
|
||||||
|
</TabButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -287,7 +363,7 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : activeTab === "public-links" ? (
|
||||||
<section className="rounded-lg border bg-white p-6">
|
<section className="rounded-lg border bg-white p-6">
|
||||||
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -319,6 +395,86 @@ export default function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="rounded-lg border bg-white p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ReceiptText className="h-5 w-5 text-slate-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Lexware Office</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Quelle: {sourceLabels[lexware.source]}
|
||||||
|
{lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => void testLexwareConnection()} disabled={testingLexware}>
|
||||||
|
<ShieldCheck />
|
||||||
|
{testingLexware ? "Prüft..." : "Verbindung testen"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void saveLexware()} disabled={savingLexware}>
|
||||||
|
<Save />
|
||||||
|
{savingLexware ? "Speichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300"
|
||||||
|
checked={lexware.enabled}
|
||||||
|
onChange={(event) => setLexware((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
Lexware-Integration aktivieren
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field label="Organisation">
|
||||||
|
<Input value={lexware.organization_name} onChange={(event) => setLexware((current) => ({ ...current, organization_name: event.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="API Base URL">
|
||||||
|
<Input value={lexware.api_base_url} onChange={(event) => setLexware((current) => ({ ...current, api_base_url: event.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={lexware.api_key_is_set ? "Neuen API-Key setzen" : "API-Key"}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newLexwareApiKey}
|
||||||
|
placeholder={lexware.api_key_is_set ? "Leer lassen, um aktuellen API-Key zu behalten" : ""}
|
||||||
|
onChange={(event) => setNewLexwareApiKey(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Standard MwSt. %">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={lexware.default_tax_rate}
|
||||||
|
onChange={(event) => setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Zahlungsziel Tage">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={lexware.default_payment_terms_days}
|
||||||
|
onChange={(event) => setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-start gap-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
||||||
|
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Olympus bereitet Werkstatt- und KV-Daten vor. Lexware Office bleibt führend für Buchhaltung,
|
||||||
|
Rechnungen, Steuer und DATEV/EÜR. Der API-Key wird nicht angezeigt und nicht an den Browser zurückgegeben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
|
HardDriveDownload,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -66,6 +67,7 @@ const menu = [
|
||||||
icon: Package,
|
icon: Package,
|
||||||
name: "Lager",
|
name: "Lager",
|
||||||
href: "/inventory",
|
href: "/inventory",
|
||||||
|
permission: "inventory.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
|
|
@ -77,6 +79,12 @@ const menu = [
|
||||||
name: "Dokumente",
|
name: "Dokumente",
|
||||||
href: "/documents",
|
href: "/documents",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: HardDriveDownload,
|
||||||
|
name: "Backups",
|
||||||
|
href: "/backups",
|
||||||
|
permission: "backup.read",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
name: "Einstellungen",
|
name: "Einstellungen",
|
||||||
|
|
@ -134,7 +142,7 @@ export default function Sidebar() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
||||||
Olympus CRM v0.1
|
Olympus CRM v0.9.1
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ type Props = {
|
||||||
description: string;
|
description: string;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
pendingLabel?: string;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
|
@ -29,6 +31,8 @@ export default function ConfirmDialog({
|
||||||
description,
|
description,
|
||||||
confirmLabel = "Löschen",
|
confirmLabel = "Löschen",
|
||||||
pending = false,
|
pending = false,
|
||||||
|
pendingLabel = "Wird gelöscht...",
|
||||||
|
confirmDisabled = false,
|
||||||
children,
|
children,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
|
@ -56,9 +60,9 @@ export default function ConfirmDialog({
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={pending}
|
disabled={pending || confirmDisabled}
|
||||||
>
|
>
|
||||||
{pending ? "Wird gelöscht..." : confirmLabel}
|
{pending ? pendingLabel : confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type {
|
||||||
|
InventoryCategory,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemPayload,
|
||||||
|
InventoryLocation,
|
||||||
|
InventorySupplier,
|
||||||
|
} from "@/types/inventory";
|
||||||
|
import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
item: InventoryItem | null;
|
||||||
|
categories: InventoryCategory[];
|
||||||
|
locations: InventoryLocation[];
|
||||||
|
suppliers: InventorySupplier[];
|
||||||
|
pending: boolean;
|
||||||
|
serverError: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: InventoryItemPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function payloadFromItem(item: InventoryItem | null): InventoryItemPayload {
|
||||||
|
if (!item) {
|
||||||
|
return emptyItemPayload;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sku: item.sku,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
category_id: item.category_id,
|
||||||
|
manufacturer: item.manufacturer,
|
||||||
|
manufacturer_part_number: item.manufacturer_part_number,
|
||||||
|
supplier_id: item.supplier_id,
|
||||||
|
supplier_part_number: item.supplier_part_number,
|
||||||
|
compatible_devices: item.compatible_devices,
|
||||||
|
location_id: item.location_id,
|
||||||
|
quantity_on_hand: item.quantity_on_hand,
|
||||||
|
quantity_reserved: item.quantity_reserved,
|
||||||
|
reorder_level: item.reorder_level,
|
||||||
|
unit: item.unit,
|
||||||
|
purchase_price_cents: item.purchase_price_cents,
|
||||||
|
selling_price_cents: item.selling_price_cents,
|
||||||
|
currency: item.currency,
|
||||||
|
tax_rate_percent: item.tax_rate_percent,
|
||||||
|
notes: item.notes,
|
||||||
|
is_active: item.is_active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InventoryItemDialog({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
categories,
|
||||||
|
locations,
|
||||||
|
suppliers,
|
||||||
|
pending,
|
||||||
|
serverError,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const initialPayload = payloadFromItem(item);
|
||||||
|
const [payload, setPayload] = useState<InventoryItemPayload>(initialPayload);
|
||||||
|
const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents));
|
||||||
|
const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents));
|
||||||
|
const [clientError, setClientError] = useState("");
|
||||||
|
|
||||||
|
function update<K extends keyof InventoryItemPayload>(key: K, value: InventoryItemPayload[K]) {
|
||||||
|
setPayload((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const purchaseCents = euroToCents(purchasePrice);
|
||||||
|
const sellingCents = euroToCents(sellingPrice);
|
||||||
|
if (!payload.name.trim()) {
|
||||||
|
setClientError("Bitte einen Artikelnamen angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.quantity_reserved > payload.quantity_on_hand) {
|
||||||
|
setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) {
|
||||||
|
setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientError("");
|
||||||
|
onSubmit({
|
||||||
|
...payload,
|
||||||
|
sku: payload.sku?.trim() || null,
|
||||||
|
name: payload.name.trim(),
|
||||||
|
purchase_price_cents: purchaseCents,
|
||||||
|
selling_price_cents: sellingCents,
|
||||||
|
description: payload.description?.trim() || null,
|
||||||
|
manufacturer: payload.manufacturer?.trim() || null,
|
||||||
|
manufacturer_part_number: payload.manufacturer_part_number?.trim() || null,
|
||||||
|
supplier_part_number: payload.supplier_part_number?.trim() || null,
|
||||||
|
compatible_devices: payload.compatible_devices?.trim() || null,
|
||||||
|
notes: payload.notes?.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{item ? "Artikel bearbeiten" : "Artikel anlegen"}</DialogTitle>
|
||||||
|
<DialogDescription>Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Stammdaten</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">SKU optional<Input value={payload.sku ?? ""} onChange={(event) => update("sku", event.target.value || null)} placeholder="ET-2026-000001" /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-2">Name<Input value={payload.name} onChange={(event) => update("name", event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-3">Beschreibung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Zuordnung</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">Kategorie<select className="h-8 rounded-lg border px-2 text-sm" value={payload.category_id ?? ""} onChange={(event) => update("category_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Keine Kategorie</option>
|
||||||
|
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lagerort<select className="h-8 rounded-lg border px-2 text-sm" value={payload.location_id ?? ""} onChange={(event) => update("location_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Kein Lagerort</option>
|
||||||
|
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lieferant<select className="h-8 rounded-lg border px-2 text-sm" value={payload.supplier_id ?? ""} onChange={(event) => update("supplier_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Kein Lieferant</option>
|
||||||
|
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Hersteller und Kompatibilität</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.manufacturer ?? ""} onChange={(event) => update("manufacturer", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Hersteller-Teilenummer<Input value={payload.manufacturer_part_number ?? ""} onChange={(event) => update("manufacturer_part_number", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lieferanten-Teilenummer<Input value={payload.supplier_part_number ?? ""} onChange={(event) => update("supplier_part_number", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-3">Kompatible Geräte<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.compatible_devices ?? ""} onChange={(event) => update("compatible_devices", event.target.value || null)} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Bestand und Preise</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<label className="grid gap-1 text-sm">Bestand<Input type="number" min={0} value={payload.quantity_on_hand} onChange={(event) => update("quantity_on_hand", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Reserviert<Input type="number" min={0} value={payload.quantity_reserved} onChange={(event) => update("quantity_reserved", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Mindestbestand<Input type="number" min={0} value={payload.reorder_level} onChange={(event) => update("reorder_level", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Einheit<Input value={payload.unit} onChange={(event) => update("unit", event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Einkaufspreis €<Input inputMode="decimal" value={purchasePrice} onChange={(event) => setPurchasePrice(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Verkaufspreis €<Input inputMode="decimal" value={sellingPrice} onChange={(event) => setSellingPrice(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Währung<Input value={payload.currency} onChange={(event) => update("currency", event.target.value.toUpperCase())} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">MwSt. %<Input inputMode="decimal" value={payload.tax_rate_percent} onChange={(event) => update("tax_rate_percent", event.target.value.replace(",", "."))} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Notizen</h3>
|
||||||
|
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} />
|
||||||
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.is_active} onChange={(event) => update("is_active", event.target.checked)} /> Artikel ist aktiv</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Edit, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { InventoryCategory, InventoryLocation, InventorySupplier } from "@/types/inventory";
|
||||||
|
import { getErrorMessage } from "./inventory-utils";
|
||||||
|
|
||||||
|
type MasterKind = "categories" | "locations" | "suppliers";
|
||||||
|
type MasterEntry = InventoryCategory | InventoryLocation | InventorySupplier;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
kind: MasterKind;
|
||||||
|
entries: MasterEntry[];
|
||||||
|
canManage: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasterDataSection({ title, kind, entries, canManage, onChanged }: Props) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [editing, setEditing] = useState<MasterEntry | null>(null);
|
||||||
|
const [deleteEntry, setDeleteEntry] = useState<MasterEntry | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
setEditing({ id: 0, name: "", description: null, created_at: "", updated_at: "" } as MasterEntry);
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(entry: MasterEntry) {
|
||||||
|
setEditing(entry);
|
||||||
|
setName(entry.name);
|
||||||
|
setDescription("description" in entry && entry.description ? entry.description : "");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Bitte einen Namen angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const payload = kind === "suppliers"
|
||||||
|
? { name: name.trim(), notes: description.trim() || null }
|
||||||
|
: { name: name.trim(), description: description.trim() || null };
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.put(`/inventory/${kind}/${editing.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post(`/inventory/${kind}`, payload);
|
||||||
|
}
|
||||||
|
showToast({ type: "success", title: `${title} gespeichert` });
|
||||||
|
setEditing(null);
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showToast({ type: "error", title: `${title} konnte nicht gespeichert werden`, description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/inventory/${kind}/${deleteEntry.id}`);
|
||||||
|
showToast({ type: "success", title: `${title} gelöscht`, description: deleteEntry.name });
|
||||||
|
setDeleteEntry(null);
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showToast({ type: "error", title: `${title} konnte nicht gelöscht werden`, description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<h2 className="font-semibold text-slate-950">{title}</h2>
|
||||||
|
{canManage && <Button type="button" size="sm" onClick={startCreate}><Plus size={14} />Neu</Button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-slate-500">Noch keine Einträge vorhanden.</p>
|
||||||
|
) : entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-950">{entry.name}</p>
|
||||||
|
{"description" in entry && entry.description && <p className="text-sm text-slate-500">{entry.description}</p>}
|
||||||
|
{"notes" in entry && entry.notes && <p className="text-sm text-slate-500">{entry.notes}</p>}
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => startEdit(entry)} title="Bearbeiten"><Edit size={16} /></Button>
|
||||||
|
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteEntry(entry)} title="Löschen"><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="mt-4 grid gap-3 rounded-lg border bg-slate-50 p-4">
|
||||||
|
<Input value={name} placeholder="Name" onChange={(event) => setName(event.target.value)} />
|
||||||
|
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={description} placeholder={kind === "suppliers" ? "Notizen" : "Beschreibung"} onChange={(event) => setDescription(event.target.value)} />
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={pending}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteEntry)}
|
||||||
|
title={`${title} löschen`}
|
||||||
|
description="Der Eintrag wird entfernt. Verknüpfte Artikel behalten ihre sonstigen Daten."
|
||||||
|
pending={pending}
|
||||||
|
onOpenChange={(open) => !open && setDeleteEntry(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{deleteEntry?.name}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { InventoryItem, InventoryStockPayload } from "@/types/inventory";
|
||||||
|
|
||||||
|
export type StockAction = "adjust" | "reserve" | "release" | "consume";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
item: InventoryItem | null;
|
||||||
|
action: StockAction;
|
||||||
|
pending: boolean;
|
||||||
|
serverError: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: InventoryStockPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<StockAction, { title: string; description: string; reason: string }> = {
|
||||||
|
adjust: {
|
||||||
|
title: "Bestand anpassen",
|
||||||
|
description: "Setzt den physischen Bestand auf den angegebenen Wert.",
|
||||||
|
reason: "Inventurkorrektur",
|
||||||
|
},
|
||||||
|
reserve: {
|
||||||
|
title: "Bestand reservieren",
|
||||||
|
description: "Reserviert verfügbaren Bestand für einen späteren Vorgang.",
|
||||||
|
reason: "Reservierung",
|
||||||
|
},
|
||||||
|
release: {
|
||||||
|
title: "Reservierung aufheben",
|
||||||
|
description: "Gibt reservierten Bestand wieder frei.",
|
||||||
|
reason: "Reservierung aufgehoben",
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
title: "Verbrauch buchen",
|
||||||
|
description: "Bucht Bestand als verbraucht aus.",
|
||||||
|
reason: "Verbrauch",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StockActionDialog({ open, item, action, pending, serverError, onOpenChange, onSubmit }: Props) {
|
||||||
|
const [quantity, setQuantity] = useState(action === "adjust" ? item?.quantity_on_hand ?? 0 : 1);
|
||||||
|
const [reason, setReason] = useState(labels[action].reason);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [clientError, setClientError] = useState("");
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (quantity <= 0 && action !== "adjust") {
|
||||||
|
setClientError("Bitte eine Menge größer 0 angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quantity < 0) {
|
||||||
|
setClientError("Bitte keine negative Menge eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setClientError("Bitte einen Grund angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientError("");
|
||||||
|
onSubmit({
|
||||||
|
quantity,
|
||||||
|
reason: reason.trim(),
|
||||||
|
note: note.trim() || null,
|
||||||
|
reference_type: null,
|
||||||
|
reference_id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{labels[action].title}</DialogTitle>
|
||||||
|
<DialogDescription>{item ? `${item.sku} · ${item.name}` : labels[action].description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-600">
|
||||||
|
Bestand {item?.quantity_on_hand ?? 0} · Reserviert {item?.quantity_reserved ?? 0} · Verfügbar {item?.quantity_available ?? 0}
|
||||||
|
</p>
|
||||||
|
<label className="grid gap-1 text-sm">Menge<Input type="number" min={action === "adjust" ? 0 : 1} value={quantity} onChange={(event) => setQuantity(Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Grund<Input value={reason} onChange={(event) => setReason(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Buchen"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { InventoryItemPayload } from "@/types/inventory";
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
return "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(cents: number | null, currency = "EUR") {
|
||||||
|
if (cents === null) {
|
||||||
|
return "Keine Angabe";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function euroToCents(value: string) {
|
||||||
|
const normalized = value.trim().replace(",", ".");
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const amount = Number(normalized);
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round(amount * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function centsToEuroInput(cents: number | null) {
|
||||||
|
if (cents === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return (cents / 100).toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyItemPayload: InventoryItemPayload = {
|
||||||
|
sku: null,
|
||||||
|
name: "",
|
||||||
|
description: null,
|
||||||
|
category_id: null,
|
||||||
|
manufacturer: null,
|
||||||
|
manufacturer_part_number: null,
|
||||||
|
supplier_id: null,
|
||||||
|
supplier_part_number: null,
|
||||||
|
compatible_devices: null,
|
||||||
|
location_id: null,
|
||||||
|
quantity_on_hand: 0,
|
||||||
|
quantity_reserved: 0,
|
||||||
|
reorder_level: 0,
|
||||||
|
unit: "Stk.",
|
||||||
|
purchase_price_cents: null,
|
||||||
|
selling_price_cents: null,
|
||||||
|
currency: "EUR",
|
||||||
|
tax_rate_percent: "19.00",
|
||||||
|
notes: null,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
ImageIcon,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { RepairDocument, RepairDocumentType, RepairDocumentVisibility } from "@/types/repair";
|
||||||
|
|
||||||
|
const documentTypeLabels: Record<RepairDocumentType, string> = {
|
||||||
|
device_photo: "Gerätefoto",
|
||||||
|
fault_photo: "Fehlerbild",
|
||||||
|
measurement: "Messbild",
|
||||||
|
estimate: "Kostenvoranschlag",
|
||||||
|
repair_report: "Reparaturbericht",
|
||||||
|
shipping: "Versandbeleg",
|
||||||
|
other: "Sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentTypes = Object.entries(documentTypeLabels) as Array<[RepairDocumentType, string]>;
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
return "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(document: RepairDocument) {
|
||||||
|
return document.mime_type.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(document: RepairDocument) {
|
||||||
|
return document.mime_type === "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(repairId: number, documentId: number, disposition: "inline" | "attachment" = "inline") {
|
||||||
|
return `/api/repairs/${repairId}/documents/${documentId}/download?disposition=${disposition}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
repairId: number;
|
||||||
|
canUpdate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RepairDocumentsSection({ repairId, canUpdate }: Props) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<RepairDocument[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [documentType, setDocumentType] = useState<RepairDocumentType>("device_photo");
|
||||||
|
const [visibility, setVisibility] = useState<RepairDocumentVisibility>("internal");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<RepairDocument | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [previewDocument, setPreviewDocument] = useState<RepairDocument | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
|
||||||
|
const loadDocuments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await api.get<RepairDocument[]>(`/repairs/${repairId}/documents`);
|
||||||
|
setDocuments(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [repairId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadDocuments();
|
||||||
|
});
|
||||||
|
}, [loadDocuments]);
|
||||||
|
|
||||||
|
const imageDocuments = useMemo(() => documents.filter(isImage), [documents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewDocument) return;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setPreviewDocument(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [previewDocument]);
|
||||||
|
|
||||||
|
function resetUploadForm() {
|
||||||
|
setTitle("");
|
||||||
|
setDocumentType("device_photo");
|
||||||
|
setVisibility("internal");
|
||||||
|
setNote("");
|
||||||
|
setSelectedFile(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDocument() {
|
||||||
|
if (!selectedFile) {
|
||||||
|
showToast({ type: "error", title: "Keine Datei ausgewählt" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile);
|
||||||
|
formData.append("title", title.trim() || selectedFile.name);
|
||||||
|
formData.append("document_type", documentType);
|
||||||
|
formData.append("visibility", visibility);
|
||||||
|
formData.append("note", note.trim());
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
try {
|
||||||
|
await api.post<RepairDocument>(`/repairs/${repairId}/documents/upload`, formData, {
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (event.total) {
|
||||||
|
setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loadDocuments();
|
||||||
|
resetUploadForm();
|
||||||
|
setUploadOpen(false);
|
||||||
|
showToast({ type: "success", title: "Dokument hochgeladen" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Upload fehlgeschlagen",
|
||||||
|
description: getErrorMessage(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDocument() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/repairs/${repairId}/documents/${deleteTarget.id}`);
|
||||||
|
setDocuments((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
showToast({ type: "success", title: "Dokument gelöscht" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Dokument konnte nicht gelöscht werden",
|
||||||
|
description: getErrorMessage(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDocument(document: RepairDocument) {
|
||||||
|
if (isImage(document)) {
|
||||||
|
setPreviewDocument(document);
|
||||||
|
setZoom(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(downloadUrl(repairId, document.id, "inline"), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Dokumente werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert
|
||||||
|
</p>
|
||||||
|
{canUpdate && (
|
||||||
|
<Button type="button" onClick={() => setUploadOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Dokument hochladen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-950">Noch keine Dokumente oder Bilder vorhanden.</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">
|
||||||
|
Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert.
|
||||||
|
</p>
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button type="button" onClick={() => setUploadOpen(true)}><Upload />Upload starten</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{documents.map((document) => (
|
||||||
|
<article key={document.id} className="overflow-hidden rounded-lg border bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex aspect-video w-full items-center justify-center bg-slate-100 text-slate-500"
|
||||||
|
onClick={() => openDocument(document)}
|
||||||
|
aria-label={`${document.title} öffnen`}
|
||||||
|
>
|
||||||
|
{isImage(document) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={downloadUrl(repairId, document.id)} alt={document.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="h-10 w-10" />
|
||||||
|
<span className="text-xs font-medium">{isPdf(document) ? "PDF" : document.mime_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{isImage(document) ? <ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" /> : <FileText className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-semibold text-slate-950">{document.title}</h3>
|
||||||
|
<p className="mt-1 truncate text-xs text-slate-500">{document.original_filename}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{document.note && <p className="mt-3 line-clamp-2 text-sm text-slate-600">{document.note}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<Meta label="Typ" value={documentTypeLabels[document.document_type]} />
|
||||||
|
<Meta label="Größe" value={formatSize(document.size_bytes)} />
|
||||||
|
<Meta label="MIME" value={document.mime_type} />
|
||||||
|
<Meta label="Sichtbarkeit" value={document.visibility === "customer" ? "Kunde" : "Intern"} />
|
||||||
|
<Meta label="Upload" value={formatDate(document.created_at)} />
|
||||||
|
<Meta label="Von" value={document.uploaded_by_display_name || document.uploaded_by_username || "System"} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => openDocument(document)}>
|
||||||
|
<Eye />
|
||||||
|
Vorschau
|
||||||
|
</Button>
|
||||||
|
<a href={downloadUrl(repairId, document.id, "attachment")} className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||||
|
<Download />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{canUpdate && (
|
||||||
|
<Button type="button" size="sm" variant="destructive" onClick={() => setDeleteTarget(document)}>
|
||||||
|
<Trash2 />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={uploadOpen} onOpenChange={(open) => {
|
||||||
|
setUploadOpen(open);
|
||||||
|
if (!open && !uploading) resetUploadForm();
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Dokument hochladen</DialogTitle>
|
||||||
|
<DialogDescription>JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Field label="Datei">
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
setSelectedFile(file);
|
||||||
|
if (file && !title) setTitle(file.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Titel">
|
||||||
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Dokumenttyp">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={documentType}
|
||||||
|
onChange={(event) => setDocumentType(event.target.value as RepairDocumentType)}
|
||||||
|
>
|
||||||
|
{documentTypes.map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sichtbarkeit">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(event) => setVisibility(event.target.value as RepairDocumentVisibility)}
|
||||||
|
>
|
||||||
|
<option value="internal">Intern</option>
|
||||||
|
<option value="customer">Kunde vorbereitet</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Notiz">
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{uploading && (
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3">
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div className="h-full bg-slate-900 transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{uploadProgress}% hochgeladen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setUploadOpen(false)} disabled={uploading}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void uploadDocument()} disabled={uploading || !selectedFile}>
|
||||||
|
<Upload />
|
||||||
|
{uploading ? "Lädt hoch..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="Dokument löschen?"
|
||||||
|
description="Die Datei wird aus dem Storage entfernt und kann nicht wiederhergestellt werden."
|
||||||
|
pending={deleting}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deleting) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void deleteDocument()}
|
||||||
|
>
|
||||||
|
{deleteTarget && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{deleteTarget.title}
|
||||||
|
<br />
|
||||||
|
<span className="text-slate-500">{deleteTarget.original_filename}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
{previewDocument && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="absolute right-4 top-4 flex gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.max(0.5, current - 0.25))} aria-label="Verkleinern">
|
||||||
|
<ZoomOut />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.min(3, current + 0.25))} aria-label="Vergrößern">
|
||||||
|
<ZoomIn />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen">
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="absolute inset-0 cursor-default" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen" />
|
||||||
|
<div className="relative max-h-[90vh] max-w-[90vw] overflow-auto">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={downloadUrl(repairId, previewDocument.id)}
|
||||||
|
alt={previewDocument.title}
|
||||||
|
className="relative block max-h-[85vh] max-w-[85vw] object-contain transition-transform"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="absolute bottom-4 left-1/2 max-w-[80vw] -translate-x-1/2 truncate rounded bg-black/60 px-3 py-2 text-sm text-white">
|
||||||
|
{previewDocument.title} · {imageDocuments.findIndex((item) => item.id === previewDocument.id) + 1}/{imageDocuments.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium uppercase tracking-wide text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 break-words text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue