Compare commits
No commits in common. "main" and "v0.8.1" have entirely different histories.
129 changed files with 117 additions and 11858 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
22
.env.example
22
.env.example
|
|
@ -20,7 +20,6 @@ STORAGE_PROVIDER=local
|
|||
STORAGE_BASE_PATH=/data/storage
|
||||
STORAGE_MAX_UPLOAD_MB=50
|
||||
STORAGE_HOST_PATH=./storage
|
||||
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
|
||||
|
||||
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
|
|
@ -29,24 +28,3 @@ KNOWLEDGE_MAX_UPLOAD_MB=50
|
|||
# Server-zu-Server Token fuer spaetere Website-Reparaturannahme.
|
||||
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
|
||||
# Env-Fallback fuer oeffentliche Reparaturstatuslinks.
|
||||
# Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
# Beispiel: https://test.funktechnik-schubert.de/status
|
||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
||||
|
||||
# Env-Fallback fuer SMTP. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
# Fuer Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
|
||||
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=Funktechnik Schubert
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
LEXWARE_ENABLED=false
|
||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
||||
LEXWARE_API_KEY=
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -39,13 +39,6 @@ out/
|
|||
# ===========================
|
||||
*.log
|
||||
|
||||
# ===========================
|
||||
# Runtime storage
|
||||
# ===========================
|
||||
storage/
|
||||
/backups/
|
||||
*.dump
|
||||
|
||||
# ===========================
|
||||
# Coverage
|
||||
# ===========================
|
||||
|
|
|
|||
304
ARCHITECTURE.md
304
ARCHITECTURE.md
|
|
@ -166,58 +166,6 @@ Hermes
|
|||
|
||||
Athena ist die einzige API-Oberflaeche fuer den Browser.
|
||||
|
||||
## Lexware Office Integration
|
||||
|
||||
Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation.
|
||||
|
||||
Verantwortlichkeiten:
|
||||
|
||||
- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege.
|
||||
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
|
||||
- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt.
|
||||
|
||||
Komponenten:
|
||||
|
||||
- Hermes speichert Lexware-Konfiguration in `system_settings`.
|
||||
- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`.
|
||||
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
|
||||
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
|
||||
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
|
||||
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
```text
|
||||
Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office
|
||||
Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL
|
||||
```
|
||||
|
||||
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
|
||||
|
||||
Buchhaltungsworkflow:
|
||||
|
||||
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
|
||||
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
|
||||
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
|
||||
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
|
||||
|
||||
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
|
||||
|
||||
Neue zentrale Endpunkte:
|
||||
|
||||
- `GET /lexware/settings`
|
||||
- `PUT /lexware/settings`
|
||||
- `POST /lexware/test-connection`
|
||||
- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice`
|
||||
|
||||
RBAC:
|
||||
|
||||
- `lexware.read`
|
||||
- `lexware.manage`
|
||||
- `lexware.export`
|
||||
|
||||
Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
|
||||
|
|
@ -271,72 +219,6 @@ Tabellen:
|
|||
- `repair_public_access_tokens`
|
||||
- `repair_notification_events`
|
||||
|
||||
## Lager / Ersatzteile
|
||||
|
||||
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
```text
|
||||
Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL
|
||||
```
|
||||
|
||||
Kernregeln:
|
||||
|
||||
- Browser greifen nie direkt auf Hermes zu.
|
||||
- Artikelnummern/SKUs sind eindeutig.
|
||||
- Leere SKU bei Erstellung wird automatisch im Format `ET-<jahr>-000001` vergeben.
|
||||
- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben.
|
||||
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
|
||||
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
|
||||
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
|
||||
- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft.
|
||||
- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird.
|
||||
- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert.
|
||||
|
||||
Tabellen:
|
||||
|
||||
- `inventory_items`
|
||||
- `inventory_categories`
|
||||
- `inventory_locations`
|
||||
- `inventory_suppliers`
|
||||
- `inventory_stock_movements`
|
||||
|
||||
Bestandsbewegungen:
|
||||
|
||||
- `initial`
|
||||
- `purchase`
|
||||
- `adjustment`
|
||||
- `reservation`
|
||||
- `release`
|
||||
- `consumption`
|
||||
- `return`
|
||||
- `correction`
|
||||
|
||||
RBAC-Permissions:
|
||||
|
||||
- `inventory.read`
|
||||
- `inventory.create`
|
||||
- `inventory.update`
|
||||
- `inventory.delete`
|
||||
- `inventory.stock.adjust`
|
||||
- `inventory.stock.reserve`
|
||||
- `inventory.stock.consume`
|
||||
- `inventory.manage.masterdata`
|
||||
|
||||
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
|
||||
|
||||
Reservierungslogik:
|
||||
|
||||
- Entwurf: keine Reservierung.
|
||||
- Senden: verknuepfte Lagerartikel werden reserviert.
|
||||
- Freigabe: Reservierung bleibt bestehen.
|
||||
- Ablehnung: Reservierung wird freigegeben.
|
||||
- Storno: Reservierung wird freigegeben.
|
||||
- Loeschung eines gesendeten KV: Reservierung wird freigegeben.
|
||||
|
||||
Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
|
||||
|
||||
Hermes-Endpunkte:
|
||||
|
||||
- `GET /repairs`
|
||||
|
|
@ -389,7 +271,7 @@ Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die
|
|||
|
||||
### Reparatur-Benachrichtigungen
|
||||
|
||||
`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert.
|
||||
`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen.
|
||||
|
||||
Vorbereitete Ereignisse:
|
||||
|
||||
|
|
@ -406,158 +288,6 @@ Vorbereitete Ereignisse:
|
|||
|
||||
Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`.
|
||||
|
||||
Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt.
|
||||
|
||||
Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant.
|
||||
|
||||
Hermes-Endpunkt fuer manuellen Neuversand:
|
||||
|
||||
- `POST /repairs/{id}/send-status-mail`
|
||||
|
||||
Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf.
|
||||
|
||||
### System Settings
|
||||
|
||||
`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten:
|
||||
|
||||
- `smtp.host`
|
||||
- `smtp.port`
|
||||
- `smtp.username`
|
||||
- `smtp.password`
|
||||
- `smtp.from_email`
|
||||
- `smtp.from_name`
|
||||
- `smtp.use_tls`
|
||||
- `smtp.enabled`
|
||||
- `public.repair_status_base_url`
|
||||
|
||||
Hermes-Endpunkte:
|
||||
|
||||
- `GET /system-settings/smtp`
|
||||
- `PUT /system-settings/smtp`
|
||||
- `POST /system-settings/smtp/test`
|
||||
- `GET /system-settings/public-links`
|
||||
- `PUT /system-settings/public-links`
|
||||
|
||||
Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist.
|
||||
|
||||
### Reparaturdokumente
|
||||
|
||||
Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework.
|
||||
|
||||
Datenmodell `repair_documents`:
|
||||
|
||||
- `repair_id`
|
||||
- `title`
|
||||
- `document_type`
|
||||
- `original_filename`
|
||||
- `stored_filename`
|
||||
- `storage_path`
|
||||
- `mime_type`
|
||||
- `size_bytes`
|
||||
- `checksum_sha256`
|
||||
- `visibility`
|
||||
- `note`
|
||||
- `uploaded_by_user_id`
|
||||
- Zeitstempel
|
||||
|
||||
Dokumenttypen:
|
||||
|
||||
- `device_photo`
|
||||
- `fault_photo`
|
||||
- `measurement`
|
||||
- `estimate`
|
||||
- `repair_report`
|
||||
- `shipping`
|
||||
- `other`
|
||||
|
||||
`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert.
|
||||
|
||||
Storage:
|
||||
|
||||
- Namespace: `repairs/<repair_id>/documents`
|
||||
- Erlaubte Uploads: JPG, PNG, WEBP und PDF
|
||||
- Dateinamen werden normalisiert
|
||||
- SHA-256 wird gespeichert
|
||||
- Dateiinhalte werden nicht geloggt
|
||||
- Storage-Pfade werden nicht in Audit-Metadaten geschrieben
|
||||
- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC
|
||||
|
||||
Hermes-Endpunkte:
|
||||
|
||||
- `GET /repairs/{id}/documents`
|
||||
- `POST /repairs/{id}/documents/upload`
|
||||
- `GET /repairs/{id}/documents/{document_id}`
|
||||
- `PUT /repairs/{id}/documents/{document_id}`
|
||||
- `GET /repairs/{id}/documents/{document_id}/download`
|
||||
- `DELETE /repairs/{id}/documents/{document_id}`
|
||||
|
||||
RBAC:
|
||||
|
||||
- Liste, Detail und Download erfordern `repairs.read`.
|
||||
- Upload, Aenderung und Loeschung erfordern `repairs.update`.
|
||||
|
||||
Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz.
|
||||
|
||||
### Kostenvoranschläge
|
||||
|
||||
Ab v0.8.6 verwaltet Olympus Kostenvoranschlaege direkt im Repair-Modul. Es gibt keinen Kundenlogin und keine neue Website-App. Kundenentscheidungen laufen ueber den bestehenden sicheren Public-Statuslink.
|
||||
|
||||
Datenmodell:
|
||||
|
||||
- `repair_estimates`
|
||||
- `repair_estimate_items`
|
||||
- `repair_estimate_events`
|
||||
|
||||
Kostenvoranschlagsnummern folgen `KV<jahr>-000001`. Positionstypen sind `labor`, `part`, `flat_rate`, `shipping` und `other`. Statuswerte sind `draft`, `sent`, `approved`, `declined`, `expired` und `cancelled`.
|
||||
|
||||
Hermes berechnet alle Summen serverseitig:
|
||||
|
||||
- `subtotal_cents` aus Positionen
|
||||
- `tax_cents` aus `tax_rate_percent`
|
||||
- `total_cents` als Summe aus netto und Steuer
|
||||
|
||||
Interne Hermes-Endpunkte:
|
||||
|
||||
- `GET /repairs/{repair_id}/estimates`
|
||||
- `POST /repairs/{repair_id}/estimates`
|
||||
- `GET /repairs/{repair_id}/estimates/{estimate_id}`
|
||||
- `PUT /repairs/{repair_id}/estimates/{estimate_id}`
|
||||
- `DELETE /repairs/{repair_id}/estimates/{estimate_id}`
|
||||
- `POST /repairs/{repair_id}/estimates/{estimate_id}/send`
|
||||
- `POST /repairs/{repair_id}/estimates/{estimate_id}/cancel`
|
||||
- `GET /repairs/{repair_id}/estimates/{estimate_id}/events`
|
||||
|
||||
RBAC:
|
||||
|
||||
- `repair_estimates.read`
|
||||
- `repair_estimates.create`
|
||||
- `repair_estimates.update`
|
||||
- `repair_estimates.delete`
|
||||
- `repair_estimates.send`
|
||||
|
||||
Administrator bekommt alle Rechte. Management und Technik bekommen Lesen/Erstellen/Bearbeiten/Senden. Support bekommt Lesen/Senden.
|
||||
|
||||
Public API:
|
||||
|
||||
- `GET /public/repairs/status/{token}` liefert bei aktivem gesendetem KV ein Feld `estimate`.
|
||||
- `POST /public/repairs/status/{token}/estimate/approve`
|
||||
- `POST /public/repairs/status/{token}/estimate/decline`
|
||||
- `POST /public/repairs/status/{token}/estimate/question`
|
||||
|
||||
Die Public-Antwort enthaelt keine internen Notizen, keine internen Events und keine Tokens. Bei Freigabe setzt Olympus den KV auf `approved` und den Reparaturstatus auf `approved`. Bei Ablehnung oder Rueckfrage bleibt der Prozess bei `waiting_for_customer`.
|
||||
|
||||
Der KV-Versand nutzt die bestehende SMTP-Konfiguration aus `system_settings` mit Env-Fallback. Fehler beim Mailversand zerstoeren den KV nicht; der Versandversuch wird als Notification Event dokumentiert.
|
||||
|
||||
Nicht enthalten in v0.8.6:
|
||||
|
||||
- PDF-Erzeugung
|
||||
- Lexoffice
|
||||
- Rechnungserstellung
|
||||
- Kundenlogin
|
||||
- Anzeige/Freigabe in der oeffentlichen Website
|
||||
|
||||
Die Website kann spaeter, z. B. in `funktechnik-schubert-website` v0.4.3, die bereits vorhandenen Public-API-Daten anzeigen und die Entscheidungsendpunkte aufrufen.
|
||||
|
||||
### Website Repair Intake
|
||||
|
||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
||||
|
|
@ -977,8 +707,6 @@ 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.
|
||||
|
||||
Reparaturdokumente werden ab v0.8.5 im Namespace `repairs/<repair_id>/documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature.
|
||||
|
||||
Konfiguration:
|
||||
|
||||
- `STORAGE_PROVIDER`, Default `local`
|
||||
|
|
@ -1060,29 +788,9 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
|
|||
|
||||
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
|
||||
|
||||
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
|
||||
`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
|
||||
|
||||
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
|
||||
|
||||
### Backup-Modul ab v0.9.1
|
||||
|
||||
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
|
||||
|
||||
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
|
||||
- Hermes-API: `backend/hermes/app/api/backups.py`
|
||||
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
|
||||
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
|
||||
- Athena-BFF: `/api/backups/...`
|
||||
|
||||
Sicherheitsregeln:
|
||||
|
||||
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
|
||||
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
|
||||
- Hermes anonymisiert den Datenbank-Host im Manifest.
|
||||
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
|
||||
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
|
||||
|
||||
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
|
||||
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
|
||||
|
||||
### Knowledge-RBAC
|
||||
|
||||
|
|
@ -1185,12 +893,6 @@ docker-compose.yml
|
|||
`OLYMPUS_REPAIR_INTAKE_TOKEN`
|
||||
: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen.
|
||||
|
||||
`PUBLIC_REPAIR_STATUS_BASE_URL`
|
||||
: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, gibt Hermes weiterhin relative Links im Format `/status/<token>` zurueck.
|
||||
|
||||
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`
|
||||
: Env-Fallback fuer automatische Reparaturstatusmails. Bevorzugt wird die Admin-Konfiguration unter `/settings`. `SMTP_PASSWORD` darf nicht geloggt, committed oder an Athena zurueckgegeben werden. Fuer Apple Mail/iCloud: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername vollstaendige Mailadresse, Passwort app-spezifisches Passwort.
|
||||
|
||||
### Athena
|
||||
|
||||
`HERMES_INTERNAL_URL`
|
||||
|
|
|
|||
182
README-DEV.md
182
README-DEV.md
|
|
@ -34,23 +34,8 @@ STORAGE_BASE_PATH=/data/storage
|
|||
STORAGE_MAX_UPLOAD_MB=50
|
||||
STORAGE_HOST_PATH=./storage
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=Funktechnik Schubert
|
||||
SMTP_USE_TLS=true
|
||||
LEXWARE_ENABLED=false
|
||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
||||
LEXWARE_API_KEY=
|
||||
```
|
||||
|
||||
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||
|
||||
`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
|
||||
|
||||
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
||||
|
||||
## Docker Netzwerk
|
||||
|
|
@ -134,46 +119,6 @@ STORAGE_BASE_PATH=/data/storage
|
|||
|
||||
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
|
||||
|
||||
## Backup und Restore
|
||||
|
||||
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
|
||||
|
||||
Ablage:
|
||||
|
||||
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
|
||||
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
|
||||
- Backup-Dateien gehoeren nie ins Git
|
||||
|
||||
Inhalt eines Backups:
|
||||
|
||||
- `manifest.json`
|
||||
- `database.dump`
|
||||
- `storage/`
|
||||
|
||||
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
|
||||
|
||||
- `GET /api/backups`
|
||||
- `POST /api/backups/create`
|
||||
- `GET /api/backups/[filename]/download`
|
||||
- `POST /api/backups/[filename]/validate`
|
||||
- `POST /api/backups/[filename]/restore`
|
||||
- `DELETE /api/backups/[filename]/delete`
|
||||
|
||||
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
|
||||
|
||||
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
|
||||
|
||||
1. Backup validieren.
|
||||
2. Sicherheitsbestaetigung pruefen.
|
||||
3. Restore ueber `scripts/restore.sh <backup-zip>` ausfuehren.
|
||||
4. Ergebnis und Audit Logs kontrollieren.
|
||||
|
||||
Empfehlung fuer den Betrieb:
|
||||
|
||||
- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
|
||||
- Backup-Dateien vor Offsite-Kopie verschluesseln.
|
||||
- Restore nur in Wartungsfenstern ausfuehren.
|
||||
|
||||
## Knowledge Workflow
|
||||
|
||||
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
||||
|
|
@ -199,15 +144,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
|
|||
|
||||
- `/api/repairs`
|
||||
- `/api/repairs/[id]`
|
||||
- `/api/repairs/[id]/documents`
|
||||
- `/api/repairs/[id]/documents/upload`
|
||||
- `/api/repairs/[id]/documents/[documentId]`
|
||||
- `/api/repairs/[id]/documents/[documentId]/download`
|
||||
- `/api/repairs/[id]/status`
|
||||
- `/api/repairs/[id]/history`
|
||||
- `/api/repairs/[id]/public-link`
|
||||
- `/api/repairs/[id]/notifications`
|
||||
- `/api/repairs/[id]/send-status-mail`
|
||||
|
||||
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
|
||||
|
||||
|
|
@ -219,132 +159,14 @@ Statuslink-Konzept:
|
|||
- RBAC-Permission: `repairs.public_link.manage`.
|
||||
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
|
||||
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
|
||||
- `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`.
|
||||
- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback.
|
||||
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
||||
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
||||
|
||||
## Lexware Office Foundation
|
||||
|
||||
Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet.
|
||||
|
||||
Rollenverteilung:
|
||||
|
||||
- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten.
|
||||
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
|
||||
|
||||
Konfiguration:
|
||||
|
||||
- Adminbereich: `/settings -> Lexware Office`
|
||||
- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection`
|
||||
- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection`
|
||||
- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
|
||||
|
||||
Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf.
|
||||
|
||||
Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden:
|
||||
|
||||
```text
|
||||
POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
|
||||
```
|
||||
|
||||
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
|
||||
|
||||
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
|
||||
|
||||
Exportstatus:
|
||||
|
||||
- `prepared`
|
||||
- `transferred`
|
||||
- `booked`
|
||||
- `cancelled`
|
||||
|
||||
Benachrichtigungen:
|
||||
|
||||
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
||||
- `repair_notification_events` dokumentiert jeden Versandversuch.
|
||||
- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet.
|
||||
- Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert.
|
||||
- SMTP wird bevorzugt ueber `/settings` konfiguriert. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` bleiben als Env-Fallback erhalten.
|
||||
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
|
||||
- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert.
|
||||
|
||||
Reparaturdokumente:
|
||||
|
||||
- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden.
|
||||
- Hermes speichert Uploads ueber `StorageService` unter `repairs/<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.
|
||||
- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor.
|
||||
- Aktuell wird kein SMTP-Versand aus Olympus erzwungen.
|
||||
|
||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||
|
||||
|
|
|
|||
118
ROADMAP.md
118
ROADMAP.md
|
|
@ -102,123 +102,13 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort
|
||||
- Optionaler E-Mail-Versand mit sicherem Statuslink
|
||||
|
||||
## v0.8.3 - Automatische Reparaturstatusmails
|
||||
|
||||
- Automatischer Statusmail-Versand bei Reparaturstatuswechseln
|
||||
- Frische sichere Public-Statuslinks fuer Statusmails
|
||||
- Professionelles HTML-Mailtemplate fuer Funktechnik Schubert
|
||||
- Notification-Historie mit Erfolg, Fehler und Empfaenger
|
||||
- Manueller Neuversand ueber `POST /repairs/{id}/send-status-mail`
|
||||
- Athena-BFF-Route und Button "E-Mail erneut senden"
|
||||
- Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail
|
||||
- SMTP-Ausfall blockiert keine Reparaturprozesse
|
||||
|
||||
## v0.8.4 - Admin SMTP Settings
|
||||
|
||||
- SMTP-Konfiguration im Olympus-Webinterface unter `/settings`
|
||||
- Testmail-Versand mit Audit-Log
|
||||
- `system_settings` als zentrale Tabelle fuer Systemkonfiguration
|
||||
- Env Vars bleiben als Fallback fuer SMTP und Public Repair Status Base URL
|
||||
- Statusmail-Versand nutzt bevorzugt Admin-Konfiguration
|
||||
- Passwort wird nicht an Athena zurueckgegeben
|
||||
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
|
||||
|
||||
## v0.8.5 - Reparaturdokumente
|
||||
|
||||
- Dokumente und Bilder direkt an Reparaturen verwalten
|
||||
- Upload ueber Athena-BFF und Hermes StorageService
|
||||
- Storage-Pfad `repairs/<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
|
||||
## v0.8.3 - Kundenportal, geplant
|
||||
|
||||
- `/portal/login` fuer spaeteren Kundenlogin
|
||||
- Separates Authentifizierungsmodell fuer Kunden
|
||||
- Keine Vermischung mit internen Olympus-Benutzern
|
||||
|
||||
## v0.9.3 - Tickets, geplant
|
||||
## v0.9.0 - Tickets, geplant
|
||||
|
||||
- Ticketverwaltung
|
||||
- Status- und Prioritaetsmodell
|
||||
|
|
@ -226,10 +116,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- RBAC-Permissions fuer Tickets
|
||||
- Audit Logs fuer Ticketaktionen
|
||||
|
||||
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
|
||||
## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
|
||||
|
||||
- Paperless-ngx Connector fuer Wissensdokumente
|
||||
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
|
||||
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
|
||||
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
||||
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
||||
|
||||
|
|
|
|||
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
|
|
@ -19,20 +19,3 @@ STORAGE_MAX_UPLOAD_MB=50
|
|||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
|
||||
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=Funktechnik Schubert
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
LEXWARE_ENABLED=false
|
||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
||||
LEXWARE_API_KEY=
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@ import app.models.knowledge
|
|||
import app.models.audit
|
||||
import app.models.rbac
|
||||
import app.models.repair
|
||||
import app.models.repair_estimate
|
||||
import app.models.inventory
|
||||
import app.models.system_setting
|
||||
|
||||
config = context.config
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,58 +0,0 @@
|
|||
"""extend repair documents
|
||||
|
||||
Revision ID: a1c5f9e2d430
|
||||
Revises: f2b8d4e6a910
|
||||
Create Date: 2026-07-05 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "a1c5f9e2d430"
|
||||
down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False))
|
||||
op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False))
|
||||
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False)
|
||||
op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False))
|
||||
op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False))
|
||||
op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False))
|
||||
op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False))
|
||||
op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True))
|
||||
op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True))
|
||||
op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
|
||||
op.create_foreign_key(
|
||||
op.f("fk_repair_documents_uploaded_by_user_id_users"),
|
||||
"repair_documents",
|
||||
"users",
|
||||
["uploaded_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False)
|
||||
op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents")
|
||||
op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents")
|
||||
op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents")
|
||||
op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey")
|
||||
op.drop_column("repair_documents", "updated_at")
|
||||
op.drop_column("repair_documents", "uploaded_by_user_id")
|
||||
op.drop_column("repair_documents", "note")
|
||||
op.drop_column("repair_documents", "visibility")
|
||||
op.drop_column("repair_documents", "checksum_sha256")
|
||||
op.drop_column("repair_documents", "size_bytes")
|
||||
op.drop_column("repair_documents", "mime_type")
|
||||
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None)
|
||||
op.drop_column("repair_documents", "stored_filename")
|
||||
op.drop_column("repair_documents", "original_filename")
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"""add repair estimate revoke permission
|
||||
|
||||
Revision ID: a7c3e9d4b821
|
||||
Revises: f4a9c2d7e118
|
||||
Create Date: 2026-07-05 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "a7c3e9d4b821"
|
||||
down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PERMISSION_NAME = "repair_estimates.revoke"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (
|
||||
:name,
|
||||
'Kostenvoranschläge zurücknehmen',
|
||||
'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
|
||||
'repair_estimates'
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=PERMISSION_NAME)
|
||||
)
|
||||
for role_name in ("administrator", "management"):
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=PERMISSION_NAME)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE name = :permission_name
|
||||
)
|
||||
"""
|
||||
).bindparams(permission_name=PERMISSION_NAME)
|
||||
)
|
||||
op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
"""create repair estimates
|
||||
|
||||
Revision ID: b4e8f1c7a205
|
||||
Revises: a1c5f9e2d430
|
||||
Create Date: 2026-07-05 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "b4e8f1c7a205"
|
||||
down_revision: Union[str, Sequence[str], None] = "a1c5f9e2d430"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
ESTIMATE_PERMISSIONS = [
|
||||
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
|
||||
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
|
||||
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
|
||||
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
|
||||
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repair_estimates",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("estimate_number", sa.String(length=20), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("customer_message", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("internal_note", sa.Text(), nullable=True),
|
||||
sa.Column("subtotal_cents", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
|
||||
sa.Column("tax_cents", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||
sa.Column("valid_until", sa.Date(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("customer_response_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("estimate_number"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_estimates_created_by_user_id"), "repair_estimates", ["created_by_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimates_estimate_number"), "repair_estimates", ["estimate_number"], unique=True)
|
||||
op.create_index(op.f("ix_repair_estimates_repair_id"), "repair_estimates", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimates_status"), "repair_estimates", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"repair_estimate_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("estimate_id", sa.Integer(), nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("item_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(10, 2), server_default="1.00", nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
|
||||
sa.Column("unit_price_cents", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_estimate_items_estimate_id"), "repair_estimate_items", ["estimate_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimate_items_item_type"), "repair_estimate_items", ["item_type"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"repair_estimate_events",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("estimate_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_estimate_events_actor_type"), "repair_estimate_events", ["actor_type"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimate_events_actor_user_id"), "repair_estimate_events", ["actor_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimate_events_estimate_id"), "repair_estimate_events", ["estimate_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_estimate_events_event_type"), "repair_estimate_events", ["event_type"], unique=False)
|
||||
|
||||
for name, display_name, description, module in ESTIMATE_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (:name, :display_name, :description, :module)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
"administrator": [item[0] for item in ESTIMATE_PERMISSIONS],
|
||||
"management": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
|
||||
"technician": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"],
|
||||
"support": ["repair_estimates.read", "repair_estimates.send"],
|
||||
}
|
||||
for role_name, permission_names in role_permissions.items():
|
||||
for permission_name in permission_names:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'repair_estimates'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'repair_estimates'")
|
||||
op.drop_index(op.f("ix_repair_estimate_events_event_type"), table_name="repair_estimate_events")
|
||||
op.drop_index(op.f("ix_repair_estimate_events_estimate_id"), table_name="repair_estimate_events")
|
||||
op.drop_index(op.f("ix_repair_estimate_events_actor_user_id"), table_name="repair_estimate_events")
|
||||
op.drop_index(op.f("ix_repair_estimate_events_actor_type"), table_name="repair_estimate_events")
|
||||
op.drop_table("repair_estimate_events")
|
||||
op.drop_index(op.f("ix_repair_estimate_items_item_type"), table_name="repair_estimate_items")
|
||||
op.drop_index(op.f("ix_repair_estimate_items_estimate_id"), table_name="repair_estimate_items")
|
||||
op.drop_table("repair_estimate_items")
|
||||
op.drop_index(op.f("ix_repair_estimates_status"), table_name="repair_estimates")
|
||||
op.drop_index(op.f("ix_repair_estimates_repair_id"), table_name="repair_estimates")
|
||||
op.drop_index(op.f("ix_repair_estimates_estimate_number"), table_name="repair_estimates")
|
||||
op.drop_index(op.f("ix_repair_estimates_created_by_user_id"), table_name="repair_estimates")
|
||||
op.drop_table("repair_estimates")
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""add lexware foundation
|
||||
|
||||
Revision ID: c9d4e5f6a7b8
|
||||
Revises: a7c3e9d4b821
|
||||
Create Date: 2026-07-05 16:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c9d4e5f6a7b8"
|
||||
down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEXWARE_PERMISSIONS = [
|
||||
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
||||
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
||||
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lexware_sync_records",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("entity_id", sa.Integer(), nullable=False),
|
||||
sa.Column("lexware_resource_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("lexware_resource_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("status", sa.String(length=40), server_default="pending", nullable=False),
|
||||
sa.Column("direction", sa.String(length=40), server_default="push", nullable=False),
|
||||
sa.Column("payload_summary", sa.Text(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
for name, display_name, description, module in LEXWARE_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (:name, :display_name, :description, :module)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
"administrator": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||
"management": [item[0] for item in LEXWARE_PERMISSIONS],
|
||||
"support": ["lexware.read"],
|
||||
}
|
||||
for role_name, permission_names in role_permissions.items():
|
||||
for permission_name in permission_names:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'lexware'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'lexware'")
|
||||
op.drop_column("repair_estimates", "lexware_synced_at")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_status")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_number")
|
||||
op.drop_column("repair_estimates", "lexware_invoice_id")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records")
|
||||
op.drop_table("lexware_sync_records")
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
"""add accounting export status
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c9d4e5f6a7b8
|
||||
Create Date: 2026-07-05 17:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d2e3f4a5b6c7"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
|
||||
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimates_accounting_transferred_by_user_id",
|
||||
"repair_estimates",
|
||||
"users",
|
||||
["accounting_transferred_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_at")
|
||||
op.drop_column("repair_estimates", "accounting_note")
|
||||
op.drop_column("repair_estimates", "accounting_export_status")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
|
||||
op.drop_column("lexware_sync_records", "transferred_by_user_id")
|
||||
op.drop_column("lexware_sync_records", "transferred_at")
|
||||
op.drop_column("lexware_sync_records", "accounting_note")
|
||||
op.drop_column("lexware_sync_records", "export_status")
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"""extend repair notifications
|
||||
|
||||
Revision ID: e9a7c3d5b812
|
||||
Revises: d4f6a2b8c901
|
||||
Create Date: 2026-07-04 13:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "e9a7c3d5b812"
|
||||
down_revision: Union[str, Sequence[str], None] = "d4f6a2b8c901"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repair_notification_events", sa.Column("template", sa.Text(), server_default="", nullable=False))
|
||||
op.add_column("repair_notification_events", sa.Column("success", sa.Boolean(), server_default="false", nullable=False))
|
||||
op.create_index(op.f("ix_repair_notification_events_success"), "repair_notification_events", ["success"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_repair_notification_events_success"), table_name="repair_notification_events")
|
||||
op.drop_column("repair_notification_events", "success")
|
||||
op.drop_column("repair_notification_events", "template")
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
"""create inventory
|
||||
|
||||
Revision ID: ef8a2d5c9017
|
||||
Revises: b4e8f1c7a205
|
||||
Create Date: 2026-07-05 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ef8a2d5c9017"
|
||||
down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
INVENTORY_PERMISSIONS = [
|
||||
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
|
||||
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
|
||||
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
||||
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
||||
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"inventory_categories",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("slug", sa.String(length=180), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
|
||||
op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_locations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_suppliers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=180), nullable=False),
|
||||
sa.Column("contact_name", sa.String(length=180), nullable=True),
|
||||
sa.Column("email", sa.String(length=255), nullable=True),
|
||||
sa.Column("phone", sa.String(length=80), nullable=True),
|
||||
sa.Column("website", sa.String(length=255), nullable=True),
|
||||
sa.Column("customer_number", sa.String(length=120), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"inventory_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("sku", sa.String(length=40), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.Column("manufacturer", sa.String(length=180), nullable=True),
|
||||
sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
|
||||
sa.Column("compatible_devices", sa.Text(), nullable=True),
|
||||
sa.Column("location_id", sa.Integer(), nullable=True),
|
||||
sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
|
||||
sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("selling_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
|
||||
op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"inventory_stock_movements",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("movement_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("quantity", sa.Integer(), nullable=False),
|
||||
sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("reference_type", sa.String(length=80), nullable=True),
|
||||
sa.Column("reference_id", sa.Integer(), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
|
||||
op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
|
||||
|
||||
for name, display_name, description, module in INVENTORY_PERMISSIONS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (:name, :display_name, :description, :module)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
"administrator": [item[0] for item in INVENTORY_PERMISSIONS],
|
||||
"management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||
"technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
|
||||
"support": ["inventory.read"],
|
||||
"warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
|
||||
}
|
||||
for role_name, permission_names in role_permissions.items():
|
||||
for permission_name in permission_names:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = :role_name
|
||||
AND permissions.name = :permission_name
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'inventory'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
|
||||
op.drop_table("inventory_stock_movements")
|
||||
op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
|
||||
op.drop_table("inventory_items")
|
||||
op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
|
||||
op.drop_table("inventory_suppliers")
|
||||
op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
|
||||
op.drop_table("inventory_locations")
|
||||
op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
|
||||
op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
|
||||
op.drop_table("inventory_categories")
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
"""create system settings
|
||||
|
||||
Revision ID: f2b8d4e6a910
|
||||
Revises: e9a7c3d5b812
|
||||
Create Date: 2026-07-04 14:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f2b8d4e6a910"
|
||||
down_revision: Union[str, Sequence[str], None] = "e9a7c3d5b812"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("key", sa.String(length=160), nullable=False),
|
||||
sa.Column("value", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("is_secret", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
op.create_index(op.f("ix_system_settings_key"), "system_settings", ["key"], unique=True)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (
|
||||
'system_settings.manage',
|
||||
'Systemeinstellungen verwalten',
|
||||
'SMTP und öffentliche Systemlinks verwalten',
|
||||
'system'
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = 'administrator'
|
||||
AND permissions.name = 'system_settings.manage'
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE name = 'system_settings.manage'
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE name = 'system_settings.manage'")
|
||||
op.drop_index(op.f("ix_system_settings_key"), table_name="system_settings")
|
||||
op.drop_table("system_settings")
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"""extend repair estimate items inventory
|
||||
|
||||
Revision ID: f4a9c2d7e118
|
||||
Revises: ef8a2d5c9017
|
||||
Create Date: 2026-07-05 13:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f4a9c2d7e118"
|
||||
down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
|
||||
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
|
||||
op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimate_items_inventory_item_id",
|
||||
"repair_estimate_items",
|
||||
"inventory_items",
|
||||
["inventory_item_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
|
||||
op.drop_column("repair_estimate_items", "inventory_snapshot_name")
|
||||
op.drop_column("repair_estimate_items", "inventory_item_id")
|
||||
Binary file not shown.
|
|
@ -58,16 +58,6 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
|||
return "knowledge.read" in permissions
|
||||
if action.startswith("repairs."):
|
||||
return "repairs.read" in permissions
|
||||
if action.startswith("repair_estimates."):
|
||||
return "repair_estimates.read" in permissions
|
||||
if action.startswith("inventory."):
|
||||
return "inventory.read" in permissions
|
||||
if action.startswith("lexware."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("accounting."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("backups."):
|
||||
return "backup.read" in permissions
|
||||
if action.startswith("audit_logs."):
|
||||
return "audit_logs.read" in permissions
|
||||
if action.startswith("auth."):
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.api_response import ApiSuccess
|
||||
from app.schemas.backup import BackupRestoreRequest
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
router = APIRouter(prefix="/backups", tags=["Backups"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiSuccess)
|
||||
def list_backups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.read")),
|
||||
):
|
||||
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
|
||||
|
||||
|
||||
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
|
||||
def create_backup(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.create")),
|
||||
):
|
||||
backup = BackupService.create_backup(actor=current_user)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.create",
|
||||
entity_type="backup",
|
||||
entity_label=backup.filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={
|
||||
"filename": backup.filename,
|
||||
"size_bytes": backup.size_bytes,
|
||||
"app_version": backup.app_version,
|
||||
},
|
||||
)
|
||||
return ApiSuccess(data=backup, message="Backup erstellt")
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.download")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.download",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
|
||||
)
|
||||
return FileResponse(path=path, media_type="application/zip", filename=path.name)
|
||||
|
||||
|
||||
@router.delete("/{filename}", response_model=ApiSuccess)
|
||||
def delete_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.delete")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
size_bytes = path.stat().st_size
|
||||
BackupService.delete_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.delete",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": size_bytes},
|
||||
)
|
||||
return ApiSuccess(message="Backup geloescht")
|
||||
|
||||
|
||||
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
|
||||
def validate_backup_restore(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
validation = BackupService.validate_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.validate",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
|
||||
)
|
||||
return ApiSuccess(data=validation, message=validation.message)
|
||||
|
||||
|
||||
@router.post("/{filename}/restore", response_model=ApiSuccess)
|
||||
def restore_backup(
|
||||
filename: str,
|
||||
payload: BackupRestoreRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_started",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
try:
|
||||
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_failed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
raise
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_completed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
return ApiSuccess(data=validation, message="Restore abgeschlossen")
|
||||
|
|
@ -4,21 +4,14 @@ from fastapi import APIRouter, Depends
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rbac import get_user_permission_names, require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.rbac import Role
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.lexware import LexwareSyncRecord
|
||||
from app.models.user import User
|
||||
from app.repositories.customer_repository import CustomerRepository
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||
from app.services.backup_service import BackupService
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -39,8 +32,6 @@ def get_dashboard_summary(
|
|||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
inventory: list[MetricCard] = []
|
||||
system_status: list[SystemStatusItem] = []
|
||||
|
||||
if "customers.read" in permissions:
|
||||
customers = [
|
||||
|
|
@ -73,118 +64,8 @@ def get_dashboard_summary(
|
|||
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
|
||||
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
|
||||
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
|
||||
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
|
||||
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
|
||||
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
|
||||
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
|
||||
]
|
||||
|
||||
if "repair_estimates.read" in permissions:
|
||||
repairs.extend([
|
||||
MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)),
|
||||
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)),
|
||||
MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
|
||||
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
|
||||
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
|
||||
])
|
||||
|
||||
if "lexware.read" in permissions:
|
||||
repairs.extend([
|
||||
MetricCard(
|
||||
label="Vorbereitete Rechnungen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="An Buchhaltung übergeben",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "transferred")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="Noch nicht übertragen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
])
|
||||
|
||||
if "inventory.read" in permissions:
|
||||
inventory = [
|
||||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
||||
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
|
||||
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
|
||||
MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)),
|
||||
MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)),
|
||||
MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)),
|
||||
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
|
||||
]
|
||||
|
||||
if "system_settings.manage" in permissions:
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
public_links = SystemSettingsService.get_public_links_settings(db)
|
||||
latest_testmail = db.scalar(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"]))
|
||||
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db)
|
||||
system_status = [
|
||||
SystemStatusItem(
|
||||
label="SMTP-Konfiguration",
|
||||
value={
|
||||
"database": "Admin-Konfiguration",
|
||||
"environment": "Umgebung",
|
||||
"missing": "Nicht konfiguriert",
|
||||
}[smtp_config.source],
|
||||
status="ok" if smtp_config.is_configured else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Public Status Base URL",
|
||||
value=public_links.repair_status_base_url or "Nicht konfiguriert",
|
||||
status="ok" if public_links.repair_status_base_url else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Letzte SMTP-Testmail",
|
||||
value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail",
|
||||
status="info" if latest_testmail else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Letzte fehlgeschlagene Statusmail",
|
||||
value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler",
|
||||
status="warning" if latest_failed_status_mail else "ok",
|
||||
),
|
||||
]
|
||||
|
||||
if "backup.read" in permissions:
|
||||
backup_stats = BackupService.get_backup_stats()
|
||||
repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
|
||||
system_status.extend([
|
||||
SystemStatusItem(
|
||||
label="Letztes Backup",
|
||||
value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
|
||||
status="ok" if backup_stats.latest_backup_at else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Backup-Speicher",
|
||||
value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
|
||||
status="ok" if backup_stats.total_count else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Hermes-Version",
|
||||
value=settings.app_version,
|
||||
status="info",
|
||||
),
|
||||
])
|
||||
|
||||
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||
|
||||
return DashboardSummary(
|
||||
|
|
@ -193,8 +74,6 @@ def get_dashboard_summary(
|
|||
users=users,
|
||||
roles=roles,
|
||||
repairs=repairs,
|
||||
inventory=inventory,
|
||||
system_status=system_status,
|
||||
activities=EmptyWidget(
|
||||
title="Letzte Aktivitäten",
|
||||
message="Noch keine Aktivitäten vorhanden.",
|
||||
|
|
|
|||
|
|
@ -1,319 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.schemas.inventory import (
|
||||
InventoryCategoryPayload,
|
||||
InventoryCategoryResponse,
|
||||
InventoryItemCreate,
|
||||
InventoryItemListResponse,
|
||||
InventoryItemResponse,
|
||||
InventoryItemUpdate,
|
||||
InventoryLocationPayload,
|
||||
InventoryLocationResponse,
|
||||
InventoryStockAction,
|
||||
InventoryStockMovementResponse,
|
||||
InventorySupplierPayload,
|
||||
InventorySupplierResponse,
|
||||
)
|
||||
from app.services.inventory_service import InventoryService
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["Inventory"])
|
||||
|
||||
|
||||
def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
|
||||
item = InventoryRepository.get_item(db, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
|
||||
category = InventoryRepository.get_category(db, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
|
||||
return category
|
||||
|
||||
|
||||
def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
|
||||
location = InventoryRepository.get_location(db, location_id)
|
||||
if location is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
|
||||
return location
|
||||
|
||||
|
||||
def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
|
||||
supplier = InventoryRepository.get_supplier(db, supplier_id)
|
||||
if supplier is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
|
||||
return supplier
|
||||
|
||||
|
||||
@router.get("/items", response_model=InventoryItemListResponse)
|
||||
def list_items(
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
supplier_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
low_stock: bool | None = None,
|
||||
active: bool | None = True,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
items, total = InventoryRepository.list_items(
|
||||
db,
|
||||
q=q,
|
||||
category_id=category_id,
|
||||
supplier_id=supplier_id,
|
||||
location_id=location_id,
|
||||
low_stock=low_stock,
|
||||
active=active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/items/search", response_model=list[InventoryItemResponse])
|
||||
def search_items(
|
||||
q: str | None = None,
|
||||
category: int | None = None,
|
||||
manufacturer: str | None = None,
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
|
||||
|
||||
|
||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_item(
|
||||
payload: InventoryItemCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.create")),
|
||||
):
|
||||
return InventoryService.create_item(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
def get_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return get_item_or_404(db, item_id)
|
||||
|
||||
|
||||
@router.put("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
def update_item(
|
||||
item_id: int,
|
||||
payload: InventoryItemUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.update")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
|
||||
def delete_item(
|
||||
item_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.delete")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.delete_item(db, item, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
|
||||
def adjust_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.adjust")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
|
||||
def reserve_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
|
||||
def release_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.reserve")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
|
||||
def consume_stock(
|
||||
item_id: int,
|
||||
payload: InventoryStockAction,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.stock.consume")),
|
||||
):
|
||||
item = get_item_or_404(db, item_id)
|
||||
return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
|
||||
def list_movements(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
get_item_or_404(db, item_id)
|
||||
return InventoryRepository.list_movements(db, item_id)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[InventoryCategoryResponse])
|
||||
def list_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_categories(db)
|
||||
|
||||
|
||||
@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(
|
||||
payload: InventoryCategoryPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_category(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
|
||||
def update_category(
|
||||
category_id: int,
|
||||
payload: InventoryCategoryPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
category = get_category_or_404(db, category_id)
|
||||
return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
category = get_category_or_404(db, category_id)
|
||||
InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[InventoryLocationResponse])
|
||||
def list_locations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_locations(db)
|
||||
|
||||
|
||||
@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_location(
|
||||
payload: InventoryLocationPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_location(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
|
||||
def update_location(
|
||||
location_id: int,
|
||||
payload: InventoryLocationPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
location = get_location_or_404(db, location_id)
|
||||
return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_location(
|
||||
location_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
location = get_location_or_404(db, location_id)
|
||||
InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/suppliers", response_model=list[InventorySupplierResponse])
|
||||
def list_suppliers(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.read")),
|
||||
):
|
||||
return InventoryRepository.list_suppliers(db)
|
||||
|
||||
|
||||
@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_supplier(
|
||||
payload: InventorySupplierPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
|
||||
def update_supplier(
|
||||
supplier_id: int,
|
||||
payload: InventorySupplierPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
supplier = get_supplier_or_404(db, supplier_id)
|
||||
return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_supplier(
|
||||
supplier_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
|
||||
):
|
||||
supplier = get_supplier_or_404(db, supplier_id)
|
||||
InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
|
||||
return None
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.services.lexware_service import LexwareService
|
||||
|
||||
router = APIRouter(tags=["Lexware"])
|
||||
|
||||
|
||||
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
|
||||
repair = RepairRepository.get_by_id(db, repair_id)
|
||||
if repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
|
||||
return repair
|
||||
|
||||
|
||||
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
|
||||
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
|
||||
if estimate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||
return estimate
|
||||
|
||||
|
||||
@router.get("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||
def get_lexware_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.read")),
|
||||
):
|
||||
return LexwareService.get_settings(db)
|
||||
|
||||
|
||||
@router.put("/lexware/settings", response_model=LexwareSettingsResponse)
|
||||
def update_lexware_settings(
|
||||
payload: LexwareSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.manage")),
|
||||
):
|
||||
return LexwareService.update_settings(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/lexware/test-connection", response_model=LexwareTestConnectionResponse)
|
||||
def test_lexware_connection(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.manage")),
|
||||
):
|
||||
return LexwareService.test_connection(db, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice", response_model=LexwareInvoicePreparationResponse)
|
||||
def prepare_lexware_invoice(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
|
||||
def mark_accounting_transferred(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
payload: AccountingTransferUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_any_permission, require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair_estimate import (
|
||||
PublicEstimateDecisionRequest,
|
||||
PublicEstimateResponse,
|
||||
RepairEstimateCreate,
|
||||
RepairEstimateEventResponse,
|
||||
RepairEstimateResponse,
|
||||
RepairEstimateUpdate,
|
||||
)
|
||||
from app.services.repair_estimate_service import RepairEstimateService
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
|
||||
router = APIRouter(tags=["Repair Estimates"])
|
||||
|
||||
|
||||
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
|
||||
repair = RepairRepository.get_by_id(db, repair_id)
|
||||
if repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
|
||||
return repair
|
||||
|
||||
|
||||
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
|
||||
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
|
||||
if estimate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||
return estimate
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/estimates", response_model=list[RepairEstimateResponse])
|
||||
def list_estimates(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||
):
|
||||
get_repair_or_404(db, repair_id)
|
||||
return RepairEstimateRepository.list_by_repair(db, repair_id)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates", response_model=RepairEstimateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_estimate(
|
||||
repair_id: int,
|
||||
payload: RepairEstimateCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.create")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
return RepairEstimateService.create(db, repair, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
|
||||
def get_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||
):
|
||||
get_repair_or_404(db, repair_id)
|
||||
return get_estimate_or_404(db, repair_id, estimate_id)
|
||||
|
||||
|
||||
@router.put("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
|
||||
def update_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
payload: RepairEstimateUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.update")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return RepairEstimateService.update(db, repair, estimate, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/repairs/{repair_id}/estimates/{estimate_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.delete")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
RepairEstimateService.delete(db, repair, estimate, actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/send", response_model=RepairEstimateResponse)
|
||||
def send_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.send")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return RepairEstimateService.send(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/cancel", response_model=RepairEstimateResponse)
|
||||
def cancel_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse)
|
||||
def revoke_estimate(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.revoke")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
|
||||
def list_estimate_events(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repair_estimates.read")),
|
||||
):
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return estimate.events
|
||||
|
||||
|
||||
def get_public_repair_and_estimate(db: Session, token: str) -> tuple[Repair, RepairEstimate]:
|
||||
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
|
||||
repair = public_link.repair
|
||||
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
|
||||
if estimate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
|
||||
RepairRepository.mark_public_link_used(db, public_link)
|
||||
return repair, estimate
|
||||
|
||||
|
||||
@router.post("/public/repairs/status/{token}/estimate/approve", response_model=PublicEstimateResponse)
|
||||
def approve_public_estimate(
|
||||
token: str,
|
||||
payload: PublicEstimateDecisionRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||
updated = RepairEstimateService.customer_decision(db, repair, estimate, "approve", payload, request=request)
|
||||
return RepairEstimateService.public_response(updated)
|
||||
|
||||
|
||||
@router.post("/public/repairs/status/{token}/estimate/decline", response_model=PublicEstimateResponse)
|
||||
def decline_public_estimate(
|
||||
token: str,
|
||||
payload: PublicEstimateDecisionRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||
updated = RepairEstimateService.customer_decision(db, repair, estimate, "decline", payload, request=request)
|
||||
return RepairEstimateService.public_response(updated)
|
||||
|
||||
|
||||
@router.post("/public/repairs/status/{token}/estimate/question", response_model=PublicEstimateResponse)
|
||||
def question_public_estimate(
|
||||
token: str,
|
||||
payload: PublicEstimateDecisionRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
repair, estimate = get_public_repair_and_estimate(db, token)
|
||||
updated = RepairEstimateService.customer_decision(db, repair, estimate, "question", payload, request=request)
|
||||
return RepairEstimateService.public_response(updated)
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
from typing import cast
|
||||
|
|
@ -12,13 +11,8 @@ from app.models.user import User
|
|||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import (
|
||||
RepairCreate,
|
||||
RepairDocumentResponse,
|
||||
RepairDocumentType,
|
||||
RepairDocumentUpdate,
|
||||
RepairDocumentVisibility,
|
||||
RepairIntakePayload,
|
||||
RepairIntakeResponse,
|
||||
RepairNotificationEventResponse,
|
||||
RepairListResponse,
|
||||
RepairNotificationOverviewResponse,
|
||||
RepairPublicLinkCreateResponse,
|
||||
|
|
@ -31,7 +25,6 @@ from app.schemas.repair import (
|
|||
RepairUpdate,
|
||||
)
|
||||
from app.services.repair_notification_service import RepairNotificationService
|
||||
from app.services.repair_document_service import RepairDocumentService
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
from app.services.repair_service import RepairService
|
||||
|
||||
|
|
@ -136,117 +129,6 @@ def get_repair_history(
|
|||
return RepairRepository.get_history(db, repair_id)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse])
|
||||
def list_repair_documents(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
get_repair_or_404(db, repair_id)
|
||||
return RepairRepository.list_documents(db, repair_id)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_repair_document(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
title: str = Form(..., min_length=1, max_length=255),
|
||||
document_type: RepairDocumentType = Form("other"),
|
||||
visibility: RepairDocumentVisibility = Form("internal"),
|
||||
note: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.update")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
normalized_title = title.strip()
|
||||
normalized_note = note.strip() if note else None
|
||||
if not normalized_title:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich")
|
||||
return await RepairDocumentService.upload_document(
|
||||
db,
|
||||
db_repair,
|
||||
file=file,
|
||||
title=normalized_title,
|
||||
document_type=document_type,
|
||||
visibility=visibility,
|
||||
note=normalized_note,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
|
||||
def get_repair_document(
|
||||
repair_id: int,
|
||||
document_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
get_repair_or_404(db, repair_id)
|
||||
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
return document
|
||||
|
||||
|
||||
@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
|
||||
def update_repair_document(
|
||||
repair_id: int,
|
||||
document_id: int,
|
||||
payload: RepairDocumentUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.update")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/documents/{document_id}/download")
|
||||
def download_repair_document(
|
||||
repair_id: int,
|
||||
document_id: int,
|
||||
disposition: str = Query(default="inline", pattern="^(inline|attachment)$"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
get_repair_or_404(db, repair_id)
|
||||
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
file_path = RepairDocumentService.open_document_file(document)
|
||||
return FileResponse(
|
||||
file_path,
|
||||
media_type=document.mime_type or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": RepairDocumentService.content_disposition(
|
||||
document,
|
||||
mode="attachment" if disposition == "attachment" else "inline",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_repair_document(
|
||||
repair_id: int,
|
||||
document_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.update")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||
def get_public_link(
|
||||
repair_id: int,
|
||||
|
|
@ -289,17 +171,6 @@ def get_repair_notifications(
|
|||
return RepairNotificationService.overview(db, db_repair)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse)
|
||||
def send_repair_status_mail(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.update")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True)
|
||||
|
||||
|
||||
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_repair_intake(
|
||||
payload: RepairIntakePayload,
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.system_setting import (
|
||||
PublicLinksSettingsResponse,
|
||||
PublicLinksSettingsUpdate,
|
||||
SmtpSettingsResponse,
|
||||
SmtpSettingsUpdate,
|
||||
SmtpTestRequest,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/system-settings",
|
||||
tags=["System Settings"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/smtp", response_model=SmtpSettingsResponse)
|
||||
def get_smtp_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.get_smtp_settings(db)
|
||||
|
||||
|
||||
@router.put("/smtp", response_model=SmtpSettingsResponse)
|
||||
def update_smtp_settings(
|
||||
payload: SmtpSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.update_smtp_settings(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/smtp/test", response_model=SmtpTestResponse)
|
||||
def send_smtp_test_mail(
|
||||
payload: SmtpTestRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.send_test_mail(db, payload.recipient, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/public-links", response_model=PublicLinksSettingsResponse)
|
||||
def get_public_links_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.get_public_links_settings(db)
|
||||
|
||||
|
||||
@router.put("/public-links", response_model=PublicLinksSettingsResponse)
|
||||
def update_public_links_settings(
|
||||
payload: PublicLinksSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.update_public_links_settings(db, payload, actor=current_user, request=request)
|
||||
Binary file not shown.
|
|
@ -9,7 +9,7 @@ class Settings(BaseSettings):
|
|||
secret_key: str
|
||||
|
||||
app_name: str = "Hermes API"
|
||||
app_version: str = "0.9.1"
|
||||
app_version: str = "0.1.0"
|
||||
access_token_expire_minutes: int = 60
|
||||
jwt_issuer: str = "hermes"
|
||||
log_level: str = "INFO"
|
||||
|
|
@ -24,17 +24,6 @@ class Settings(BaseSettings):
|
|||
knowledge_storage_path: str = "/data/knowledge"
|
||||
knowledge_max_upload_mb: int = 50
|
||||
olympus_repair_intake_token: str | None = None
|
||||
public_repair_status_base_url: str | None = None
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from_email: str | None = None
|
||||
smtp_from_name: str = "Funktechnik Schubert"
|
||||
smtp_use_tls: bool = True
|
||||
lexware_enabled: bool = False
|
||||
lexware_api_base_url: str = "https://api.lexware.io"
|
||||
lexware_api_key: str | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -25,11 +25,6 @@ import app.models.customer
|
|||
import app.models.knowledge
|
||||
import app.models.audit
|
||||
import app.models.user
|
||||
import app.models.inventory
|
||||
import app.models.repair
|
||||
import app.models.repair_estimate
|
||||
import app.models.system_setting
|
||||
import app.models.lexware
|
||||
|
||||
|
||||
def get_db():
|
||||
|
|
|
|||
|
|
@ -11,17 +11,12 @@ from starlette.requests import Request
|
|||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.audit import router as audit_router
|
||||
from app.api.backups import router as backups_router
|
||||
from app.api.customers import router as customers_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.inventory import router as inventory_router
|
||||
from app.api.knowledge import router as knowledge_router
|
||||
from app.api.lexware import router as lexware_router
|
||||
from app.api.permissions import router as permissions_router
|
||||
from app.api.repairs import router as repairs_router
|
||||
from app.api.repair_estimates import router as repair_estimates_router
|
||||
from app.api.roles import router as roles_router
|
||||
from app.api.system_settings import router as system_settings_router
|
||||
from app.api.users import router as users_router
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.health import check_database
|
||||
|
|
@ -34,24 +29,19 @@ configure_logging()
|
|||
|
||||
app = FastAPI(
|
||||
title="Hermes API",
|
||||
version="0.9.1",
|
||||
version="0.1.0",
|
||||
description="Backend von Olympus",
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(roles_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(customers_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(knowledge_router)
|
||||
app.include_router(repairs_router)
|
||||
app.include_router(repair_estimates_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(system_settings_router)
|
||||
app.include_router(lexware_router)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,114 +0,0 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class InventoryCategory(Base):
|
||||
__tablename__ = "inventory_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
|
||||
|
||||
|
||||
class InventoryLocation(Base):
|
||||
__tablename__ = "inventory_locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
|
||||
|
||||
|
||||
class InventorySupplier(Base):
|
||||
__tablename__ = "inventory_suppliers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
|
||||
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
|
||||
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
|
||||
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
|
||||
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
|
||||
movements: Mapped[list["InventoryStockMovement"]] = relationship(
|
||||
back_populates="item",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="InventoryStockMovement.created_at.desc()",
|
||||
)
|
||||
|
||||
|
||||
class InventoryStockMovement(Base):
|
||||
__tablename__ = "inventory_stock_movements"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
|
||||
movement_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="InventoryStockMovement.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
return self.actor.username if self.actor is not None else ""
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class LexwareSyncRecord(Base):
|
||||
__tablename__ = "lexware_sync_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
lexware_resource_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
|
||||
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
|
@ -41,7 +41,6 @@ class Repair(Base):
|
|||
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
|
||||
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||
documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RepairStatusHistory(Base):
|
||||
|
|
@ -56,11 +55,7 @@ class RepairStatusHistory(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairStatusHistory.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
actor = relationship("User", lazy="joined")
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
|
|
@ -94,37 +89,10 @@ class RepairDocument(Base):
|
|||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
|
||||
visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="documents")
|
||||
uploaded_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairDocument.uploaded_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def uploaded_by_username(self) -> str:
|
||||
return self.uploaded_by.username if self.uploaded_by is not None else ""
|
||||
|
||||
@property
|
||||
def uploaded_by_display_name(self) -> str:
|
||||
if self.uploaded_by is None:
|
||||
return ""
|
||||
display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip()
|
||||
return display_name or self.uploaded_by.username
|
||||
|
||||
|
||||
class RepairPublicAccessToken(Base):
|
||||
|
|
@ -152,9 +120,7 @@ class RepairNotificationEvent(Base):
|
|||
channel: Mapped[str] = mapped_column(String(40), index=True)
|
||||
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
subject: Mapped[str] = mapped_column(String(255))
|
||||
template: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
status: Mapped[str] = mapped_column(String(40), index=True)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class RepairEstimate(Base):
|
||||
__tablename__ = "repair_estimates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
estimate_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
customer_message: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
internal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
subtotal_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||
tax_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lexware_invoice_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
repair = relationship("Repair", lazy="joined")
|
||||
created_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimate.created_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
accounting_transferred_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
items: Mapped[list["RepairEstimateItem"]] = relationship(
|
||||
back_populates="estimate",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="RepairEstimateItem.position",
|
||||
lazy="selectin",
|
||||
)
|
||||
events: Mapped[list["RepairEstimateEvent"]] = relationship(
|
||||
back_populates="estimate",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="RepairEstimateEvent.created_at",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class RepairEstimateItem(Base):
|
||||
__tablename__ = "repair_estimate_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
||||
inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="")
|
||||
inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=Decimal("1.00"), server_default="1.00")
|
||||
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||
unit_price_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
||||
inventory_item = relationship("InventoryItem", lazy="joined")
|
||||
|
||||
|
||||
class RepairEstimateEvent(Base):
|
||||
__tablename__ = "repair_estimate_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
actor_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimateEvent.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
is_secret: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
|
@ -69,7 +69,6 @@ STANDARD_PERMISSIONS = [
|
|||
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
||||
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||
("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"),
|
||||
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
|
||||
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
|
||||
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
|
||||
|
|
@ -85,28 +84,6 @@ STANDARD_PERMISSIONS = [
|
|||
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
||||
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
||||
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
|
||||
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
|
||||
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
|
||||
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
|
||||
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
|
||||
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
|
||||
("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"),
|
||||
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
|
||||
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
|
||||
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
||||
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
||||
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
||||
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
||||
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
||||
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
||||
("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
|
||||
("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
|
||||
("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
|
||||
("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
|
||||
("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
|
||||
]
|
||||
|
||||
ROLE_PERMISSION_NAMES = {
|
||||
|
|
@ -124,24 +101,6 @@ ROLE_PERMISSION_NAMES = {
|
|||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
"repairs.public_link.manage",
|
||||
"repair_estimates.read",
|
||||
"repair_estimates.create",
|
||||
"repair_estimates.update",
|
||||
"repair_estimates.send",
|
||||
"repair_estimates.revoke",
|
||||
"inventory.read",
|
||||
"inventory.create",
|
||||
"inventory.update",
|
||||
"inventory.stock.adjust",
|
||||
"inventory.stock.reserve",
|
||||
"inventory.stock.consume",
|
||||
"inventory.manage.masterdata",
|
||||
"lexware.read",
|
||||
"lexware.manage",
|
||||
"lexware.export",
|
||||
"backup.read",
|
||||
"backup.create",
|
||||
"backup.download",
|
||||
},
|
||||
"sales": {
|
||||
"dashboard.read",
|
||||
|
|
@ -165,13 +124,6 @@ ROLE_PERMISSION_NAMES = {
|
|||
"repairs.read",
|
||||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
"repair_estimates.read",
|
||||
"repair_estimates.create",
|
||||
"repair_estimates.update",
|
||||
"repair_estimates.send",
|
||||
"inventory.read",
|
||||
"inventory.stock.reserve",
|
||||
"inventory.stock.consume",
|
||||
},
|
||||
"support": {
|
||||
"dashboard.read",
|
||||
|
|
@ -188,20 +140,9 @@ ROLE_PERMISSION_NAMES = {
|
|||
"repairs.status.update",
|
||||
"repairs.intake",
|
||||
"repairs.public_link.manage",
|
||||
"repair_estimates.read",
|
||||
"repair_estimates.send",
|
||||
"inventory.read",
|
||||
"lexware.read",
|
||||
},
|
||||
"warehouse": {
|
||||
"dashboard.read",
|
||||
"inventory.read",
|
||||
"inventory.create",
|
||||
"inventory.update",
|
||||
"inventory.stock.adjust",
|
||||
"inventory.stock.reserve",
|
||||
"inventory.stock.consume",
|
||||
"inventory.manage.masterdata",
|
||||
},
|
||||
"guest": {
|
||||
"dashboard.read",
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.inventory import (
|
||||
InventoryCategory,
|
||||
InventoryItem,
|
||||
InventoryLocation,
|
||||
InventoryStockMovement,
|
||||
InventorySupplier,
|
||||
)
|
||||
|
||||
|
||||
class InventoryRepository:
|
||||
@staticmethod
|
||||
def item_query() -> Select[tuple[InventoryItem]]:
|
||||
return select(InventoryItem).options(
|
||||
selectinload(InventoryItem.category),
|
||||
selectinload(InventoryItem.supplier),
|
||||
selectinload(InventoryItem.location),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_items(
|
||||
db: Session,
|
||||
*,
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
supplier_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
low_stock: bool | None = None,
|
||||
active: bool | None = True,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[InventoryItem], int]:
|
||||
query = InventoryRepository.item_query()
|
||||
if q:
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(
|
||||
InventoryItem.sku.ilike(pattern),
|
||||
InventoryItem.name.ilike(pattern),
|
||||
InventoryItem.manufacturer.ilike(pattern),
|
||||
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||
InventoryItem.supplier_part_number.ilike(pattern),
|
||||
))
|
||||
if category_id is not None:
|
||||
query = query.where(InventoryItem.category_id == category_id)
|
||||
if supplier_id is not None:
|
||||
query = query.where(InventoryItem.supplier_id == supplier_id)
|
||||
if location_id is not None:
|
||||
query = query.where(InventoryItem.location_id == location_id)
|
||||
if low_stock is not None:
|
||||
if low_stock:
|
||||
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||
else:
|
||||
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
|
||||
if active is not None:
|
||||
query = query.where(InventoryItem.is_active == active)
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
items = list(
|
||||
db.scalars(
|
||||
query
|
||||
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def search_items(
|
||||
db: Session,
|
||||
*,
|
||||
q: str | None = None,
|
||||
category_id: int | None = None,
|
||||
manufacturer: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[InventoryItem]:
|
||||
query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
|
||||
if q:
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(
|
||||
InventoryItem.sku.ilike(pattern),
|
||||
InventoryItem.name.ilike(pattern),
|
||||
InventoryItem.manufacturer.ilike(pattern),
|
||||
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||
InventoryItem.supplier_part_number.ilike(pattern),
|
||||
InventoryItem.compatible_devices.ilike(pattern),
|
||||
))
|
||||
if category_id is not None:
|
||||
query = query.where(InventoryItem.category_id == category_id)
|
||||
if manufacturer:
|
||||
query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
|
||||
return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
|
||||
|
||||
@staticmethod
|
||||
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
||||
|
||||
@staticmethod
|
||||
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
|
||||
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
|
||||
|
||||
@staticmethod
|
||||
def get_next_sku(db: Session, year: int | None = None) -> str:
|
||||
current_year = year or datetime.now(UTC).year
|
||||
prefix = f"ET-{current_year}-"
|
||||
latest = db.scalar(
|
||||
select(InventoryItem.sku)
|
||||
.where(InventoryItem.sku.like(f"{prefix}%"))
|
||||
.order_by(InventoryItem.sku.desc())
|
||||
.limit(1)
|
||||
)
|
||||
next_number = 1
|
||||
if latest:
|
||||
next_number = int(latest.split("-")[-1]) + 1
|
||||
return f"{prefix}{next_number:06d}"
|
||||
|
||||
@staticmethod
|
||||
def list_categories(db: Session) -> list[InventoryCategory]:
|
||||
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
|
||||
return db.get(InventoryCategory, category_id)
|
||||
|
||||
@staticmethod
|
||||
def list_locations(db: Session) -> list[InventoryLocation]:
|
||||
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
|
||||
return db.get(InventoryLocation, location_id)
|
||||
|
||||
@staticmethod
|
||||
def list_suppliers(db: Session) -> list[InventorySupplier]:
|
||||
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
|
||||
|
||||
@staticmethod
|
||||
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
|
||||
return db.get(InventorySupplier, supplier_id)
|
||||
|
||||
@staticmethod
|
||||
def has_movements(db: Session, item_id: int) -> bool:
|
||||
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
|
||||
|
||||
@staticmethod
|
||||
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(InventoryStockMovement)
|
||||
.where(InventoryStockMovement.item_id == item_id)
|
||||
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_movement(
|
||||
db: Session,
|
||||
*,
|
||||
item_id: int,
|
||||
movement_type: str,
|
||||
quantity: int,
|
||||
reason: str,
|
||||
reference_type: str | None,
|
||||
reference_id: int | None,
|
||||
note: str | None,
|
||||
actor_user_id: int | None,
|
||||
) -> InventoryStockMovement:
|
||||
movement = InventoryStockMovement(
|
||||
item_id=item_id,
|
||||
movement_type=movement_type,
|
||||
quantity=quantity,
|
||||
reason=reason,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
note=note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
db.add(movement)
|
||||
return movement
|
||||
|
||||
@staticmethod
|
||||
def count_active_items(db: Session) -> int:
|
||||
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_low_stock_items(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(InventoryItem.id))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def total_stock_value_cents(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.purchase_price_cents.is_not(None))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_reserved_items(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(InventoryItem.id))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.quantity_reserved > 0)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def reserved_stock_value_cents(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
|
||||
.where(InventoryItem.is_active.is_(True))
|
||||
.where(InventoryItem.selling_price_cents.is_not(None))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(InventoryStockMovement)
|
||||
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.repair_estimate import RepairEstimate, RepairEstimateEvent, RepairEstimateItem
|
||||
|
||||
|
||||
class RepairEstimateRepository:
|
||||
@staticmethod
|
||||
def list_by_repair(db: Session, repair_id: int) -> list[RepairEstimate]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairEstimate)
|
||||
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
|
||||
.where(RepairEstimate.repair_id == repair_id)
|
||||
.order_by(RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get(db: Session, *, repair_id: int, estimate_id: int) -> RepairEstimate | None:
|
||||
return db.scalar(
|
||||
select(RepairEstimate)
|
||||
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
|
||||
.where(RepairEstimate.repair_id == repair_id)
|
||||
.where(RepairEstimate.id == estimate_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_latest_public(db: Session, repair_id: int) -> RepairEstimate | None:
|
||||
return db.scalar(
|
||||
select(RepairEstimate)
|
||||
.options(selectinload(RepairEstimate.items))
|
||||
.where(RepairEstimate.repair_id == repair_id)
|
||||
.where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
|
||||
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_next_number(db: Session, year: int) -> str:
|
||||
prefix = f"KV{year}-"
|
||||
latest = db.scalar(
|
||||
select(RepairEstimate.estimate_number)
|
||||
.where(RepairEstimate.estimate_number.like(f"{prefix}%"))
|
||||
.order_by(RepairEstimate.estimate_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
next_number = 1
|
||||
if latest:
|
||||
next_number = int(latest.split("-")[-1]) + 1
|
||||
return f"{prefix}{next_number:06d}"
|
||||
|
||||
@staticmethod
|
||||
def save(db: Session, estimate: RepairEstimate) -> RepairEstimate:
|
||||
db.add(estimate)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
|
||||
|
||||
@staticmethod
|
||||
def replace_items(db: Session, estimate: RepairEstimate, items: list[RepairEstimateItem]) -> None:
|
||||
estimate.items.clear()
|
||||
db.flush()
|
||||
for item in items:
|
||||
estimate.items.append(item)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, estimate: RepairEstimate) -> None:
|
||||
db.delete(estimate)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def add_event(
|
||||
db: Session,
|
||||
*,
|
||||
estimate_id: int,
|
||||
event_type: str,
|
||||
actor_type: str,
|
||||
actor_user_id: int | None = None,
|
||||
note: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> RepairEstimateEvent:
|
||||
event = RepairEstimateEvent(
|
||||
estimate_id=estimate_id,
|
||||
event_type=event_type,
|
||||
actor_type=actor_type,
|
||||
actor_user_id=actor_user_id,
|
||||
note=note,
|
||||
)
|
||||
db.add(event)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def mark_sent(db: Session, estimate: RepairEstimate) -> RepairEstimate:
|
||||
estimate.status = "sent"
|
||||
estimate.sent_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
|
||||
|
||||
@staticmethod
|
||||
def count_open(db: Session) -> int:
|
||||
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status.in_(["draft", "sent"]))) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_waiting(db: Session) -> int:
|
||||
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "sent")) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_approved_today(db: Session) -> int:
|
||||
today = datetime.now(UTC).date()
|
||||
return db.scalar(
|
||||
select(func.count(RepairEstimate.id))
|
||||
.where(RepairEstimate.status == "approved")
|
||||
.where(func.date(RepairEstimate.approved_at) == today)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_declined(db: Session) -> int:
|
||||
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_revoked_today(db: Session) -> int:
|
||||
today = datetime.now(UTC).date()
|
||||
return db.scalar(
|
||||
select(func.count(RepairEstimate.id))
|
||||
.join(RepairEstimateEvent, RepairEstimateEvent.estimate_id == RepairEstimate.id)
|
||||
.where(RepairEstimate.status == "revoked")
|
||||
.where(RepairEstimateEvent.event_type == "revoked")
|
||||
.where(func.date(RepairEstimateEvent.created_at) == today)
|
||||
) or 0
|
||||
|
|
@ -3,8 +3,8 @@ from datetime import UTC, datetime
|
|||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
||||
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
|
||||
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
||||
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
|
||||
|
||||
|
||||
class RepairRepository:
|
||||
|
|
@ -221,7 +221,6 @@ class RepairRepository:
|
|||
select(RepairPublicAccessToken)
|
||||
.where(RepairPublicAccessToken.repair_id == repair_id)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
.where(RepairPublicAccessToken.revoked_at.is_(None))
|
||||
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
||||
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
|
||||
.limit(1)
|
||||
|
|
@ -235,7 +234,6 @@ class RepairRepository:
|
|||
.options(selectinload(RepairPublicAccessToken.repair))
|
||||
.where(RepairPublicAccessToken.token_hash == token_hash)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
.where(RepairPublicAccessToken.revoked_at.is_(None))
|
||||
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
||||
.limit(1)
|
||||
)
|
||||
|
|
@ -301,9 +299,7 @@ class RepairRepository:
|
|||
channel: str,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
template: str,
|
||||
status: str,
|
||||
success: bool,
|
||||
error_message: str | None = None,
|
||||
sent_at: datetime | None = None,
|
||||
) -> RepairNotificationEvent:
|
||||
|
|
@ -313,9 +309,7 @@ class RepairRepository:
|
|||
channel=channel,
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
template=template,
|
||||
status=status,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
|
|
@ -324,115 +318,6 @@ class RepairRepository:
|
|||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def count_status_mails_sent_today(db: Session) -> int:
|
||||
today = datetime.now(UTC).date()
|
||||
return db.scalar(
|
||||
select(func.count(RepairNotificationEvent.id))
|
||||
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
||||
.where(RepairNotificationEvent.success.is_(True))
|
||||
.where(func.date(RepairNotificationEvent.created_at) == today)
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def count_failed_status_mails(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(RepairNotificationEvent.id))
|
||||
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
||||
.where(RepairNotificationEvent.success.is_(False))
|
||||
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None:
|
||||
return db.scalar(
|
||||
select(RepairNotificationEvent)
|
||||
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
||||
.where(RepairNotificationEvent.success.is_(False))
|
||||
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
||||
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def count_open_repairs_without_customer_email(db: Session) -> int:
|
||||
return db.scalar(
|
||||
select(func.count(Repair.id))
|
||||
.where(~Repair.status.in_(["completed", "cancelled"]))
|
||||
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairDocument)
|
||||
.where(RepairDocument.repair_id == repair_id)
|
||||
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
|
||||
return db.scalar(
|
||||
select(RepairDocument)
|
||||
.where(RepairDocument.repair_id == repair_id)
|
||||
.where(RepairDocument.id == document_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_document(
|
||||
db: Session,
|
||||
*,
|
||||
repair_id: int,
|
||||
title: str,
|
||||
document_type: str,
|
||||
original_filename: str,
|
||||
stored_filename: str,
|
||||
storage_path: str,
|
||||
mime_type: str,
|
||||
size_bytes: int,
|
||||
checksum_sha256: str,
|
||||
visibility: str,
|
||||
note: str | None,
|
||||
uploaded_by_user_id: int | None,
|
||||
) -> RepairDocument:
|
||||
document = RepairDocument(
|
||||
repair_id=repair_id,
|
||||
title=title,
|
||||
document_type=document_type,
|
||||
original_filename=original_filename,
|
||||
stored_filename=stored_filename,
|
||||
storage_path=storage_path,
|
||||
mime_type=mime_type,
|
||||
size_bytes=size_bytes,
|
||||
checksum_sha256=checksum_sha256,
|
||||
visibility=visibility,
|
||||
note=note,
|
||||
uploaded_by_user_id=uploaded_by_user_id,
|
||||
)
|
||||
db.add(document)
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
|
||||
|
||||
@staticmethod
|
||||
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(document, key, value)
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
|
||||
|
||||
@staticmethod
|
||||
def delete_document(db: Session, document: RepairDocument) -> None:
|
||||
db.delete(document)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def count_documents(db: Session) -> int:
|
||||
return db.scalar(select(func.count(RepairDocument.id))) or 0
|
||||
|
||||
@staticmethod
|
||||
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
||||
data = payload.model_dump()
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
|
||||
class SystemSettingsRepository:
|
||||
@staticmethod
|
||||
def get(db: Session, key: str) -> SystemSetting | None:
|
||||
return db.scalar(select(SystemSetting).where(SystemSetting.key == key))
|
||||
|
||||
@staticmethod
|
||||
def get_many(db: Session, keys: Iterable[str]) -> dict[str, SystemSetting]:
|
||||
key_list = list(keys)
|
||||
if not key_list:
|
||||
return {}
|
||||
|
||||
settings = db.scalars(select(SystemSetting).where(SystemSetting.key.in_(key_list))).all()
|
||||
return {setting.key: setting for setting in settings}
|
||||
|
||||
@staticmethod
|
||||
def upsert(db: Session, *, key: str, value: str, is_secret: bool = False) -> SystemSetting:
|
||||
setting = SystemSettingsRepository.get(db, key)
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, is_secret=is_secret)
|
||||
db.add(setting)
|
||||
db.flush()
|
||||
return setting
|
||||
|
||||
setting.value = value
|
||||
setting.is_secret = is_secret
|
||||
db.flush()
|
||||
return setting
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
backup_id: str
|
||||
created_at: datetime
|
||||
app_version: str
|
||||
backup_type: str = "full"
|
||||
database_url_host_anonymized: str
|
||||
database_name: str
|
||||
storage_base_path: str
|
||||
included_sections: list[str] = Field(default_factory=list)
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
checksum_sha256: str
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
|
||||
|
||||
class BackupSummary(BaseModel):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: datetime | None = None
|
||||
app_version: str = ""
|
||||
backup_type: str = "full"
|
||||
database_name: str = ""
|
||||
storage_base_path: str = ""
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
validation_status: str = "valid"
|
||||
validation_message: str = ""
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
items: list[BackupSummary] = Field(default_factory=list)
|
||||
total_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
latest_backup_at: datetime | None = None
|
||||
|
||||
|
||||
class BackupValidationResponse(BaseModel):
|
||||
filename: str
|
||||
valid: bool
|
||||
message: str
|
||||
issues: list[str] = Field(default_factory=list)
|
||||
checksum_valid: bool = False
|
||||
restore_supported: bool = False
|
||||
requires_cli_restore: bool = True
|
||||
manifest: BackupManifest | None = None
|
||||
|
||||
|
||||
class BackupRestoreRequest(BaseModel):
|
||||
confirm_text: str
|
||||
|
|
@ -13,20 +13,12 @@ class EmptyWidget(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class SystemStatusItem(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
status: str = "info"
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
customers: list[MetricCard] = []
|
||||
latest_customers: list[CustomerResponse] = []
|
||||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
inventory: list[MetricCard] = []
|
||||
system_status: list[SystemStatusItem] = []
|
||||
activities: EmptyWidget
|
||||
tasks: EmptyWidget
|
||||
tickets: EmptyWidget
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
InventoryMovementType = Literal[
|
||||
"initial",
|
||||
"purchase",
|
||||
"adjustment",
|
||||
"reservation",
|
||||
"release",
|
||||
"consumption",
|
||||
"return",
|
||||
"correction",
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_optional_text(value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
||||
|
||||
class InventoryCategoryPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventoryLocationPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventorySupplierPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=180)
|
||||
contact_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
customer_number: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_optional_text(value)
|
||||
|
||||
|
||||
class InventoryItemPayload(BaseModel):
|
||||
sku: str | None = Field(default=None, max_length=40)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
category_id: int | None = None
|
||||
manufacturer: str | None = None
|
||||
manufacturer_part_number: str | None = None
|
||||
supplier_id: int | None = None
|
||||
supplier_part_number: str | None = None
|
||||
compatible_devices: str | None = None
|
||||
location_id: int | None = None
|
||||
quantity_on_hand: int = Field(default=0, ge=0)
|
||||
quantity_reserved: int = Field(default=0, ge=0)
|
||||
reorder_level: int = Field(default=0, ge=0)
|
||||
unit: str = Field(default="Stk.", max_length=40)
|
||||
purchase_price_cents: int | None = Field(default=None, ge=0)
|
||||
selling_price_cents: int | None = Field(default=None, ge=0)
|
||||
currency: str = Field(default="EUR", max_length=3)
|
||||
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
|
||||
notes: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator(
|
||||
"sku",
|
||||
"name",
|
||||
"description",
|
||||
"manufacturer",
|
||||
"manufacturer_part_number",
|
||||
"supplier_part_number",
|
||||
"compatible_devices",
|
||||
"unit",
|
||||
"currency",
|
||||
"notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def normalize_currency(cls, value: str) -> str:
|
||||
return value.upper() or "EUR"
|
||||
|
||||
@field_validator("tax_rate_percent", mode="before")
|
||||
@classmethod
|
||||
def normalize_tax_rate(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip().replace(",", ".")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reserved_quantity(self):
|
||||
if self.quantity_reserved > self.quantity_on_hand:
|
||||
raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||
return self
|
||||
|
||||
|
||||
class InventoryItemCreate(InventoryItemPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryItemUpdate(InventoryItemPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryStockAction(BaseModel):
|
||||
quantity: int = Field(ge=0)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
note: str | None = None
|
||||
reference_type: str | None = Field(default=None, max_length=80)
|
||||
reference_id: int | None = None
|
||||
|
||||
@field_validator("reason", "note", "reference_type", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class InventoryCategoryResponse(InventoryCategoryPayload):
|
||||
id: int
|
||||
slug: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryLocationResponse(InventoryLocationPayload):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventorySupplierResponse(InventorySupplierPayload):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
id: int
|
||||
sku: str
|
||||
name: str
|
||||
description: str | None
|
||||
category_id: int | None
|
||||
manufacturer: str | None
|
||||
manufacturer_part_number: str | None
|
||||
supplier_id: int | None
|
||||
supplier_part_number: str | None
|
||||
compatible_devices: str | None
|
||||
location_id: int | None
|
||||
quantity_on_hand: int
|
||||
quantity_reserved: int
|
||||
quantity_available: int
|
||||
reorder_level: int
|
||||
unit: str
|
||||
purchase_price_cents: int | None
|
||||
selling_price_cents: int | None
|
||||
currency: str
|
||||
tax_rate_percent: Decimal
|
||||
notes: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: InventoryCategoryResponse | None
|
||||
supplier: InventorySupplierResponse | None
|
||||
location: InventoryLocationResponse | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryItemListResponse(BaseModel):
|
||||
items: list[InventoryItemResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class InventoryStockMovementResponse(BaseModel):
|
||||
id: int
|
||||
item_id: int
|
||||
movement_type: str
|
||||
quantity: int
|
||||
reason: str
|
||||
reference_type: str | None
|
||||
reference_id: int | None
|
||||
note: str | None
|
||||
actor_user_id: int | None
|
||||
actor_username: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.system_setting import SettingsSource, normalize_text
|
||||
|
||||
|
||||
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
|
||||
LexwareSyncDirection = Literal["push", "pull"]
|
||||
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
|
||||
|
||||
|
||||
class LexwareSettingsResponse(BaseModel):
|
||||
enabled: bool = False
|
||||
api_base_url: str = "https://api.lexware.io"
|
||||
api_key_is_set: bool = False
|
||||
organization_name: str = ""
|
||||
default_tax_rate: Decimal = Decimal("19.00")
|
||||
default_payment_terms_days: int = 14
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class LexwareSettingsUpdate(BaseModel):
|
||||
enabled: bool = False
|
||||
api_base_url: str = Field(default="https://api.lexware.io", max_length=500)
|
||||
api_key: str | None = Field(default=None, max_length=2000)
|
||||
organization_name: str = Field(default="", max_length=255)
|
||||
default_tax_rate: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"), le=Decimal("100"))
|
||||
default_payment_terms_days: int = Field(default=14, ge=0, le=365)
|
||||
|
||||
@field_validator("api_base_url", "api_key", "organization_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("api_base_url")
|
||||
@classmethod
|
||||
def normalize_base_url(cls, value: str) -> str:
|
||||
return (value or "https://api.lexware.io").rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_configuration(self):
|
||||
if self.enabled and not self.api_base_url:
|
||||
raise ValueError("API Base URL ist erforderlich, wenn Lexware aktiviert ist")
|
||||
return self
|
||||
|
||||
|
||||
class LexwareTestConnectionResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
source: SettingsSource
|
||||
api_base_url: str
|
||||
organization_name: str = ""
|
||||
|
||||
|
||||
class LexwareCustomerMapping(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
phone: str
|
||||
search_strategy: str
|
||||
create_payload: dict
|
||||
|
||||
|
||||
class LexwareLineItemMapping(BaseModel):
|
||||
title: str
|
||||
description: str | None
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
unit_price: Decimal
|
||||
tax_rate: Decimal
|
||||
total: Decimal
|
||||
|
||||
|
||||
class LexwareInvoicePreparationResponse(BaseModel):
|
||||
ready_for_export: bool
|
||||
export_status: AccountingExportStatus
|
||||
payload_summary: dict
|
||||
customer_mapping: LexwareCustomerMapping
|
||||
line_item_mapping: list[LexwareLineItemMapping]
|
||||
tax_mapping: dict
|
||||
warnings: list[str]
|
||||
sync_record_id: int
|
||||
accounting_note: str = ""
|
||||
transferred_at: str | None = None
|
||||
transferred_by_user_id: int | None = None
|
||||
|
||||
|
||||
class AccountingTransferUpdate(BaseModel):
|
||||
accounting_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("accounting_note", mode="before")
|
||||
@classmethod
|
||||
def normalize_note(cls, value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
|
@ -3,8 +3,6 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.repair_estimate import PublicEstimateResponse
|
||||
|
||||
RepairStatus = Literal[
|
||||
"new",
|
||||
"accepted",
|
||||
|
|
@ -21,16 +19,6 @@ RepairStatus = Literal[
|
|||
]
|
||||
RepairPriority = Literal["low", "normal", "high", "urgent"]
|
||||
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
|
||||
RepairDocumentType = Literal[
|
||||
"device_photo",
|
||||
"fault_photo",
|
||||
"measurement",
|
||||
"estimate",
|
||||
"repair_report",
|
||||
"shipping",
|
||||
"other",
|
||||
]
|
||||
RepairDocumentVisibility = Literal["internal", "customer"]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
|
|
@ -204,7 +192,6 @@ class RepairPublicStatusResponse(BaseModel):
|
|||
device_model: str
|
||||
status_history_public: list[RepairPublicStatusHistoryItem]
|
||||
updated_at: datetime
|
||||
estimate: PublicEstimateResponse | None = None
|
||||
|
||||
|
||||
class RepairNotificationTemplateResponse(BaseModel):
|
||||
|
|
@ -222,9 +209,7 @@ class RepairNotificationEventResponse(BaseModel):
|
|||
channel: str
|
||||
recipient: str
|
||||
subject: str
|
||||
template: str
|
||||
status: str
|
||||
success: bool
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
sent_at: datetime | None
|
||||
|
|
@ -235,39 +220,3 @@ class RepairNotificationEventResponse(BaseModel):
|
|||
class RepairNotificationOverviewResponse(BaseModel):
|
||||
templates: list[RepairNotificationTemplateResponse]
|
||||
events: list[RepairNotificationEventResponse]
|
||||
|
||||
|
||||
class RepairDocumentBase(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
document_type: RepairDocumentType = "other"
|
||||
visibility: RepairDocumentVisibility = "internal"
|
||||
note: str | None = None
|
||||
|
||||
@field_validator("title", "note", mode="before")
|
||||
@classmethod
|
||||
def normalize_document_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
|
||||
class RepairDocumentUpdate(RepairDocumentBase):
|
||||
pass
|
||||
|
||||
|
||||
class RepairDocumentResponse(RepairDocumentBase):
|
||||
id: int
|
||||
repair_id: int
|
||||
original_filename: str
|
||||
stored_filename: str
|
||||
storage_path: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
checksum_sha256: str
|
||||
uploaded_by_user_id: int | None
|
||||
uploaded_by_username: str = ""
|
||||
uploaded_by_display_name: str = ""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
from datetime import date, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
|
||||
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
||||
EstimateActorType = Literal["user", "customer", "system"]
|
||||
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class RepairEstimateItemPayload(BaseModel):
|
||||
item_type: EstimateItemType = "other"
|
||||
inventory_item_id: int | None = None
|
||||
inventory_price_overridden: bool = False
|
||||
title: str = Field(default="", max_length=255)
|
||||
description: str | None = None
|
||||
quantity: Decimal = Field(gt=Decimal("0"))
|
||||
unit: str = Field(default="Stk.", max_length=40)
|
||||
unit_price_cents: int = Field(ge=0)
|
||||
|
||||
@field_validator("title", "description", "unit", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("quantity", mode="before")
|
||||
@classmethod
|
||||
def normalize_quantity(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip().replace(",", ".")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inventory_or_title(self):
|
||||
if self.inventory_item_id is None and not self.title.strip():
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
return self
|
||||
|
||||
|
||||
class RepairEstimatePayload(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
customer_message: str = ""
|
||||
internal_note: str | None = None
|
||||
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
|
||||
currency: str = Field(default="EUR", max_length=3)
|
||||
valid_until: date | None = None
|
||||
items: list[RepairEstimateItemPayload]
|
||||
|
||||
@field_validator("title", "customer_message", "internal_note", "currency", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def normalize_currency(cls, value: str) -> str:
|
||||
return value.upper() or "EUR"
|
||||
|
||||
@field_validator("tax_rate_percent", mode="before")
|
||||
@classmethod
|
||||
def normalize_tax_rate(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip().replace(",", ".")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_items(self):
|
||||
if not self.items:
|
||||
raise ValueError("Mindestens eine Position ist erforderlich")
|
||||
return self
|
||||
|
||||
|
||||
class RepairEstimateCreate(RepairEstimatePayload):
|
||||
pass
|
||||
|
||||
|
||||
class RepairEstimateUpdate(RepairEstimatePayload):
|
||||
pass
|
||||
|
||||
|
||||
class RepairEstimateItemResponse(BaseModel):
|
||||
id: int
|
||||
estimate_id: int
|
||||
inventory_item_id: int | None
|
||||
inventory_snapshot_name: str
|
||||
inventory_snapshot_sku: str
|
||||
inventory_snapshot_manufacturer: str | None
|
||||
inventory_snapshot_part_number: str | None
|
||||
position: int
|
||||
item_type: EstimateItemType
|
||||
title: str
|
||||
description: str | None
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RepairEstimateEventResponse(BaseModel):
|
||||
id: int
|
||||
estimate_id: int
|
||||
event_type: str
|
||||
actor_type: str
|
||||
actor_user_id: int | None
|
||||
note: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RepairEstimateResponse(BaseModel):
|
||||
id: int
|
||||
repair_id: int
|
||||
estimate_number: str
|
||||
status: EstimateStatus
|
||||
title: str
|
||||
customer_message: str
|
||||
internal_note: str | None
|
||||
subtotal_cents: int
|
||||
tax_rate_percent: Decimal
|
||||
tax_cents: int
|
||||
total_cents: int
|
||||
currency: str
|
||||
valid_until: date | None
|
||||
sent_at: datetime | None
|
||||
approved_at: datetime | None
|
||||
declined_at: datetime | None
|
||||
customer_response_message: str | None
|
||||
lexware_invoice_id: str | None
|
||||
lexware_invoice_number: str | None
|
||||
lexware_invoice_status: str | None
|
||||
lexware_synced_at: datetime | None
|
||||
accounting_export_status: str | None
|
||||
accounting_note: str | None
|
||||
accounting_transferred_at: datetime | None
|
||||
accounting_transferred_by_user_id: int | None
|
||||
created_by_user_id: int | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[RepairEstimateItemResponse]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PublicEstimateItemResponse(BaseModel):
|
||||
item_type: EstimateItemType
|
||||
inventory_snapshot_name: str
|
||||
inventory_snapshot_sku: str
|
||||
inventory_snapshot_manufacturer: str | None
|
||||
inventory_snapshot_part_number: str | None
|
||||
title: str
|
||||
description: str | None
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
|
||||
|
||||
class PublicEstimateResponse(BaseModel):
|
||||
estimate_number: str
|
||||
status: EstimateStatus
|
||||
status_label: str
|
||||
title: str
|
||||
customer_message: str
|
||||
subtotal_cents: int
|
||||
tax_cents: int
|
||||
total_cents: int
|
||||
currency: str
|
||||
valid_until: date | None
|
||||
items: list[PublicEstimateItemResponse]
|
||||
|
||||
|
||||
class PublicEstimateDecisionRequest(BaseModel):
|
||||
message: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def normalize_message(cls, value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
||||
|
||||
def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int:
|
||||
total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return int(total)
|
||||
|
||||
|
||||
def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int:
|
||||
tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return int(tax)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
|
||||
SettingsSource = Literal["database", "environment", "missing"]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class SmtpSettingsResponse(BaseModel):
|
||||
host: str = ""
|
||||
port: int = 587
|
||||
username: str = ""
|
||||
password_is_set: bool = False
|
||||
from_email: str = ""
|
||||
from_name: str = "Funktechnik Schubert"
|
||||
use_tls: bool = True
|
||||
enabled: bool = False
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class SmtpSettingsUpdate(BaseModel):
|
||||
host: str = Field(default="", max_length=255)
|
||||
port: int = Field(default=587, ge=1, le=65535)
|
||||
username: str = Field(default="", max_length=255)
|
||||
password: str | None = Field(default=None, max_length=1000)
|
||||
from_email: str = Field(default="", max_length=255)
|
||||
from_name: str = Field(default="Funktechnik Schubert", max_length=255)
|
||||
use_tls: bool = True
|
||||
enabled: bool = False
|
||||
|
||||
@field_validator("host", "username", "password", "from_email", "from_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_configuration(self):
|
||||
if self.enabled and (not self.host or not self.from_email):
|
||||
raise ValueError("SMTP Host und Absenderadresse sind erforderlich, wenn SMTP aktiviert ist")
|
||||
return self
|
||||
|
||||
|
||||
class SmtpTestRequest(BaseModel):
|
||||
recipient: EmailStr
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class PublicLinksSettingsResponse(BaseModel):
|
||||
repair_status_base_url: str = ""
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class PublicLinksSettingsUpdate(BaseModel):
|
||||
repair_status_base_url: str = Field(default="", max_length=500)
|
||||
|
||||
@field_validator("repair_status_base_url", mode="before")
|
||||
@classmethod
|
||||
def normalize_url(cls, value: object) -> str:
|
||||
return normalize_text(value).rstrip("/")
|
||||
|
|
@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
|
||||
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"}
|
||||
|
||||
|
||||
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
|
||||
|
|
@ -165,63 +165,6 @@ def action_title(action: str) -> str:
|
|||
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
|
||||
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
|
||||
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
|
||||
"repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt",
|
||||
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
||||
"repairs.status_mail.sent": "Statusmail versendet",
|
||||
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
||||
"repairs.documents.upload": "Reparaturdokument hochgeladen",
|
||||
"repairs.documents.update": "Reparaturdokument geändert",
|
||||
"repairs.documents.delete": "Reparaturdokument gelöscht",
|
||||
"repair_estimates.create": "Kostenvoranschlag erstellt",
|
||||
"repair_estimates.update": "Kostenvoranschlag geändert",
|
||||
"repair_estimates.send": "Kostenvoranschlag gesendet",
|
||||
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
|
||||
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
|
||||
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
||||
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
||||
"repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
|
||||
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
||||
"inventory.items.create": "Lagerartikel erstellt",
|
||||
"inventory.items.update": "Lagerartikel geändert",
|
||||
"inventory.items.deactivate": "Lagerartikel deaktiviert",
|
||||
"inventory.items.delete": "Lagerartikel gelöscht",
|
||||
"inventory.stock.adjust": "Bestand angepasst",
|
||||
"inventory.stock.reserve": "Bestand reserviert",
|
||||
"inventory.stock.release": "Reservierung aufgehoben",
|
||||
"inventory.stock.consume": "Bestand verbraucht",
|
||||
"inventory.estimate.reserve": "KV reserviert Lagerbestand",
|
||||
"inventory.estimate.release": "KV-Reservierung aufgehoben",
|
||||
"inventory.estimate.consume": "Reservierung verbraucht",
|
||||
"inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben",
|
||||
"inventory.stock.low": "Lagerbestand knapp",
|
||||
"inventory.categories.create": "Lagerkategorie erstellt",
|
||||
"inventory.categories.update": "Lagerkategorie geändert",
|
||||
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
||||
"inventory.locations.create": "Lagerort erstellt",
|
||||
"inventory.locations.update": "Lagerort geändert",
|
||||
"inventory.locations.delete": "Lagerort gelöscht",
|
||||
"inventory.suppliers.create": "Lieferant erstellt",
|
||||
"inventory.suppliers.update": "Lieferant geändert",
|
||||
"inventory.suppliers.delete": "Lieferant gelöscht",
|
||||
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
|
||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
|
||||
"lexware.settings.update": "Lexware Einstellungen geändert",
|
||||
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
|
||||
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
|
||||
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
|
||||
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
|
||||
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
|
||||
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
|
||||
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
|
||||
"backups.create": "Backup erstellt",
|
||||
"backups.download": "Backup heruntergeladen",
|
||||
"backups.delete": "Backup gelöscht",
|
||||
"backups.validate": "Backup validiert",
|
||||
"backups.restore_started": "Restore gestartet",
|
||||
"backups.restore_failed": "Restore fehlgeschlagen",
|
||||
"backups.restore_completed": "Restore abgeschlossen",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,373 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.backup import (
|
||||
BackupListResponse,
|
||||
BackupManifest,
|
||||
BackupSummary,
|
||||
BackupValidationResponse,
|
||||
)
|
||||
|
||||
BACKUP_FILENAME_PREFIX = "olympus-backup-"
|
||||
BACKUP_FILENAME_SUFFIX = ".zip"
|
||||
DATABASE_DUMP_NAME = "database.dump"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
STORAGE_DIR_NAME = "storage"
|
||||
BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
|
||||
RESTORE_DISABLED_MESSAGE = (
|
||||
"Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
|
||||
"Bitte Restore ueber CLI-Script ausfuehren."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupStats:
|
||||
total_count: int
|
||||
total_size_bytes: int
|
||||
latest_backup_at: datetime | None
|
||||
|
||||
|
||||
class BackupService:
|
||||
@staticmethod
|
||||
def get_backup_dir() -> Path:
|
||||
backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
return backup_dir
|
||||
|
||||
@staticmethod
|
||||
def list_backups() -> BackupListResponse:
|
||||
items = [
|
||||
BackupService._read_summary(path)
|
||||
for path in sorted(
|
||||
BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
|
||||
return BackupListResponse(
|
||||
items=items,
|
||||
total_count=len(items),
|
||||
total_size_bytes=sum(item.size_bytes for item in items),
|
||||
latest_backup_at=latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_backup_stats() -> BackupStats:
|
||||
backups = BackupService.list_backups()
|
||||
return BackupStats(
|
||||
total_count=backups.total_count,
|
||||
total_size_bytes=backups.total_size_bytes,
|
||||
latest_backup_at=backups.latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_backup(*, actor: User) -> BackupSummary:
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
|
||||
target_path = backup_dir / filename
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
|
||||
temp_dir = Path(temp_dir_name)
|
||||
dump_path = temp_dir / DATABASE_DUMP_NAME
|
||||
storage_temp_dir = temp_dir / STORAGE_DIR_NAME
|
||||
manifest_path = temp_dir / MANIFEST_NAME
|
||||
archive_path = temp_dir / filename
|
||||
|
||||
BackupService._run_pg_dump(dump_path)
|
||||
file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
|
||||
dump_size = dump_path.stat().st_size
|
||||
checksum_sha256 = BackupService._calculate_archive_checksum(
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
|
||||
manifest = BackupManifest(
|
||||
backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
|
||||
created_at=datetime.now(UTC),
|
||||
app_version=settings.app_version,
|
||||
backup_type="full",
|
||||
database_url_host_anonymized=BackupService._anonymized_database_host(),
|
||||
database_name=BackupService._database_name(),
|
||||
storage_base_path=settings.storage_base_path,
|
||||
included_sections=["database", "storage"],
|
||||
file_count=file_count + 1,
|
||||
total_size_bytes=total_size_bytes + dump_size,
|
||||
checksum_sha256=checksum_sha256,
|
||||
created_by_user_id=actor.id,
|
||||
created_by_username=actor.username,
|
||||
)
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
BackupService._write_archive(
|
||||
archive_path=archive_path,
|
||||
manifest_path=manifest_path,
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
shutil.move(str(archive_path), target_path)
|
||||
|
||||
return BackupService._read_summary(target_path)
|
||||
|
||||
@staticmethod
|
||||
def validate_backup(filename: str) -> BackupValidationResponse:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
issues: list[str] = []
|
||||
manifest: BackupManifest | None = None
|
||||
checksum_valid = False
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = set(archive.namelist())
|
||||
if MANIFEST_NAME not in names:
|
||||
issues.append("manifest.json fehlt")
|
||||
if DATABASE_DUMP_NAME not in names:
|
||||
issues.append("database.dump fehlt")
|
||||
if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
|
||||
issues.append("storage/ fehlt")
|
||||
|
||||
if MANIFEST_NAME in names:
|
||||
try:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
except Exception:
|
||||
issues.append("manifest.json ist ungueltig")
|
||||
|
||||
if manifest is not None:
|
||||
checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
|
||||
if not checksum_valid:
|
||||
issues.append("Checksumme ist ungueltig")
|
||||
except zipfile.BadZipFile:
|
||||
issues.append("ZIP-Datei ist ungueltig")
|
||||
|
||||
valid = len(issues) == 0
|
||||
return BackupValidationResponse(
|
||||
filename=path.name,
|
||||
valid=valid,
|
||||
message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
|
||||
issues=issues,
|
||||
checksum_valid=checksum_valid,
|
||||
restore_supported=False,
|
||||
requires_cli_restore=True,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
|
||||
if confirm_text != BACKUP_CONFIRM_TEXT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Bestaetigungstext stimmt nicht ueberein",
|
||||
)
|
||||
|
||||
validation = BackupService.validate_backup(filename)
|
||||
if not validation.valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=RESTORE_DISABLED_MESSAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_backup(filename: str) -> None:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
path.unlink(missing_ok=False)
|
||||
|
||||
@staticmethod
|
||||
def resolve_backup_path(filename: str) -> Path:
|
||||
if not filename.endswith(BACKUP_FILENAME_SUFFIX):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
if Path(filename).name != filename or ".." in Path(filename).parts:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
|
||||
path = (BackupService.get_backup_dir() / filename).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
if backup_dir != path.parent or not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _run_pg_dump(dump_path: Path) -> None:
|
||||
pg_dump_url = BackupService._pg_dump_database_url()
|
||||
command = [
|
||||
"pg_dump",
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
f"--file={dump_path}",
|
||||
f"--dbname={pg_dump_url}",
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="pg_dump ist im Hermes-Container nicht verfuegbar",
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="PostgreSQL-Dump konnte nicht erstellt werden",
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
|
||||
source_dir = Path(settings.storage_base_path).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_count = 0
|
||||
total_size_bytes = 0
|
||||
|
||||
for source_path in sorted(source_dir.rglob("*")):
|
||||
if source_path == backup_dir or backup_dir in source_path.parents:
|
||||
continue
|
||||
relative_path = source_path.relative_to(source_dir)
|
||||
destination_path = target_dir / relative_path
|
||||
if source_path.is_dir():
|
||||
destination_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not source_path.is_file():
|
||||
continue
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination_path)
|
||||
file_count += 1
|
||||
total_size_bytes += source_path.stat().st_size
|
||||
|
||||
return file_count, total_size_bytes
|
||||
|
||||
@staticmethod
|
||||
def _write_archive(
|
||||
*,
|
||||
archive_path: Path,
|
||||
manifest_path: Path,
|
||||
dump_path: Path,
|
||||
storage_dir: Path,
|
||||
) -> None:
|
||||
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(f"{STORAGE_DIR_NAME}/", "")
|
||||
archive.write(manifest_path, MANIFEST_NAME)
|
||||
archive.write(dump_path, DATABASE_DUMP_NAME)
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
|
||||
|
||||
@staticmethod
|
||||
def _read_summary(path: Path) -> BackupSummary:
|
||||
default_summary = BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
validation_status="warning",
|
||||
validation_message="Manifest konnte nicht gelesen werden",
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
return BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
created_at=manifest.created_at,
|
||||
app_version=manifest.app_version,
|
||||
backup_type=manifest.backup_type,
|
||||
database_name=manifest.database_name,
|
||||
storage_base_path=manifest.storage_base_path,
|
||||
file_count=manifest.file_count,
|
||||
total_size_bytes=manifest.total_size_bytes,
|
||||
created_by_user_id=manifest.created_by_user_id,
|
||||
created_by_username=manifest.created_by_username,
|
||||
validation_status="valid",
|
||||
validation_message="Backup ist lesbar",
|
||||
)
|
||||
except Exception:
|
||||
return default_summary
|
||||
|
||||
@staticmethod
|
||||
def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, dump_path)
|
||||
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, file_path)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
|
||||
digest = hashlib.sha256()
|
||||
if DATABASE_DUMP_NAME not in archive.namelist():
|
||||
return False
|
||||
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
with archive.open(DATABASE_DUMP_NAME) as dump_file:
|
||||
BackupService._update_digest_from_stream(digest, dump_file)
|
||||
|
||||
for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
|
||||
digest.update(name.encode("utf-8"))
|
||||
with archive.open(name) as storage_file:
|
||||
BackupService._update_digest_from_stream(digest, storage_file)
|
||||
|
||||
return digest.hexdigest() == expected_checksum
|
||||
|
||||
@staticmethod
|
||||
def _anonymized_database_host() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
host = parsed.host or "unknown"
|
||||
digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _database_name() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
return parsed.database or "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _pg_dump_database_url() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
normalized = parsed.set(drivername="postgresql")
|
||||
return normalized.render_as_string(hide_password=False)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_file(digest, file_path: Path) -> None:
|
||||
with file_path.open("rb") as file_handle:
|
||||
BackupService._update_digest_from_stream(digest, file_handle)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_stream(digest, stream) -> None:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
|
|
@ -1,415 +0,0 @@
|
|||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.schemas.inventory import (
|
||||
InventoryCategoryPayload,
|
||||
InventoryItemCreate,
|
||||
InventoryItemUpdate,
|
||||
InventoryLocationPayload,
|
||||
InventoryStockAction,
|
||||
InventorySupplierPayload,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
|
||||
MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
|
||||
return slug or "eintrag"
|
||||
|
||||
|
||||
def _item_label(item: InventoryItem) -> str:
|
||||
return f"{item.sku} · {item.name}"
|
||||
|
||||
|
||||
def _audit_item_data(item: InventoryItem) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"sku": item.sku,
|
||||
"name": item.name,
|
||||
"category_id": item.category_id,
|
||||
"supplier_id": item.supplier_id,
|
||||
"location_id": item.location_id,
|
||||
"quantity_on_hand": item.quantity_on_hand,
|
||||
"quantity_reserved": item.quantity_reserved,
|
||||
"quantity_available": item.quantity_available,
|
||||
"reorder_level": item.reorder_level,
|
||||
"selling_price_cents": item.selling_price_cents,
|
||||
"currency": item.currency,
|
||||
"is_active": item.is_active,
|
||||
}
|
||||
|
||||
|
||||
class InventoryService:
|
||||
@staticmethod
|
||||
def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
|
||||
sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
|
||||
InventoryService._validate_sku_available(db, sku)
|
||||
item = InventoryItem(sku=sku)
|
||||
InventoryService._apply_item_payload(item, payload)
|
||||
try:
|
||||
db.add(item)
|
||||
db.flush()
|
||||
if item.quantity_on_hand > 0:
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="initial",
|
||||
quantity=item.quantity_on_hand,
|
||||
reason="Anfangsbestand",
|
||||
reference_type=None,
|
||||
reference_id=None,
|
||||
note=None,
|
||||
actor_user_id=actor.id,
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||
db.refresh(item)
|
||||
item = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.create",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=_item_label(item),
|
||||
actor=actor,
|
||||
request=request,
|
||||
after_data=_audit_item_data(item),
|
||||
)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
|
||||
before_data = _audit_item_data(item)
|
||||
sku = (payload.sku or "").upper() or item.sku
|
||||
InventoryService._validate_sku_available(db, sku, item_id=item.id)
|
||||
item.sku = sku
|
||||
InventoryService._apply_item_payload(item, payload)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
|
||||
db.refresh(item)
|
||||
updated = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.update",
|
||||
entity_type="inventory_items",
|
||||
entity_id=updated.id,
|
||||
entity_label=_item_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(updated),
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
|
||||
before_data = _audit_item_data(item)
|
||||
label = _item_label(item)
|
||||
if InventoryRepository.has_movements(db, item.id):
|
||||
item.is_active = False
|
||||
InventoryService._recalculate_available(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.deactivate",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(item),
|
||||
)
|
||||
return InventoryRepository.get_item(db, item.id) or item
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.items.delete",
|
||||
entity_type="inventory_items",
|
||||
entity_id=item.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_on_hand = payload.quantity
|
||||
if item.quantity_reserved > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
|
||||
InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_available:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_reserved += payload.quantity
|
||||
InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_reserved:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_reserved -= payload.quantity
|
||||
InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._validate_positive_stock_quantity(payload)
|
||||
if payload.quantity > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
|
||||
before_data = _audit_item_data(item)
|
||||
item.quantity_on_hand -= payload.quantity
|
||||
item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
|
||||
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None or not item.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
if quantity > item.quantity_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
|
||||
)
|
||||
item.quantity_reserved += quantity
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="reservation",
|
||||
quantity=quantity,
|
||||
reason="estimate_reserved",
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def release_estimate_reservation(
|
||||
db: Session,
|
||||
estimate: RepairEstimate,
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
reason: str = "estimate_released",
|
||||
) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None:
|
||||
continue
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
item.quantity_reserved = max(0, item.quantity_reserved - quantity)
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="release",
|
||||
quantity=quantity,
|
||||
reason=reason,
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None:
|
||||
continue
|
||||
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if item is None:
|
||||
continue
|
||||
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||
if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
|
||||
item.quantity_reserved -= quantity
|
||||
item.quantity_on_hand -= quantity
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type="consumption",
|
||||
quantity=quantity,
|
||||
reason="estimate_consumed",
|
||||
reference_type="repair_estimate",
|
||||
reference_id=estimate.id,
|
||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||
return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||
before_data = {"id": category.id, "name": category.name, "slug": category.slug}
|
||||
category.name = payload.name
|
||||
category.slug = _slugify(payload.name)
|
||||
category.description = payload.description
|
||||
return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||
location = InventoryLocation(name=payload.name, description=payload.description)
|
||||
return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
|
||||
before_data = {"id": location.id, "name": location.name}
|
||||
location.name = payload.name
|
||||
location.description = payload.description
|
||||
return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||
supplier = InventorySupplier(**payload.model_dump())
|
||||
return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
|
||||
|
||||
@staticmethod
|
||||
def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
|
||||
before_data = {"id": supplier.id, "name": supplier.name}
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(supplier, key, value)
|
||||
return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
|
||||
label = getattr(entity, "name", "")
|
||||
before_data = {"id": entity.id, "name": label}
|
||||
db.delete(entity)
|
||||
db.commit()
|
||||
write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
|
||||
|
||||
@staticmethod
|
||||
def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
|
||||
for key, value in payload.model_dump(exclude={"sku"}).items():
|
||||
setattr(item, key, value)
|
||||
InventoryService._recalculate_available(item)
|
||||
|
||||
@staticmethod
|
||||
def _recalculate_available(item: InventoryItem) -> None:
|
||||
item.quantity_available = item.quantity_on_hand - item.quantity_reserved
|
||||
|
||||
@staticmethod
|
||||
def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
|
||||
existing = InventoryRepository.get_item_by_sku(db, sku)
|
||||
if existing is not None and existing.id != item_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
|
||||
|
||||
@staticmethod
|
||||
def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
|
||||
if payload.quantity <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
||||
|
||||
@staticmethod
|
||||
def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
|
||||
if quantity != quantity.to_integral_value():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
|
||||
)
|
||||
normalized = int(quantity)
|
||||
if normalized <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||
InventoryService._recalculate_available(item)
|
||||
InventoryRepository.create_movement(
|
||||
db,
|
||||
item_id=item.id,
|
||||
movement_type=movement_type,
|
||||
quantity=payload.quantity,
|
||||
reason=payload.reason,
|
||||
reference_type=payload.reference_type,
|
||||
reference_id=payload.reference_id,
|
||||
note=payload.note,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
|
||||
InventoryService._recalculate_available(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
updated = InventoryRepository.get_item(db, item.id) or item
|
||||
write_audit_log(
|
||||
db,
|
||||
action=action,
|
||||
entity_type="inventory_items",
|
||||
entity_id=updated.id,
|
||||
entity_label=_item_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_item_data(updated),
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
|
||||
try:
|
||||
db.add(entity)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
|
||||
db.refresh(entity)
|
||||
label = getattr(entity, "name", "")
|
||||
write_audit_log(
|
||||
db,
|
||||
action=action,
|
||||
entity_type=entity.__tablename__,
|
||||
entity_id=entity.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data={"id": entity.id, "name": label},
|
||||
)
|
||||
return entity
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request as UrlRequest
|
||||
from urllib.request import urlopen
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.lexware import LexwareSyncRecord
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareCustomerMapping,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareLineItemMapping,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.schemas.system_setting import SettingsSource
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.system_settings_service import parse_bool
|
||||
|
||||
|
||||
LEXWARE_KEYS = (
|
||||
"lexware.enabled",
|
||||
"lexware.api_base_url",
|
||||
"lexware.api_key",
|
||||
"lexware.organization_name",
|
||||
"lexware.default_tax_rate",
|
||||
"lexware.default_payment_terms_days",
|
||||
)
|
||||
|
||||
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
|
||||
DEFAULT_API_BASE_URL = "https://api.lexware.io"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LexwareRuntimeConfig:
|
||||
enabled: bool
|
||||
api_base_url: str
|
||||
api_key: str
|
||||
organization_name: str
|
||||
default_tax_rate: Decimal
|
||||
default_payment_terms_days: int
|
||||
source: SettingsSource
|
||||
|
||||
@property
|
||||
def api_key_is_set(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.enabled and bool(self.api_base_url and self.api_key)
|
||||
|
||||
|
||||
def _decimal(value: object, *, default: Decimal) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "").replace(",", "."))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _int(value: object, *, default: int) -> int:
|
||||
try:
|
||||
parsed = int(str(value or "").strip())
|
||||
except ValueError:
|
||||
return default
|
||||
return parsed if 0 <= parsed <= 365 else default
|
||||
|
||||
|
||||
def _euros(cents: int) -> Decimal:
|
||||
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _safe_error_message(error: Exception) -> str:
|
||||
if isinstance(error, HTTPError):
|
||||
if error.code in {401, 403}:
|
||||
return "Lexware hat den API-Key abgelehnt."
|
||||
if error.code == 404:
|
||||
return "Lexware-Endpunkt wurde nicht gefunden."
|
||||
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
|
||||
if isinstance(error, URLError):
|
||||
return "Lexware ist momentan nicht erreichbar."
|
||||
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
|
||||
|
||||
|
||||
class LexwareService:
|
||||
@staticmethod
|
||||
def get_settings(db: Session) -> LexwareSettingsResponse:
|
||||
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
|
||||
|
||||
@staticmethod
|
||||
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
|
||||
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
|
||||
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
|
||||
db_api_key = values.get("lexware.api_key") or ""
|
||||
db_has_config = db_enabled or db_base_url or db_api_key
|
||||
|
||||
if db_has_config:
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=db_enabled,
|
||||
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
|
||||
api_key=db_api_key,
|
||||
organization_name=values.get("lexware.organization_name", ""),
|
||||
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
|
||||
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
|
||||
source="database",
|
||||
)
|
||||
|
||||
if settings.lexware_enabled or settings.lexware_api_key:
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=settings.lexware_enabled,
|
||||
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
|
||||
api_key=settings.lexware_api_key or "",
|
||||
organization_name="",
|
||||
default_tax_rate=Decimal("19.00"),
|
||||
default_payment_terms_days=14,
|
||||
source="environment",
|
||||
)
|
||||
|
||||
return LexwareRuntimeConfig(
|
||||
enabled=False,
|
||||
api_base_url=DEFAULT_API_BASE_URL,
|
||||
api_key="",
|
||||
organization_name="",
|
||||
default_tax_rate=Decimal("19.00"),
|
||||
default_payment_terms_days=14,
|
||||
source="missing",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
|
||||
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
|
||||
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
|
||||
updates = {
|
||||
"lexware.enabled": "true" if payload.enabled else "false",
|
||||
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
|
||||
"lexware.api_key": api_key,
|
||||
"lexware.organization_name": payload.organization_name,
|
||||
"lexware.default_tax_rate": str(payload.default_tax_rate),
|
||||
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
|
||||
}
|
||||
for key, value in updates.items():
|
||||
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.settings.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"enabled": payload.enabled,
|
||||
"api_base_url": payload.api_base_url,
|
||||
"organization_name": payload.organization_name,
|
||||
"default_tax_rate": str(payload.default_tax_rate),
|
||||
"default_payment_terms_days": payload.default_payment_terms_days,
|
||||
"api_key_changed": bool(payload.api_key),
|
||||
},
|
||||
)
|
||||
return LexwareService.get_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
if not config.is_configured:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "lexware_not_configured", "source": config.source},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=False,
|
||||
message="Lexware ist nicht vollständig konfiguriert.",
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=config.organization_name,
|
||||
)
|
||||
|
||||
try:
|
||||
profile = LexwareService._get_profile(config)
|
||||
except Exception as exc:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=False,
|
||||
message=_safe_error_message(exc),
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=config.organization_name,
|
||||
)
|
||||
|
||||
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.connection.test_success",
|
||||
entity_type="system_settings",
|
||||
entity_label="Lexware Office",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
|
||||
)
|
||||
return LexwareTestConnectionResponse(
|
||||
success=True,
|
||||
message="Lexware-Verbindung erfolgreich geprüft.",
|
||||
source=config.source,
|
||||
api_base_url=config.api_base_url,
|
||||
organization_name=organization_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prepare_invoice(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
if estimate.status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
|
||||
|
||||
warnings: list[str] = []
|
||||
if not config.enabled:
|
||||
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
|
||||
if not config.api_key_is_set:
|
||||
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
|
||||
if not repair.customer_email:
|
||||
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not estimate.items:
|
||||
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
|
||||
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
export_status="prepared",
|
||||
accounting_note=estimate.accounting_note,
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
estimate.accounting_export_status = "prepared"
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.handoff",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
},
|
||||
)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_transferred(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
payload: AccountingTransferUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
record = LexwareService._latest_invoice_record(db, estimate.id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
|
||||
if record.export_status not in {"prepared", "transferred"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
|
||||
|
||||
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
|
||||
now = datetime.now(UTC)
|
||||
record.export_status = "transferred"
|
||||
record.status = "success"
|
||||
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
|
||||
record.transferred_at = now
|
||||
record.transferred_by_user_id = actor.id
|
||||
record.synced_at = now
|
||||
estimate.accounting_export_status = "transferred"
|
||||
estimate.accounting_note = record.accounting_note
|
||||
estimate.accounting_transferred_at = now
|
||||
estimate.accounting_transferred_by_user_id = actor.id
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.mark_transferred",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
"transferred_at": record.transferred_at,
|
||||
},
|
||||
)
|
||||
if note_changed:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.note_update",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
|
||||
)
|
||||
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
|
||||
warnings: list[str] = []
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
return customer_mapping, line_items
|
||||
|
||||
@staticmethod
|
||||
def _invoice_payload_summary(
|
||||
config: LexwareRuntimeConfig,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "invoice",
|
||||
"title": f"Rechnung zu Reparatur {repair.repair_number}",
|
||||
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
|
||||
"repair_number": repair.repair_number,
|
||||
"estimate_number": estimate.estimate_number,
|
||||
"currency": estimate.currency,
|
||||
"payment_terms_days": config.default_payment_terms_days,
|
||||
"subtotal": str(_euros(estimate.subtotal_cents)),
|
||||
"tax": str(_euros(estimate.tax_cents)),
|
||||
"total": str(_euros(estimate.total_cents)),
|
||||
"line_item_count": len(line_items),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _invoice_preparation_response(
|
||||
*,
|
||||
record: LexwareSyncRecord,
|
||||
payload_summary: dict,
|
||||
customer_mapping: LexwareCustomerMapping,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
estimate: RepairEstimate,
|
||||
config: LexwareRuntimeConfig,
|
||||
warnings: list[str],
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
return LexwareInvoicePreparationResponse(
|
||||
ready_for_export=not warnings,
|
||||
export_status=record.export_status,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_item_mapping=line_items,
|
||||
tax_mapping={
|
||||
"source": "repair_estimate",
|
||||
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
|
||||
"default_tax_rate": str(config.default_tax_rate),
|
||||
"tax_amount": str(_euros(estimate.tax_cents)),
|
||||
},
|
||||
warnings=warnings,
|
||||
sync_record_id=record.id,
|
||||
accounting_note=record.accounting_note or "",
|
||||
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
|
||||
transferred_by_user_id=record.transferred_by_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
return db.scalar(
|
||||
select(LexwareSyncRecord)
|
||||
.where(LexwareSyncRecord.entity_type == "repair_estimate")
|
||||
.where(LexwareSyncRecord.entity_id == estimate_id)
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
|
||||
return LexwareSettingsResponse(
|
||||
enabled=config.enabled,
|
||||
api_base_url=config.api_base_url,
|
||||
api_key_is_set=config.api_key_is_set,
|
||||
organization_name=config.organization_name,
|
||||
default_tax_rate=config.default_tax_rate,
|
||||
default_payment_terms_days=config.default_payment_terms_days,
|
||||
source=config.source,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_profile(config: LexwareRuntimeConfig) -> dict:
|
||||
request = UrlRequest(
|
||||
f"{config.api_base_url}/v1/profile",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(request, timeout=15) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
if not body:
|
||||
return {}
|
||||
data = json.loads(body)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.models.repair import Repair, RepairDocument
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairDocumentUpdate
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.storage import get_storage_service
|
||||
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||
|
||||
ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
||||
ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
}
|
||||
|
||||
|
||||
DOCUMENT_TYPE_LABELS = {
|
||||
"device_photo": "Gerätefoto",
|
||||
"fault_photo": "Fehlerbild",
|
||||
"measurement": "Messbild",
|
||||
"estimate": "Kostenvoranschlag",
|
||||
"repair_report": "Reparaturbericht",
|
||||
"shipping": "Versandbeleg",
|
||||
"other": "Dokument",
|
||||
}
|
||||
|
||||
|
||||
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
|
||||
detail = str(exc) or "Ungültige Datei"
|
||||
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
|
||||
return HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
def _repair_label(repair: Repair) -> str:
|
||||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def _document_label(document: RepairDocument) -> str:
|
||||
return f"{document.title} · {document.original_filename}"
|
||||
|
||||
|
||||
def _audit_document_data(document: RepairDocument) -> dict:
|
||||
return {
|
||||
"id": document.id,
|
||||
"repair_id": document.repair_id,
|
||||
"title": document.title,
|
||||
"document_type": document.document_type,
|
||||
"original_filename": document.original_filename,
|
||||
"mime_type": document.mime_type,
|
||||
"size_bytes": document.size_bytes,
|
||||
"visibility": document.visibility,
|
||||
"uploaded_by_user_id": document.uploaded_by_user_id,
|
||||
"created_at": document.created_at,
|
||||
"updated_at": document.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class RepairDocumentService:
|
||||
@staticmethod
|
||||
async def upload_document(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
*,
|
||||
file: UploadFile,
|
||||
title: str,
|
||||
document_type: str,
|
||||
visibility: str,
|
||||
note: str | None,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> RepairDocument:
|
||||
storage_service = get_storage_service()
|
||||
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
||||
content = await file.read(max_bytes + 1)
|
||||
original_filename = file.filename or ""
|
||||
mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type)
|
||||
|
||||
RepairDocumentService._validate_repair_document_type(original_filename, mime_type)
|
||||
|
||||
try:
|
||||
metadata = storage_service.save_file(
|
||||
namespace=f"repairs/{repair.id}/documents",
|
||||
content=content,
|
||||
original_filename=original_filename,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
except StorageValidationError as exc:
|
||||
raise storage_validation_error(exc) from exc
|
||||
|
||||
try:
|
||||
document = RepairRepository.create_document(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
title=title,
|
||||
document_type=document_type,
|
||||
original_filename=metadata.original_filename,
|
||||
stored_filename=metadata.stored_filename,
|
||||
storage_path=metadata.storage_key,
|
||||
mime_type=metadata.mime_type,
|
||||
size_bytes=metadata.size,
|
||||
checksum_sha256=metadata.checksum_sha256,
|
||||
visibility=visibility,
|
||||
note=note,
|
||||
uploaded_by_user_id=actor.id,
|
||||
)
|
||||
except Exception:
|
||||
storage_service.delete_file(metadata.storage_key)
|
||||
raise
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.documents.upload",
|
||||
entity_type="repair_documents",
|
||||
entity_id=document.id,
|
||||
entity_label=_document_label(document),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"document_type": document.document_type,
|
||||
"visibility": document.visibility,
|
||||
"mime_type": document.mime_type,
|
||||
"size_bytes": document.size_bytes,
|
||||
},
|
||||
)
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def update_document(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
document: RepairDocument,
|
||||
payload: RepairDocumentUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> RepairDocument:
|
||||
before_data = _audit_document_data(document)
|
||||
updated = RepairRepository.update_document(db, document, payload)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.documents.update",
|
||||
entity_type="repair_documents",
|
||||
entity_id=updated.id,
|
||||
entity_label=_document_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_document_data(updated),
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def delete_document(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
document: RepairDocument,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> None:
|
||||
before_data = _audit_document_data(document)
|
||||
storage_path = document.storage_path
|
||||
label = _document_label(document)
|
||||
try:
|
||||
get_storage_service().delete_file(storage_path)
|
||||
except StorageValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||
|
||||
RepairRepository.delete_document(db, document)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.documents.delete",
|
||||
entity_type="repair_documents",
|
||||
entity_id=document.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def open_document_file(document: RepairDocument) -> Path:
|
||||
if not document.storage_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
|
||||
try:
|
||||
return get_storage_service().open_file(document.storage_path)
|
||||
except StorageFileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
|
||||
except StorageValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||
|
||||
@staticmethod
|
||||
def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str:
|
||||
filename = document.original_filename or document.stored_filename or "reparatur-dokument"
|
||||
safe_filename = filename.replace('"', "")
|
||||
encoded_filename = quote(filename)
|
||||
return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}'
|
||||
|
||||
@staticmethod
|
||||
def _resolve_mime_type(original_filename: str, content_type: str | None) -> str:
|
||||
if content_type and content_type != "application/octet-stream":
|
||||
return content_type
|
||||
return mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
|
||||
|
||||
@staticmethod
|
||||
def _validate_repair_document_type(original_filename: str, mime_type: str) -> None:
|
||||
extension = Path(original_filename or "").suffix.lower()
|
||||
if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
|
||||
if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
|
||||
|
|
@ -1,662 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from html import escape
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairStatusUpdate
|
||||
from app.schemas.repair_estimate import (
|
||||
PublicEstimateDecisionRequest,
|
||||
PublicEstimateItemResponse,
|
||||
PublicEstimateResponse,
|
||||
RepairEstimateCreate,
|
||||
RepairEstimateItemPayload,
|
||||
RepairEstimateUpdate,
|
||||
calculate_item_total,
|
||||
calculate_tax,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
|
||||
def _repair_label(repair: Repair) -> str:
|
||||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def _estimate_label(estimate: RepairEstimate) -> str:
|
||||
return f"{estimate.estimate_number} · {estimate.title}"
|
||||
|
||||
|
||||
def _money(cents: int, currency: str = "EUR") -> str:
|
||||
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
|
||||
|
||||
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
||||
return {
|
||||
"id": estimate.id,
|
||||
"repair_id": estimate.repair_id,
|
||||
"estimate_number": estimate.estimate_number,
|
||||
"status": estimate.status,
|
||||
"title": estimate.title,
|
||||
"currency": estimate.currency,
|
||||
"valid_until": estimate.valid_until,
|
||||
"sent_at": estimate.sent_at,
|
||||
"approved_at": estimate.approved_at,
|
||||
"declined_at": estimate.declined_at,
|
||||
"created_by_user_id": estimate.created_by_user_id,
|
||||
"inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None],
|
||||
}
|
||||
|
||||
|
||||
ESTIMATE_STATUS_LABELS = {
|
||||
"draft": "Entwurf",
|
||||
"sent": "Wartet auf Freigabe",
|
||||
"approved": "Freigegeben",
|
||||
"declined": "Abgelehnt",
|
||||
"expired": "Abgelaufen",
|
||||
"cancelled": "Storniert",
|
||||
"revoked": "Kostenvoranschlag wird überarbeitet",
|
||||
}
|
||||
|
||||
|
||||
class RepairEstimateService:
|
||||
@staticmethod
|
||||
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
|
||||
estimate = RepairEstimate(
|
||||
repair_id=repair.id,
|
||||
estimate_number=estimate_number,
|
||||
status="draft",
|
||||
created_by_user_id=actor.id,
|
||||
)
|
||||
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||
try:
|
||||
db.add(estimate)
|
||||
db.flush()
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
|
||||
db.add(estimate)
|
||||
db.flush()
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.create",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
after_data=_audit_estimate_data(estimate),
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request)
|
||||
return estimate
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
if estimate.status not in {"draft", "sent"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
||||
before_data = _audit_estimate_data(estimate)
|
||||
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||
was_sent = estimate.status == "sent"
|
||||
if was_sent:
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||
if was_sent:
|
||||
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.reserve",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.update",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=updated.id,
|
||||
entity_label=_estimate_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_estimate_data(updated),
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||
if estimate.status not in {"draft", "sent", "cancelled"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
|
||||
before_data = _audit_estimate_data(estimate)
|
||||
label = _estimate_label(estimate)
|
||||
if estimate.status == "sent":
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.release",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
RepairEstimateRepository.delete(db, estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.delete",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=label,
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
if estimate.status not in {"draft", "sent"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
|
||||
|
||||
if not repair.customer_email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
|
||||
|
||||
was_sent = estimate.status == "sent"
|
||||
if not was_sent:
|
||||
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||
|
||||
link = RepairPublicLinkService.create_with_audit(
|
||||
db,
|
||||
repair,
|
||||
actor=actor,
|
||||
request=request,
|
||||
audit_action="repairs.public_link.regenerate",
|
||||
)
|
||||
public_status_url = link.public_status_path
|
||||
estimate = RepairEstimateRepository.mark_sent(db, estimate)
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
|
||||
if not was_sent:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.reserve",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
mail_sent = False
|
||||
if smtp_config.is_configured:
|
||||
try:
|
||||
SystemSettingsService.send_email(
|
||||
smtp_config,
|
||||
recipient=repair.customer_email,
|
||||
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
|
||||
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
|
||||
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
|
||||
)
|
||||
mail_sent = True
|
||||
except Exception:
|
||||
mail_sent = False
|
||||
|
||||
RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_estimate_mail",
|
||||
channel="email",
|
||||
recipient=repair.customer_email,
|
||||
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
|
||||
template="repair_estimate",
|
||||
status="sent" if mail_sent else "failed",
|
||||
success=mail_sent,
|
||||
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
|
||||
sent_at=datetime.now(UTC) if mail_sent else None,
|
||||
)
|
||||
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.send",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
|
||||
)
|
||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
|
||||
@staticmethod
|
||||
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
||||
was_sent = estimate.status == "sent"
|
||||
if was_sent:
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||
estimate.status = "cancelled"
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.cancel",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
if was_sent:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.release",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
|
||||
@staticmethod
|
||||
def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
if estimate.status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
|
||||
|
||||
before_data = _audit_estimate_data(estimate)
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
|
||||
estimate.status = "revoked"
|
||||
RepairEstimateRepository.add_event(
|
||||
db,
|
||||
estimate_id=estimate.id,
|
||||
event_type="revoked",
|
||||
actor_type="user",
|
||||
actor_user_id=actor.id,
|
||||
note="Freigabe zurückgenommen",
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
|
||||
if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
|
||||
RepairRepository.update_status(
|
||||
db,
|
||||
repair,
|
||||
RepairStatusUpdate(
|
||||
status="waiting_for_customer",
|
||||
note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
|
||||
),
|
||||
actor_user_id=actor.id,
|
||||
)
|
||||
|
||||
link = RepairPublicLinkService.create_with_audit(
|
||||
db,
|
||||
repair,
|
||||
actor=actor,
|
||||
request=request,
|
||||
audit_action="repairs.public_link.regenerate",
|
||||
)
|
||||
subject = "Kostenvoranschlag wurde zurückgenommen"
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
mail_sent = False
|
||||
if smtp_config.is_configured and repair.customer_email:
|
||||
try:
|
||||
SystemSettingsService.send_email(
|
||||
smtp_config,
|
||||
recipient=repair.customer_email,
|
||||
subject=subject,
|
||||
text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
|
||||
html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
|
||||
)
|
||||
mail_sent = True
|
||||
except Exception:
|
||||
mail_sent = False
|
||||
|
||||
RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_estimate_revoked_mail",
|
||||
channel="email",
|
||||
recipient=repair.customer_email,
|
||||
subject=subject,
|
||||
template="repair_estimate_revoked",
|
||||
status="sent" if mail_sent else "failed",
|
||||
success=mail_sent,
|
||||
error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
|
||||
sent_at=datetime.now(UTC) if mail_sent else None,
|
||||
)
|
||||
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repair_estimates.revoke",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=updated.id,
|
||||
entity_label=_estimate_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
before_data=before_data,
|
||||
after_data=_audit_estimate_data(updated),
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
|
||||
)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.release",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=updated.id,
|
||||
entity_label=_estimate_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
||||
if estimate is None:
|
||||
return None
|
||||
return PublicEstimateResponse(
|
||||
estimate_number=estimate.estimate_number,
|
||||
status=estimate.status,
|
||||
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
|
||||
title=estimate.title,
|
||||
customer_message=estimate.customer_message,
|
||||
subtotal_cents=estimate.subtotal_cents,
|
||||
tax_cents=estimate.tax_cents,
|
||||
total_cents=estimate.total_cents,
|
||||
currency=estimate.currency,
|
||||
valid_until=estimate.valid_until,
|
||||
items=[
|
||||
PublicEstimateItemResponse(
|
||||
item_type=item.item_type,
|
||||
inventory_snapshot_name=item.inventory_snapshot_name,
|
||||
inventory_snapshot_sku=item.inventory_snapshot_sku,
|
||||
inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer,
|
||||
inventory_snapshot_part_number=item.inventory_snapshot_part_number,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price_cents=item.unit_price_cents,
|
||||
total_cents=item.total_cents,
|
||||
)
|
||||
for item in estimate.items
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def customer_decision(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
decision: str,
|
||||
payload: PublicEstimateDecisionRequest,
|
||||
*,
|
||||
request: Request | None,
|
||||
) -> RepairEstimate:
|
||||
if estimate.status != "sent":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
|
||||
now = datetime.now(UTC)
|
||||
estimate.customer_response_message = payload.message
|
||||
if decision == "approve":
|
||||
estimate.status = "approved"
|
||||
estimate.approved_at = now
|
||||
event_type = "approved"
|
||||
repair_status = "approved"
|
||||
note = "Kunde hat den Kostenvoranschlag freigegeben"
|
||||
audit_action = "repair_estimates.approve"
|
||||
elif decision == "decline":
|
||||
estimate.status = "declined"
|
||||
estimate.declined_at = now
|
||||
event_type = "declined"
|
||||
repair_status = "waiting_for_customer"
|
||||
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
||||
audit_action = "repair_estimates.decline"
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
|
||||
else:
|
||||
event_type = "question"
|
||||
repair_status = "waiting_for_customer"
|
||||
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
|
||||
audit_action = "repair_estimates.question"
|
||||
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
|
||||
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||
)
|
||||
if decision == "decline":
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.release",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=_estimate_label(estimate),
|
||||
request=request,
|
||||
metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||
)
|
||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||
|
||||
@staticmethod
|
||||
def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
||||
estimate.title = payload.title
|
||||
estimate.customer_message = payload.customer_message
|
||||
estimate.internal_note = payload.internal_note
|
||||
estimate.tax_rate_percent = payload.tax_rate_percent
|
||||
estimate.currency = payload.currency
|
||||
estimate.valid_until = payload.valid_until
|
||||
items, subtotal = RepairEstimateService._build_items(db, payload.items)
|
||||
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
||||
estimate.subtotal_cents = subtotal
|
||||
estimate.tax_cents = tax_cents
|
||||
estimate.total_cents = subtotal + tax_cents
|
||||
estimate.items = items
|
||||
|
||||
@staticmethod
|
||||
def _build_items(
|
||||
db: Session,
|
||||
payload_items: list[RepairEstimateItemPayload],
|
||||
) -> tuple[list[RepairEstimateItem], int]:
|
||||
items: list[RepairEstimateItem] = []
|
||||
subtotal = 0
|
||||
for index, payload in enumerate(payload_items, start=1):
|
||||
item_type = payload.item_type
|
||||
title = payload.title
|
||||
description = payload.description
|
||||
unit = payload.unit
|
||||
unit_price_cents = payload.unit_price_cents
|
||||
inventory_item_id = payload.inventory_item_id
|
||||
inventory_snapshot_name = ""
|
||||
inventory_snapshot_sku = ""
|
||||
inventory_snapshot_manufacturer = None
|
||||
inventory_snapshot_part_number = None
|
||||
if inventory_item_id is not None:
|
||||
inventory_item = InventoryRepository.get_item(db, inventory_item_id)
|
||||
if inventory_item is None or not inventory_item.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden")
|
||||
item_type = "part"
|
||||
title = inventory_item.name
|
||||
unit = inventory_item.unit
|
||||
inventory_snapshot_name = inventory_item.name
|
||||
inventory_snapshot_sku = inventory_item.sku
|
||||
inventory_snapshot_manufacturer = inventory_item.manufacturer
|
||||
inventory_snapshot_part_number = inventory_item.manufacturer_part_number
|
||||
if not payload.inventory_price_overridden:
|
||||
unit_price_cents = inventory_item.selling_price_cents or 0
|
||||
total = calculate_item_total(payload.quantity, unit_price_cents)
|
||||
subtotal += total
|
||||
items.append(
|
||||
RepairEstimateItem(
|
||||
inventory_item_id=inventory_item_id,
|
||||
inventory_snapshot_name=inventory_snapshot_name,
|
||||
inventory_snapshot_sku=inventory_snapshot_sku,
|
||||
inventory_snapshot_manufacturer=inventory_snapshot_manufacturer,
|
||||
inventory_snapshot_part_number=inventory_snapshot_part_number,
|
||||
position=index,
|
||||
item_type=item_type,
|
||||
title=title,
|
||||
description=description,
|
||||
quantity=payload.quantity,
|
||||
unit=unit,
|
||||
unit_price_cents=unit_price_cents,
|
||||
total_cents=total,
|
||||
)
|
||||
)
|
||||
return items, subtotal
|
||||
|
||||
@staticmethod
|
||||
def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]:
|
||||
overrides: list[dict] = []
|
||||
for payload in payload_items:
|
||||
if payload.inventory_item_id is None or not payload.inventory_price_overridden:
|
||||
continue
|
||||
inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id)
|
||||
if inventory_item is None:
|
||||
continue
|
||||
default_price = inventory_item.selling_price_cents or 0
|
||||
if payload.unit_price_cents != default_price:
|
||||
overrides.append({
|
||||
"inventory_item_id": inventory_item.id,
|
||||
"label": f"{inventory_item.sku} · {inventory_item.name}",
|
||||
"default_price_cents": default_price,
|
||||
"override_price_cents": payload.unit_price_cents,
|
||||
})
|
||||
return overrides
|
||||
|
||||
@staticmethod
|
||||
def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None:
|
||||
for override in overrides:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.estimate.price_override",
|
||||
entity_type="inventory_items",
|
||||
entity_id=override["inventory_item_id"],
|
||||
entity_label=override["label"],
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"estimate_id": estimate.id,
|
||||
"estimate_number": estimate.estimate_number,
|
||||
"default_price_cents": override["default_price_cents"],
|
||||
"override_price_cents": override["override_price_cents"],
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||
seen_item_ids: set[int] = set()
|
||||
for estimate_item in estimate.items:
|
||||
if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids:
|
||||
continue
|
||||
seen_item_ids.add(estimate_item.inventory_item_id)
|
||||
inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||
if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level:
|
||||
continue
|
||||
write_audit_log(
|
||||
db,
|
||||
action="inventory.stock.low",
|
||||
entity_type="inventory_items",
|
||||
entity_id=inventory_item.id,
|
||||
entity_label=f"{inventory_item.sku} · {inventory_item.name}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"estimate_id": estimate.id,
|
||||
"estimate_number": estimate.estimate_number,
|
||||
"quantity_available": inventory_item.quantity_available,
|
||||
"reorder_level": inventory_item.reorder_level,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||
return (
|
||||
f"Hallo {repair.customer_name},\n\n"
|
||||
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
|
||||
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
|
||||
f"Kostenvoranschlag: {estimate.estimate_number}\n"
|
||||
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
|
||||
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
|
||||
"Funktechnik Schubert"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||
return f"""<!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>"""
|
||||
|
|
@ -1,18 +1,26 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from html import escape
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.models.repair import Repair, RepairNotificationEvent
|
||||
from app.models.user import User
|
||||
from app.models.repair import Repair
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
|
||||
STATUS_LABELS: dict[str, str] = {
|
||||
"new": "Neu",
|
||||
"accepted": "Angenommen",
|
||||
"diagnosis": "Diagnose",
|
||||
"estimate": "Kostenvoranschlag",
|
||||
"waiting_for_customer": "Wartet auf Kunde",
|
||||
"approved": "Freigegeben",
|
||||
"repair": "Reparatur",
|
||||
"final_test": "Endprüfung",
|
||||
"ready_for_pickup": "Abholbereit",
|
||||
"shipped": "Versand",
|
||||
"completed": "Abgeschlossen",
|
||||
"cancelled": "Storniert",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -88,83 +96,13 @@ TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
|
|||
)
|
||||
|
||||
|
||||
def _device_label(repair: Repair) -> str:
|
||||
return f"{repair.device_manufacturer} {repair.device_model}".strip()
|
||||
|
||||
|
||||
def _repair_label(repair: Repair) -> str:
|
||||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
|
||||
return (
|
||||
f"Hallo {repair.customer_name},\n\n"
|
||||
"der Status Ihrer Reparatur wurde aktualisiert.\n\n"
|
||||
f"Reparaturnummer:\n{repair.repair_number}\n\n"
|
||||
f"Gerät:\n{_device_label(repair)}\n\n"
|
||||
f"Neuer Status:\n{status_label}\n\n"
|
||||
f"Status online ansehen:\n{public_status_url}\n\n"
|
||||
"Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n"
|
||||
"Funktechnik Schubert"
|
||||
)
|
||||
|
||||
|
||||
def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#172033;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid #dce5ef;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#082a60;color:#ffffff;padding:24px 28px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Olympus CRM</div>
|
||||
<div style="font-size:24px;font-weight:800;margin-top:6px;">Funktechnik Schubert</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;">Hallo {escape(repair.customer_name)},</p>
|
||||
<p style="margin:0 0 22px;font-size:16px;line-height:1.55;">der Status Ihrer Reparatur wurde aktualisiert.</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;">Reparaturnummer</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;">{escape(repair.repair_number)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Gerät</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(_device_label(repair))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Neuer Status</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(status_label)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 24px;">
|
||||
<a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#ffffff;text-decoration:none;font-weight:800;border-radius:8px;padding:13px 18px;">Reparaturstatus ansehen</a>
|
||||
</p>
|
||||
<p style="margin:0;color:#657184;font-size:14px;line-height:1.5;">Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f8fafc;color:#657184;font-size:13px;">Funktechnik Schubert</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class RepairNotificationService:
|
||||
@staticmethod
|
||||
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
|
||||
placeholders = {
|
||||
"repair_number": repair.repair_number,
|
||||
"customer_name": repair.customer_name,
|
||||
"device": _device_label(repair),
|
||||
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
|
||||
"status_label": STATUS_LABELS.get(repair.status, repair.status),
|
||||
"public_status_url": public_status_url or "wird später bereitgestellt",
|
||||
"company_name": "Olympus CRM",
|
||||
|
|
@ -186,112 +124,3 @@ class RepairNotificationService:
|
|||
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
|
||||
events=RepairRepository.list_notification_events(db, repair.id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_status_mail(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
*,
|
||||
actor: User | None,
|
||||
request: Request | None,
|
||||
manual: bool = False,
|
||||
) -> RepairNotificationEvent:
|
||||
recipient = repair.customer_email.strip()
|
||||
subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert"
|
||||
status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status)
|
||||
template = "repair_status_update"
|
||||
|
||||
if not recipient:
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_status_mail",
|
||||
channel="email",
|
||||
recipient="",
|
||||
subject=subject,
|
||||
template=template,
|
||||
status="skipped",
|
||||
success=False,
|
||||
error_message="Keine Kunden-E-Mail hinterlegt",
|
||||
)
|
||||
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient")
|
||||
return event
|
||||
|
||||
link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create"
|
||||
public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action)
|
||||
public_status_url = public_link.public_status_path
|
||||
|
||||
text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
|
||||
html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
|
||||
if not smtp_config.is_configured:
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_status_mail",
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
template=template,
|
||||
status="skipped",
|
||||
success=False,
|
||||
error_message="SMTP nicht konfiguriert",
|
||||
)
|
||||
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured")
|
||||
return event
|
||||
|
||||
try:
|
||||
SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html)
|
||||
except Exception:
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_status_mail",
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
template=template,
|
||||
status="failed",
|
||||
success=False,
|
||||
error_message="Statusmail konnte nicht versendet werden",
|
||||
)
|
||||
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed")
|
||||
return event
|
||||
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
event_type="repair_status_mail",
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
template=template,
|
||||
status="sent",
|
||||
success=True,
|
||||
sent_at=datetime.now(UTC),
|
||||
)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.status_mail.sent",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=_repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"notification_event_id": event.id},
|
||||
)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.status_mail.failed",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=_repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": reason},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from app.core.config import settings
|
|||
from app.models.repair import Repair, RepairPublicAccessToken
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.schemas.repair import (
|
||||
RepairPublicLinkCreateResponse,
|
||||
RepairPublicLinkResponse,
|
||||
|
|
@ -19,32 +18,18 @@ from app.schemas.repair import (
|
|||
RepairPublicStatusResponse,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.repair_status_labels import STATUS_LABELS
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
from app.services.repair_notification_service import STATUS_LABELS
|
||||
from app.services.repair_service import repair_label
|
||||
|
||||
|
||||
def normalize_token(token: str) -> str:
|
||||
return token.strip()
|
||||
|
||||
|
||||
def repair_label(repair: Repair) -> str:
|
||||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def public_status_path(db: Session, token: str) -> str:
|
||||
normalized_token = normalize_token(token)
|
||||
path = f"/status/{normalized_token}"
|
||||
base_url = SystemSettingsService.get_public_repair_status_base_url(db)
|
||||
if not base_url:
|
||||
return path
|
||||
return f"{base_url}/{normalized_token}"
|
||||
def public_status_path(token: str) -> str:
|
||||
return f"/status/{token}"
|
||||
|
||||
|
||||
class RepairPublicLinkService:
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
normalized_token = normalize_token(token)
|
||||
return hmac.new(settings.secret_key.encode("utf-8"), normalized_token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def create_token() -> str:
|
||||
|
|
@ -69,23 +54,6 @@ class RepairPublicLinkService:
|
|||
|
||||
@staticmethod
|
||||
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
|
||||
return RepairPublicLinkService.create_with_audit(
|
||||
db,
|
||||
repair,
|
||||
actor=actor,
|
||||
request=request,
|
||||
audit_action="repairs.public_link.create",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_with_audit(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
*,
|
||||
actor: User | None,
|
||||
request: Request | None,
|
||||
audit_action: str,
|
||||
) -> RepairPublicLinkCreateResponse:
|
||||
token = RepairPublicLinkService.create_token()
|
||||
public_link = RepairRepository.create_public_link(
|
||||
db,
|
||||
|
|
@ -95,7 +63,7 @@ class RepairPublicLinkService:
|
|||
)
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
action="repairs.public_link.create",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
|
|
@ -111,7 +79,7 @@ class RepairPublicLinkService:
|
|||
last_used_at=public_link.last_used_at,
|
||||
created_at=public_link.created_at,
|
||||
revoked_at=public_link.revoked_at,
|
||||
public_status_path=public_status_path(db, token),
|
||||
public_status_path=public_status_path(token),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -132,13 +100,14 @@ class RepairPublicLinkService:
|
|||
|
||||
@staticmethod
|
||||
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
|
||||
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
|
||||
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token))
|
||||
if public_link is None or public_link.repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
|
||||
|
||||
RepairRepository.mark_public_link_used(db, public_link)
|
||||
repair = public_link.repair
|
||||
history = RepairRepository.get_history_public(db, repair.id)
|
||||
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
|
||||
from app.services.repair_estimate_service import RepairEstimateService
|
||||
|
||||
response = RepairPublicStatusResponse(
|
||||
return RepairPublicStatusResponse(
|
||||
repair_number=repair.repair_number,
|
||||
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
|
||||
device_manufacturer=repair.device_manufacturer,
|
||||
|
|
@ -152,18 +121,4 @@ class RepairPublicLinkService:
|
|||
for item in history
|
||||
],
|
||||
updated_at=repair.updated_at,
|
||||
estimate=RepairEstimateService.public_response(estimate),
|
||||
)
|
||||
RepairRepository.mark_public_link_used(db, public_link)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken:
|
||||
normalized_token = normalize_token(token)
|
||||
if not normalized_token:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
|
||||
|
||||
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
|
||||
if public_link is None or public_link.repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
|
||||
return public_link
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from app.repositories.customer_repository import CustomerRepository
|
|||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate
|
||||
from app.services.audit_service import sanitize, write_audit_log
|
||||
from app.services.repair_notification_service import RepairNotificationService
|
||||
|
||||
|
||||
def repair_label(repair: Repair) -> str:
|
||||
|
|
@ -75,19 +74,6 @@ class RepairService:
|
|||
after_data=updated,
|
||||
metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status},
|
||||
)
|
||||
try:
|
||||
RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.status_mail.failed",
|
||||
entity_type="repairs",
|
||||
entity_id=updated.id,
|
||||
entity_label=repair_label(updated),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "unexpected_notification_error"},
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
STATUS_LABELS: dict[str, str] = {
|
||||
"new": "Neu",
|
||||
"accepted": "Angenommen",
|
||||
"diagnosis": "Diagnose",
|
||||
"estimate": "Kostenvoranschlag",
|
||||
"waiting_for_customer": "Wartet auf Kunde",
|
||||
"approved": "Freigegeben",
|
||||
"repair": "Reparatur",
|
||||
"final_test": "Endprüfung",
|
||||
"ready_for_pickup": "Abholbereit",
|
||||
"shipped": "Versand",
|
||||
"completed": "Abgeschlossen",
|
||||
"cancelled": "Storniert",
|
||||
}
|
||||
|
||||
MAIL_STATUS_LABELS: dict[str, str] = {
|
||||
"new": "Neu eingegangen",
|
||||
"accepted": "Reparatur angenommen",
|
||||
"diagnosis": "Gerät in Diagnose",
|
||||
"estimate": "Kostenvoranschlag erstellt",
|
||||
"waiting_for_customer": "Wartet auf Ihre Rückmeldung",
|
||||
"approved": "Reparatur freigegeben",
|
||||
"repair": "Reparatur wird durchgeführt",
|
||||
"final_test": "Gerät wird geprüft",
|
||||
"ready_for_pickup": "Gerät ist abholbereit",
|
||||
"shipped": "Gerät wurde versendet",
|
||||
"completed": "Reparatur abgeschlossen",
|
||||
"cancelled": "Reparatur wurde storniert",
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from pydantic import EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.system_setting import (
|
||||
PublicLinksSettingsResponse,
|
||||
PublicLinksSettingsUpdate,
|
||||
SmtpSettingsResponse,
|
||||
SmtpSettingsUpdate,
|
||||
SmtpTestResponse,
|
||||
SettingsSource,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
|
||||
|
||||
SMTP_KEYS = (
|
||||
"smtp.host",
|
||||
"smtp.port",
|
||||
"smtp.username",
|
||||
"smtp.password",
|
||||
"smtp.from_email",
|
||||
"smtp.from_name",
|
||||
"smtp.use_tls",
|
||||
"smtp.enabled",
|
||||
)
|
||||
|
||||
SECRET_KEYS = {"smtp.password"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmtpRuntimeConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
from_email: str
|
||||
from_name: str
|
||||
use_tls: bool
|
||||
enabled: bool
|
||||
source: SettingsSource
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.enabled and bool(self.host and self.from_email)
|
||||
|
||||
@property
|
||||
def password_is_set(self) -> bool:
|
||||
return bool(self.password)
|
||||
|
||||
@property
|
||||
def from_header(self) -> str:
|
||||
if self.from_name:
|
||||
return f"{self.from_name} <{self.from_email}>"
|
||||
return self.from_email
|
||||
|
||||
|
||||
def parse_bool(value: str | None, *, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def parse_int(value: str | None, *, default: int) -> int:
|
||||
try:
|
||||
parsed = int(str(value or "").strip())
|
||||
except ValueError:
|
||||
return default
|
||||
if parsed < 1 or parsed > 65535:
|
||||
return default
|
||||
return parsed
|
||||
|
||||
|
||||
class SystemSettingsService:
|
||||
@staticmethod
|
||||
def get_smtp_settings(db: Session) -> SmtpSettingsResponse:
|
||||
return SystemSettingsService.smtp_response(SystemSettingsService.get_smtp_runtime_config(db))
|
||||
|
||||
@staticmethod
|
||||
def get_smtp_runtime_config(db: Session) -> SmtpRuntimeConfig:
|
||||
values = SystemSettingsService._get_values(db, SMTP_KEYS)
|
||||
database_enabled = parse_bool(values.get("smtp.enabled"), default=False)
|
||||
database_host = values.get("smtp.host", "")
|
||||
database_from_email = values.get("smtp.from_email", "")
|
||||
|
||||
if database_enabled and database_host and database_from_email:
|
||||
return SmtpRuntimeConfig(
|
||||
host=database_host,
|
||||
port=parse_int(values.get("smtp.port"), default=587),
|
||||
username=values.get("smtp.username", ""),
|
||||
password=values.get("smtp.password", ""),
|
||||
from_email=database_from_email,
|
||||
from_name=values.get("smtp.from_name", "Funktechnik Schubert") or "Funktechnik Schubert",
|
||||
use_tls=parse_bool(values.get("smtp.use_tls"), default=True),
|
||||
enabled=True,
|
||||
source="database",
|
||||
)
|
||||
|
||||
if settings.smtp_host and settings.smtp_from_email:
|
||||
return SmtpRuntimeConfig(
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username or "",
|
||||
password=settings.smtp_password or "",
|
||||
from_email=settings.smtp_from_email,
|
||||
from_name=settings.smtp_from_name,
|
||||
use_tls=settings.smtp_use_tls,
|
||||
enabled=True,
|
||||
source="environment",
|
||||
)
|
||||
|
||||
return SmtpRuntimeConfig(
|
||||
host=database_host or settings.smtp_host or "",
|
||||
port=parse_int(values.get("smtp.port"), default=settings.smtp_port),
|
||||
username=values.get("smtp.username", "") or settings.smtp_username or "",
|
||||
password=values.get("smtp.password", "") or settings.smtp_password or "",
|
||||
from_email=database_from_email or settings.smtp_from_email or "",
|
||||
from_name=values.get("smtp.from_name", "") or settings.smtp_from_name,
|
||||
use_tls=parse_bool(values.get("smtp.use_tls"), default=settings.smtp_use_tls),
|
||||
enabled=False,
|
||||
source="missing",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_smtp_settings(
|
||||
db: Session,
|
||||
payload: SmtpSettingsUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> SmtpSettingsResponse:
|
||||
current_values = SystemSettingsService._get_values(db, SMTP_KEYS)
|
||||
password = payload.password if payload.password else current_values.get("smtp.password", "")
|
||||
|
||||
updates = {
|
||||
"smtp.host": payload.host,
|
||||
"smtp.port": str(payload.port),
|
||||
"smtp.username": payload.username,
|
||||
"smtp.password": password,
|
||||
"smtp.from_email": payload.from_email,
|
||||
"smtp.from_name": payload.from_name or "Funktechnik Schubert",
|
||||
"smtp.use_tls": "true" if payload.use_tls else "false",
|
||||
"smtp.enabled": "true" if payload.enabled else "false",
|
||||
}
|
||||
|
||||
for key, value in updates.items():
|
||||
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in SECRET_KEYS)
|
||||
db.commit()
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Konfiguration",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"enabled": payload.enabled,
|
||||
"host": payload.host,
|
||||
"from_email": payload.from_email,
|
||||
"password_changed": bool(payload.password),
|
||||
},
|
||||
)
|
||||
return SystemSettingsService.get_smtp_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def get_public_links_settings(db: Session) -> PublicLinksSettingsResponse:
|
||||
value = SystemSettingsService.get_public_repair_status_base_url(db)
|
||||
source: SettingsSource = "missing"
|
||||
if value:
|
||||
db_value = SystemSettingsRepository.get(db, "public.repair_status_base_url")
|
||||
source = "database" if db_value and db_value.value.strip() else "environment"
|
||||
return PublicLinksSettingsResponse(repair_status_base_url=value, source=source)
|
||||
|
||||
@staticmethod
|
||||
def get_public_repair_status_base_url(db: Session) -> str:
|
||||
setting = SystemSettingsRepository.get(db, "public.repair_status_base_url")
|
||||
if setting and setting.value.strip():
|
||||
return setting.value.strip().rstrip("/")
|
||||
return (settings.public_repair_status_base_url or "").strip().rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def update_public_links_settings(
|
||||
db: Session,
|
||||
payload: PublicLinksSettingsUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> PublicLinksSettingsResponse:
|
||||
SystemSettingsRepository.upsert(
|
||||
db,
|
||||
key="public.repair_status_base_url",
|
||||
value=payload.repair_status_base_url,
|
||||
is_secret=False,
|
||||
)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.public_links.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="Öffentliche Links",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_status_base_url": payload.repair_status_base_url},
|
||||
)
|
||||
return SystemSettingsService.get_public_links_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def send_test_mail(
|
||||
db: Session,
|
||||
recipient: EmailStr,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> SmtpTestResponse:
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
if not smtp_config.is_configured:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "smtp_not_configured", "source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=False, message="SMTP ist nicht vollständig konfiguriert.", source=smtp_config.source)
|
||||
|
||||
try:
|
||||
SystemSettingsService.send_email(
|
||||
smtp_config,
|
||||
recipient=str(recipient),
|
||||
subject="Olympus CRM SMTP-Test",
|
||||
text=(
|
||||
"Hallo,\n\n"
|
||||
"dies ist eine Testmail aus Olympus CRM. "
|
||||
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.\n\n"
|
||||
"Olympus CRM"
|
||||
),
|
||||
html=(
|
||||
"<p>Hallo,</p>"
|
||||
"<p>dies ist eine Testmail aus Olympus CRM. "
|
||||
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.</p>"
|
||||
"<p>Olympus CRM</p>"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "smtp_send_failed", "source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=False, message="Testmail konnte nicht versendet werden.", source=smtp_config.source)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_sent",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=True, message="Testmail wurde versendet.", source=smtp_config.source)
|
||||
|
||||
@staticmethod
|
||||
def send_email(
|
||||
smtp_config: SmtpRuntimeConfig,
|
||||
*,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
text: str,
|
||||
html: str,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = smtp_config.from_header
|
||||
message["To"] = recipient
|
||||
message.set_content(text)
|
||||
message.add_alternative(html, subtype="html")
|
||||
|
||||
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=15) as smtp:
|
||||
if smtp_config.use_tls:
|
||||
smtp.starttls()
|
||||
if smtp_config.username and smtp_config.password:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
smtp.send_message(message)
|
||||
|
||||
@staticmethod
|
||||
def smtp_response(smtp_config: SmtpRuntimeConfig) -> SmtpSettingsResponse:
|
||||
return SmtpSettingsResponse(
|
||||
host=smtp_config.host,
|
||||
port=smtp_config.port,
|
||||
username=smtp_config.username,
|
||||
password_is_set=smtp_config.password_is_set,
|
||||
from_email=smtp_config.from_email,
|
||||
from_name=smtp_config.from_name,
|
||||
use_tls=smtp_config.use_tls,
|
||||
enabled=smtp_config.enabled,
|
||||
source=smtp_config.source,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_values(db: Session, keys: tuple[str, ...]) -> dict[str, str]:
|
||||
settings_by_key = SystemSettingsRepository.get_many(db, keys)
|
||||
return {key: settings_by_key[key].value.strip() for key in keys if key in settings_by_key}
|
||||
|
|
@ -87,7 +87,6 @@ class LocalDiskStorageProvider(StorageProvider):
|
|||
"customers",
|
||||
"projects",
|
||||
"tickets",
|
||||
"repairs",
|
||||
"imports",
|
||||
"temp",
|
||||
]:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.9.1
|
||||
APP_VERSION: 0.1.0
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||
|
|
@ -22,9 +22,6 @@ services:
|
|||
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
||||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
||||
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
|
||||
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
|
||||
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@ FROM python:3.13-slim
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pip install uv
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.9.1
|
||||
APP_VERSION: 0.1.0
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
|
|
@ -26,18 +26,6 @@ services:
|
|||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-}
|
||||
# Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang.
|
||||
PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
|
||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
|
||||
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
|
||||
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
return proxyHermesRequest(request, "/backups/create");
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/backups");
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyCategoryRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/categories/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCategoryRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCategoryRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/movements`);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyItemRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/items");
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyLocationRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/locations/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyLocationRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyLocationRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxySupplierRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/suppliers/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxySupplierRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxySupplierRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/lexware/settings");
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/lexware/settings");
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/lexware/test-connection");
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
documentId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id, documentId } = await params;
|
||||
return proxyHermesStreamRequest(
|
||||
request,
|
||||
`/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
documentId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyDocumentRequest(request: NextRequest, { params }: Params) {
|
||||
const { id, documentId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents`);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents/upload`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
async function proxyEstimateRequest(request: NextRequest, { params }: Params) {
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyEstimateRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyEstimateRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyEstimateRequest(request, context);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`);
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
|
||||
}
|
||||
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