Compare commits
No commits in common. "main" and "v0.8.7" have entirely different histories.
61 changed files with 113 additions and 4133 deletions
|
|
@ -20,7 +20,6 @@ STORAGE_PROVIDER=local
|
||||||
STORAGE_BASE_PATH=/data/storage
|
STORAGE_BASE_PATH=/data/storage
|
||||||
STORAGE_MAX_UPLOAD_MB=50
|
STORAGE_MAX_UPLOAD_MB=50
|
||||||
STORAGE_HOST_PATH=./storage
|
STORAGE_HOST_PATH=./storage
|
||||||
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
|
|
||||||
|
|
||||||
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
||||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||||
|
|
@ -45,8 +44,3 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
|
||||||
LEXWARE_ENABLED=false
|
|
||||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
|
||||||
LEXWARE_API_KEY=
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -43,8 +43,6 @@ out/
|
||||||
# Runtime storage
|
# Runtime storage
|
||||||
# ===========================
|
# ===========================
|
||||||
storage/
|
storage/
|
||||||
/backups/
|
|
||||||
*.dump
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Coverage
|
# Coverage
|
||||||
|
|
|
||||||
|
|
@ -166,58 +166,6 @@ Hermes
|
||||||
|
|
||||||
Athena ist die einzige API-Oberflaeche fuer den Browser.
|
Athena ist die einzige API-Oberflaeche fuer den Browser.
|
||||||
|
|
||||||
## Lexware Office Integration
|
|
||||||
|
|
||||||
Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation.
|
|
||||||
|
|
||||||
Verantwortlichkeiten:
|
|
||||||
|
|
||||||
- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege.
|
|
||||||
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
|
|
||||||
- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt.
|
|
||||||
|
|
||||||
Komponenten:
|
|
||||||
|
|
||||||
- Hermes speichert Lexware-Konfiguration in `system_settings`.
|
|
||||||
- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`.
|
|
||||||
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
|
|
||||||
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
|
|
||||||
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
|
|
||||||
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
|
|
||||||
|
|
||||||
Datenfluss:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office
|
|
||||||
Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
|
|
||||||
|
|
||||||
Buchhaltungsworkflow:
|
|
||||||
|
|
||||||
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
|
|
||||||
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
|
|
||||||
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
|
|
||||||
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
|
|
||||||
|
|
||||||
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
|
|
||||||
|
|
||||||
Neue zentrale Endpunkte:
|
|
||||||
|
|
||||||
- `GET /lexware/settings`
|
|
||||||
- `PUT /lexware/settings`
|
|
||||||
- `POST /lexware/test-connection`
|
|
||||||
- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice`
|
|
||||||
|
|
||||||
RBAC:
|
|
||||||
|
|
||||||
- `lexware.read`
|
|
||||||
- `lexware.manage`
|
|
||||||
- `lexware.export`
|
|
||||||
|
|
||||||
Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen.
|
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
|
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
|
||||||
|
|
@ -273,7 +221,7 @@ Tabellen:
|
||||||
|
|
||||||
## Lager / Ersatzteile
|
## 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.
|
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet.
|
||||||
|
|
||||||
Datenfluss:
|
Datenfluss:
|
||||||
|
|
||||||
|
|
@ -290,9 +238,6 @@ Kernregeln:
|
||||||
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
|
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
|
||||||
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
|
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
|
||||||
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
|
- 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:
|
Tabellen:
|
||||||
|
|
||||||
|
|
@ -326,16 +271,7 @@ RBAC-Permissions:
|
||||||
|
|
||||||
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
|
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:
|
Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
|
||||||
|
|
||||||
- Entwurf: keine Reservierung.
|
|
||||||
- Senden: verknuepfte Lagerartikel werden reserviert.
|
|
||||||
- Freigabe: Reservierung bleibt bestehen.
|
|
||||||
- Ablehnung: Reservierung wird freigegeben.
|
|
||||||
- Storno: Reservierung wird freigegeben.
|
|
||||||
- Loeschung eines gesendeten KV: Reservierung wird freigegeben.
|
|
||||||
|
|
||||||
Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
|
|
||||||
|
|
||||||
Hermes-Endpunkte:
|
Hermes-Endpunkte:
|
||||||
|
|
||||||
|
|
@ -1060,29 +996,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`.
|
`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.
|
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
|
||||||
|
|
||||||
### Backup-Modul ab v0.9.1
|
|
||||||
|
|
||||||
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
|
|
||||||
|
|
||||||
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
|
|
||||||
- Hermes-API: `backend/hermes/app/api/backups.py`
|
|
||||||
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
|
|
||||||
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
|
|
||||||
- Athena-BFF: `/api/backups/...`
|
|
||||||
|
|
||||||
Sicherheitsregeln:
|
|
||||||
|
|
||||||
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
|
|
||||||
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
|
|
||||||
- Hermes anonymisiert den Datenbank-Host im Manifest.
|
|
||||||
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
|
|
||||||
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
|
|
||||||
|
|
||||||
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
|
|
||||||
|
|
||||||
### Knowledge-RBAC
|
### Knowledge-RBAC
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,10 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
LEXWARE_ENABLED=false
|
|
||||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
|
||||||
LEXWARE_API_KEY=
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||||
|
|
||||||
`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
|
|
||||||
|
|
||||||
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
||||||
|
|
||||||
## Docker Netzwerk
|
## Docker Netzwerk
|
||||||
|
|
@ -134,46 +129,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.
|
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
|
||||||
|
|
||||||
## Backup und Restore
|
|
||||||
|
|
||||||
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
|
|
||||||
|
|
||||||
Ablage:
|
|
||||||
|
|
||||||
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
|
|
||||||
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
|
|
||||||
- Backup-Dateien gehoeren nie ins Git
|
|
||||||
|
|
||||||
Inhalt eines Backups:
|
|
||||||
|
|
||||||
- `manifest.json`
|
|
||||||
- `database.dump`
|
|
||||||
- `storage/`
|
|
||||||
|
|
||||||
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
|
|
||||||
|
|
||||||
- `GET /api/backups`
|
|
||||||
- `POST /api/backups/create`
|
|
||||||
- `GET /api/backups/[filename]/download`
|
|
||||||
- `POST /api/backups/[filename]/validate`
|
|
||||||
- `POST /api/backups/[filename]/restore`
|
|
||||||
- `DELETE /api/backups/[filename]/delete`
|
|
||||||
|
|
||||||
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
|
|
||||||
|
|
||||||
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
|
|
||||||
|
|
||||||
1. Backup validieren.
|
|
||||||
2. Sicherheitsbestaetigung pruefen.
|
|
||||||
3. Restore ueber `scripts/restore.sh <backup-zip>` ausfuehren.
|
|
||||||
4. Ergebnis und Audit Logs kontrollieren.
|
|
||||||
|
|
||||||
Empfehlung fuer den Betrieb:
|
|
||||||
|
|
||||||
- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
|
|
||||||
- Backup-Dateien vor Offsite-Kopie verschluesseln.
|
|
||||||
- Restore nur in Wartungsfenstern ausfuehren.
|
|
||||||
|
|
||||||
## Knowledge Workflow
|
## Knowledge Workflow
|
||||||
|
|
||||||
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
||||||
|
|
@ -224,41 +179,6 @@ Statuslink-Konzept:
|
||||||
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
||||||
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
||||||
|
|
||||||
## Lexware Office Foundation
|
|
||||||
|
|
||||||
Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet.
|
|
||||||
|
|
||||||
Rollenverteilung:
|
|
||||||
|
|
||||||
- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten.
|
|
||||||
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
|
|
||||||
|
|
||||||
Konfiguration:
|
|
||||||
|
|
||||||
- Adminbereich: `/settings -> Lexware Office`
|
|
||||||
- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection`
|
|
||||||
- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection`
|
|
||||||
- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
|
|
||||||
|
|
||||||
Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf.
|
|
||||||
|
|
||||||
Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
|
|
||||||
```
|
|
||||||
|
|
||||||
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
|
|
||||||
|
|
||||||
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
|
|
||||||
|
|
||||||
Exportstatus:
|
|
||||||
|
|
||||||
- `prepared`
|
|
||||||
- `transferred`
|
|
||||||
- `booked`
|
|
||||||
- `cancelled`
|
|
||||||
|
|
||||||
Benachrichtigungen:
|
Benachrichtigungen:
|
||||||
|
|
||||||
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
||||||
|
|
@ -308,7 +228,6 @@ SECRET_KEY=local-check uv run alembic upgrade head
|
||||||
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
|
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
|
||||||
|
|
||||||
- `/api/inventory/items`
|
- `/api/inventory/items`
|
||||||
- `/api/inventory/items/search`
|
|
||||||
- `/api/inventory/items/[id]`
|
- `/api/inventory/items/[id]`
|
||||||
- `/api/inventory/items/[id]/stock/adjust`
|
- `/api/inventory/items/[id]/stock/adjust`
|
||||||
- `/api/inventory/items/[id]/stock/reserve`
|
- `/api/inventory/items/[id]/stock/reserve`
|
||||||
|
|
@ -332,19 +251,10 @@ Bestandslogik:
|
||||||
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
|
- 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`.
|
- 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:
|
Folgefeatures:
|
||||||
|
|
||||||
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv.
|
- Integration in Kostenvoranschlaege folgt in v0.8.8.
|
||||||
|
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv.
|
||||||
|
|
||||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
|
|
|
||||||
57
ROADMAP.md
57
ROADMAP.md
|
|
@ -162,63 +162,20 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
|
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
|
||||||
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
|
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
|
||||||
|
|
||||||
## v0.8.8 - Lagerintegration in Kostenvoranschläge
|
## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant
|
||||||
|
|
||||||
- Lagerartikel als KV-Position ueber Suche auswaehlen
|
- Lagerartikel als KV-Position ueber Suche auswaehlen
|
||||||
- Verkaufspreis aus `selling_price_cents` uebernehmen
|
- Verkaufspreis aus `selling_price_cents` uebernehmen
|
||||||
- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen
|
- Reservierungen aus KV-/Reparaturprozess vorbereiten
|
||||||
- Reservierung beim Senden eines KV
|
- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten
|
||||||
- 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
|
## v0.8.9 - Kundenportal, geplant
|
||||||
|
|
||||||
- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert
|
|
||||||
- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten
|
|
||||||
- Lexware-Konfiguration unter `/settings -> Lexware Office`
|
|
||||||
- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel
|
|
||||||
- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
|
|
||||||
- API-Key wird nicht an Athena zurueckgegeben
|
|
||||||
- Server-seitiger Verbindungstest gegen `GET /v1/profile`
|
|
||||||
- Neue Tabelle `lexware_sync_records`
|
|
||||||
- Optionale Lexware-Rechnungsreferenzen an `repair_estimates`
|
|
||||||
- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
|
|
||||||
- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
|
|
||||||
- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
|
|
||||||
- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
|
|
||||||
- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
|
|
||||||
- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
|
|
||||||
|
|
||||||
## v0.9.0 - Lexware Rechnungserstellung, geplant
|
|
||||||
|
|
||||||
- Echte Rechnungserstellung in Lexware nach final geprueftem API-Mapping
|
|
||||||
- Kontaktabgleich und Kontaktanlage in Lexware produktionsreif ausbauen
|
|
||||||
- Exportstatus und Fehlerbehebung im Olympus UI erweitern
|
|
||||||
- Optionaler Download/Link zur Lexware-Rechnung
|
|
||||||
|
|
||||||
## v0.9.1 - Backup und Restore
|
|
||||||
|
|
||||||
- Backup-Modul mit Athena-BFF und Hermes-Service-Layer
|
|
||||||
- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/`
|
|
||||||
- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups`
|
|
||||||
- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore`
|
|
||||||
- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus
|
|
||||||
- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung
|
|
||||||
- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh`
|
|
||||||
- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad
|
|
||||||
|
|
||||||
## v0.9.2 - Kundenportal, geplant
|
|
||||||
|
|
||||||
- `/portal/login` fuer spaeteren Kundenlogin
|
- `/portal/login` fuer spaeteren Kundenlogin
|
||||||
- Separates Authentifizierungsmodell fuer Kunden
|
- Separates Authentifizierungsmodell fuer Kunden
|
||||||
- Keine Vermischung mit internen Olympus-Benutzern
|
- Keine Vermischung mit internen Olympus-Benutzern
|
||||||
|
|
||||||
## v0.9.3 - Tickets, geplant
|
## v0.9.0 - Tickets, geplant
|
||||||
|
|
||||||
- Ticketverwaltung
|
- Ticketverwaltung
|
||||||
- Status- und Prioritaetsmodell
|
- Status- und Prioritaetsmodell
|
||||||
|
|
@ -226,10 +183,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- RBAC-Permissions fuer Tickets
|
- RBAC-Permissions fuer Tickets
|
||||||
- Audit Logs fuer Ticketaktionen
|
- Audit Logs fuer Ticketaktionen
|
||||||
|
|
||||||
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
|
## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
|
||||||
|
|
||||||
- Paperless-ngx Connector fuer Wissensdokumente
|
- Paperless-ngx Connector fuer Wissensdokumente
|
||||||
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
|
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
|
||||||
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
- Sichere Connector-Konfiguration ohne Browser-Secrets
|
||||||
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,3 @@ SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=
|
||||||
SMTP_FROM_NAME=Funktechnik Schubert
|
SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
|
||||||
LEXWARE_ENABLED=false
|
|
||||||
LEXWARE_API_BASE_URL=https://api.lexware.io
|
|
||||||
LEXWARE_API_KEY=
|
|
||||||
|
|
|
||||||
|
|
@ -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,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,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")
|
|
||||||
|
|
@ -58,16 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
||||||
return "knowledge.read" in permissions
|
return "knowledge.read" in permissions
|
||||||
if action.startswith("repairs."):
|
if action.startswith("repairs."):
|
||||||
return "repairs.read" in permissions
|
return "repairs.read" in permissions
|
||||||
if action.startswith("repair_estimates."):
|
|
||||||
return "repair_estimates.read" in permissions
|
|
||||||
if action.startswith("inventory."):
|
if action.startswith("inventory."):
|
||||||
return "inventory.read" in permissions
|
return "inventory.read" in permissions
|
||||||
if action.startswith("lexware."):
|
|
||||||
return "lexware.read" in permissions
|
|
||||||
if action.startswith("accounting."):
|
|
||||||
return "lexware.read" in permissions
|
|
||||||
if action.startswith("backups."):
|
|
||||||
return "backup.read" in permissions
|
|
||||||
if action.startswith("audit_logs."):
|
if action.startswith("audit_logs."):
|
||||||
return "audit_logs.read" in permissions
|
return "audit_logs.read" in permissions
|
||||||
if action.startswith("auth."):
|
if action.startswith("auth."):
|
||||||
|
|
|
||||||
|
|
@ -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,12 +4,10 @@ from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.rbac import get_user_permission_names, require_permission
|
from app.core.rbac import get_user_permission_names, require_permission
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.rbac import Role
|
from app.models.rbac import Role
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.lexware import LexwareSyncRecord
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.customer_repository import CustomerRepository
|
from app.repositories.customer_repository import CustomerRepository
|
||||||
from app.repositories.inventory_repository import InventoryRepository
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
|
|
@ -17,7 +15,6 @@ from app.repositories.repair_repository import RepairRepository
|
||||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
from app.repositories.user_repository import UserRepository
|
from app.repositories.user_repository import UserRepository
|
||||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||||
from app.services.backup_service import BackupService
|
|
||||||
from app.services.system_settings_service import SystemSettingsService
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -85,35 +82,6 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(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 freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
|
||||||
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(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:
|
if "inventory.read" in permissions:
|
||||||
|
|
@ -121,9 +89,6 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
||||||
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_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="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))),
|
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -164,27 +129,6 @@ def get_dashboard_summary(
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if "backup.read" in permissions:
|
|
||||||
backup_stats = BackupService.get_backup_stats()
|
|
||||||
repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
|
|
||||||
system_status.extend([
|
|
||||||
SystemStatusItem(
|
|
||||||
label="Letztes Backup",
|
|
||||||
value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
|
|
||||||
status="ok" if backup_stats.latest_backup_at else "warning",
|
|
||||||
),
|
|
||||||
SystemStatusItem(
|
|
||||||
label="Backup-Speicher",
|
|
||||||
value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
|
|
||||||
status="ok" if backup_stats.total_count else "warning",
|
|
||||||
),
|
|
||||||
SystemStatusItem(
|
|
||||||
label="Hermes-Version",
|
|
||||||
value=settings.app_version,
|
|
||||||
status="info",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||||
|
|
||||||
return DashboardSummary(
|
return DashboardSummary(
|
||||||
|
|
|
||||||
|
|
@ -81,18 +81,6 @@ def list_items(
|
||||||
return InventoryItemListResponse(items=items, total=total, 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)
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_item(
|
def create_item(
|
||||||
payload: InventoryItemCreate,
|
payload: InventoryItemCreate,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.core.rbac import require_any_permission, require_permission
|
from app.core.rbac import require_permission
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.repair import Repair
|
from app.models.repair import Repair
|
||||||
from app.models.repair_estimate import RepairEstimate
|
from app.models.repair_estimate import RepairEstimate
|
||||||
|
|
@ -117,26 +117,13 @@ def cancel_estimate(
|
||||||
estimate_id: int,
|
estimate_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])),
|
current_user: User = Depends(require_permission("repair_estimates.update")),
|
||||||
):
|
):
|
||||||
repair = get_repair_or_404(db, repair_id)
|
repair = get_repair_or_404(db, repair_id)
|
||||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
|
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])
|
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
|
||||||
def list_estimate_events(
|
def list_estimate_events(
|
||||||
repair_id: int,
|
repair_id: int,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class Settings(BaseSettings):
|
||||||
secret_key: str
|
secret_key: str
|
||||||
|
|
||||||
app_name: str = "Hermes API"
|
app_name: str = "Hermes API"
|
||||||
app_version: str = "0.9.1"
|
app_version: str = "0.1.0"
|
||||||
access_token_expire_minutes: int = 60
|
access_token_expire_minutes: int = 60
|
||||||
jwt_issuer: str = "hermes"
|
jwt_issuer: str = "hermes"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
@ -32,9 +32,6 @@ class Settings(BaseSettings):
|
||||||
smtp_from_email: str | None = None
|
smtp_from_email: str | None = None
|
||||||
smtp_from_name: str = "Funktechnik Schubert"
|
smtp_from_name: str = "Funktechnik Schubert"
|
||||||
smtp_use_tls: bool = True
|
smtp_use_tls: bool = True
|
||||||
lexware_enabled: bool = False
|
|
||||||
lexware_api_base_url: str = "https://api.lexware.io"
|
|
||||||
lexware_api_key: str | None = None
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,9 @@ import app.models.customer
|
||||||
import app.models.knowledge
|
import app.models.knowledge
|
||||||
import app.models.audit
|
import app.models.audit
|
||||||
import app.models.user
|
import app.models.user
|
||||||
import app.models.inventory
|
|
||||||
import app.models.repair
|
import app.models.repair
|
||||||
import app.models.repair_estimate
|
import app.models.repair_estimate
|
||||||
import app.models.system_setting
|
import app.models.system_setting
|
||||||
import app.models.lexware
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@ from starlette.requests import Request
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.audit import router as audit_router
|
from app.api.audit import router as audit_router
|
||||||
from app.api.backups import router as backups_router
|
|
||||||
from app.api.customers import router as customers_router
|
from app.api.customers import router as customers_router
|
||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
from app.api.inventory import router as inventory_router
|
from app.api.inventory import router as inventory_router
|
||||||
from app.api.knowledge import router as knowledge_router
|
from app.api.knowledge import router as knowledge_router
|
||||||
from app.api.lexware import router as lexware_router
|
|
||||||
from app.api.permissions import router as permissions_router
|
from app.api.permissions import router as permissions_router
|
||||||
from app.api.repairs import router as repairs_router
|
from app.api.repairs import router as repairs_router
|
||||||
from app.api.repair_estimates import router as repair_estimates_router
|
from app.api.repair_estimates import router as repair_estimates_router
|
||||||
|
|
@ -34,13 +32,12 @@ configure_logging()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Hermes API",
|
title="Hermes API",
|
||||||
version="0.9.1",
|
version="0.1.0",
|
||||||
description="Backend von Olympus",
|
description="Backend von Olympus",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(audit_router)
|
app.include_router(audit_router)
|
||||||
app.include_router(backups_router)
|
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(roles_router)
|
app.include_router(roles_router)
|
||||||
app.include_router(permissions_router)
|
app.include_router(permissions_router)
|
||||||
|
|
@ -51,7 +48,6 @@ app.include_router(repairs_router)
|
||||||
app.include_router(repair_estimates_router)
|
app.include_router(repair_estimates_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(system_settings_router)
|
app.include_router(system_settings_router)
|
||||||
app.include_router(lexware_router)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,7 @@ class InventoryStockMovement(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||||
actor = relationship(
|
actor = relationship("User", lazy="joined")
|
||||||
"User",
|
|
||||||
foreign_keys="InventoryStockMovement.actor_user_id",
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actor_username(self) -> str:
|
def actor_username(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -56,11 +56,7 @@ class RepairStatusHistory(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||||
actor = relationship(
|
actor = relationship("User", lazy="joined")
|
||||||
"User",
|
|
||||||
foreign_keys="RepairStatusHistory.actor_user_id",
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actor_username(self) -> str:
|
def actor_username(self) -> str:
|
||||||
|
|
@ -109,11 +105,7 @@ class RepairDocument(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=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")
|
repair: Mapped[Repair] = relationship(back_populates="documents")
|
||||||
uploaded_by = relationship(
|
uploaded_by = relationship("User", lazy="joined")
|
||||||
"User",
|
|
||||||
foreign_keys="RepairDocument.uploaded_by_user_id",
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uploaded_by_username(self) -> str:
|
def uploaded_by_username(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -27,29 +27,12 @@ class RepairEstimate(Base):
|
||||||
approved_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)
|
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
customer_response_message: Mapped[str | None] = mapped_column(Text, 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_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
repair = relationship("Repair", lazy="joined")
|
repair = relationship("Repair", lazy="joined")
|
||||||
created_by = relationship(
|
created_by = relationship("User", lazy="joined")
|
||||||
"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(
|
items: Mapped[list["RepairEstimateItem"]] = relationship(
|
||||||
back_populates="estimate",
|
back_populates="estimate",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
|
|
@ -69,11 +52,6 @@ class RepairEstimateItem(Base):
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=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)
|
position: Mapped[int] = mapped_column(Integer)
|
||||||
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
|
@ -86,7 +64,6 @@ class RepairEstimateItem(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=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")
|
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
||||||
inventory_item = relationship("InventoryItem", lazy="joined")
|
|
||||||
|
|
||||||
|
|
||||||
class RepairEstimateEvent(Base):
|
class RepairEstimateEvent(Base):
|
||||||
|
|
@ -101,8 +78,4 @@ class RepairEstimateEvent(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
|
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
|
||||||
actor = relationship(
|
actor = relationship("User", lazy="joined")
|
||||||
"User",
|
|
||||||
foreign_keys="RepairEstimateEvent.actor_user_id",
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ STANDARD_PERMISSIONS = [
|
||||||
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "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.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.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.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||||
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||||
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||||
|
|
@ -99,14 +98,6 @@ STANDARD_PERMISSIONS = [
|
||||||
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
|
||||||
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
|
||||||
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
|
||||||
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
|
||||||
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
|
||||||
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
|
||||||
("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
|
|
||||||
("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
|
|
||||||
("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
|
|
||||||
("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
|
|
||||||
("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROLE_PERMISSION_NAMES = {
|
ROLE_PERMISSION_NAMES = {
|
||||||
|
|
@ -128,7 +119,6 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repair_estimates.create",
|
"repair_estimates.create",
|
||||||
"repair_estimates.update",
|
"repair_estimates.update",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
"repair_estimates.revoke",
|
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
"inventory.create",
|
"inventory.create",
|
||||||
"inventory.update",
|
"inventory.update",
|
||||||
|
|
@ -136,12 +126,6 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"inventory.stock.reserve",
|
"inventory.stock.reserve",
|
||||||
"inventory.stock.consume",
|
"inventory.stock.consume",
|
||||||
"inventory.manage.masterdata",
|
"inventory.manage.masterdata",
|
||||||
"lexware.read",
|
|
||||||
"lexware.manage",
|
|
||||||
"lexware.export",
|
|
||||||
"backup.read",
|
|
||||||
"backup.create",
|
|
||||||
"backup.download",
|
|
||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
|
@ -191,7 +175,6 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repair_estimates.read",
|
"repair_estimates.read",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
"lexware.read",
|
|
||||||
},
|
},
|
||||||
"warehouse": {
|
"warehouse": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
|
|
||||||
|
|
@ -69,32 +69,6 @@ class InventoryRepository:
|
||||||
)
|
)
|
||||||
return items, total
|
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
|
@staticmethod
|
||||||
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||||
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
||||||
|
|
@ -202,22 +176,6 @@ class InventoryRepository:
|
||||||
.where(InventoryItem.purchase_price_cents.is_not(None))
|
.where(InventoryItem.purchase_price_cents.is_not(None))
|
||||||
) or 0
|
) 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
|
@staticmethod
|
||||||
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||||
return list(
|
return list(
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class RepairEstimateRepository:
|
||||||
select(RepairEstimate)
|
select(RepairEstimate)
|
||||||
.options(selectinload(RepairEstimate.items))
|
.options(selectinload(RepairEstimate.items))
|
||||||
.where(RepairEstimate.repair_id == repair_id)
|
.where(RepairEstimate.repair_id == repair_id)
|
||||||
.where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
|
.where(RepairEstimate.status.in_(["sent", "approved", "declined"]))
|
||||||
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -123,14 +123,3 @@ class RepairEstimateRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def count_declined(db: Session) -> int:
|
def count_declined(db: Session) -> int:
|
||||||
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -4,10 +4,10 @@ from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
|
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"]
|
||||||
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
||||||
EstimateActorType = Literal["user", "customer", "system"]
|
EstimateActorType = Literal["user", "customer", "system"]
|
||||||
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
|
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"]
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(value: object) -> str:
|
def normalize_text(value: object) -> str:
|
||||||
|
|
@ -18,9 +18,7 @@ def normalize_text(value: object) -> str:
|
||||||
|
|
||||||
class RepairEstimateItemPayload(BaseModel):
|
class RepairEstimateItemPayload(BaseModel):
|
||||||
item_type: EstimateItemType = "other"
|
item_type: EstimateItemType = "other"
|
||||||
inventory_item_id: int | None = None
|
title: str = Field(min_length=1, max_length=255)
|
||||||
inventory_price_overridden: bool = False
|
|
||||||
title: str = Field(default="", max_length=255)
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
quantity: Decimal = Field(gt=Decimal("0"))
|
quantity: Decimal = Field(gt=Decimal("0"))
|
||||||
unit: str = Field(default="Stk.", max_length=40)
|
unit: str = Field(default="Stk.", max_length=40)
|
||||||
|
|
@ -40,12 +38,6 @@ class RepairEstimateItemPayload(BaseModel):
|
||||||
return value.strip().replace(",", ".")
|
return value.strip().replace(",", ".")
|
||||||
return value
|
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):
|
class RepairEstimatePayload(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|
@ -93,11 +85,6 @@ class RepairEstimateUpdate(RepairEstimatePayload):
|
||||||
class RepairEstimateItemResponse(BaseModel):
|
class RepairEstimateItemResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
estimate_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
|
position: int
|
||||||
item_type: EstimateItemType
|
item_type: EstimateItemType
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -142,14 +129,6 @@ class RepairEstimateResponse(BaseModel):
|
||||||
approved_at: datetime | None
|
approved_at: datetime | None
|
||||||
declined_at: datetime | None
|
declined_at: datetime | None
|
||||||
customer_response_message: str | 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_by_user_id: int | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
@ -160,10 +139,6 @@ class RepairEstimateResponse(BaseModel):
|
||||||
|
|
||||||
class PublicEstimateItemResponse(BaseModel):
|
class PublicEstimateItemResponse(BaseModel):
|
||||||
item_type: EstimateItemType
|
item_type: EstimateItemType
|
||||||
inventory_snapshot_name: str
|
|
||||||
inventory_snapshot_sku: str
|
|
||||||
inventory_snapshot_manufacturer: str | None
|
|
||||||
inventory_snapshot_part_number: str | None
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
quantity: Decimal
|
quantity: Decimal
|
||||||
|
|
@ -175,7 +150,6 @@ class PublicEstimateItemResponse(BaseModel):
|
||||||
class PublicEstimateResponse(BaseModel):
|
class PublicEstimateResponse(BaseModel):
|
||||||
estimate_number: str
|
estimate_number: str
|
||||||
status: EstimateStatus
|
status: EstimateStatus
|
||||||
status_label: str
|
|
||||||
title: str
|
title: str
|
||||||
customer_message: str
|
customer_message: str
|
||||||
subtotal_cents: int
|
subtotal_cents: int
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
|
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
|
||||||
|
|
||||||
|
|
||||||
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
|
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
|
||||||
|
|
@ -179,7 +179,6 @@ def action_title(action: str) -> str:
|
||||||
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
|
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
|
||||||
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
||||||
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
||||||
"repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
|
|
||||||
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
||||||
"inventory.items.create": "Lagerartikel erstellt",
|
"inventory.items.create": "Lagerartikel erstellt",
|
||||||
"inventory.items.update": "Lagerartikel geändert",
|
"inventory.items.update": "Lagerartikel geändert",
|
||||||
|
|
@ -189,11 +188,6 @@ def action_title(action: str) -> str:
|
||||||
"inventory.stock.reserve": "Bestand reserviert",
|
"inventory.stock.reserve": "Bestand reserviert",
|
||||||
"inventory.stock.release": "Reservierung aufgehoben",
|
"inventory.stock.release": "Reservierung aufgehoben",
|
||||||
"inventory.stock.consume": "Bestand verbraucht",
|
"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.create": "Lagerkategorie erstellt",
|
||||||
"inventory.categories.update": "Lagerkategorie geändert",
|
"inventory.categories.update": "Lagerkategorie geändert",
|
||||||
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
||||||
|
|
@ -207,21 +201,6 @@ def action_title(action: str) -> str:
|
||||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||||
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
|
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
|
||||||
"lexware.settings.update": "Lexware Einstellungen geändert",
|
|
||||||
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
|
|
||||||
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
|
|
||||||
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
|
|
||||||
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
|
|
||||||
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
|
|
||||||
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
|
|
||||||
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
|
|
||||||
"backups.create": "Backup erstellt",
|
|
||||||
"backups.download": "Backup heruntergeladen",
|
|
||||||
"backups.delete": "Backup gelöscht",
|
|
||||||
"backups.validate": "Backup validiert",
|
|
||||||
"backups.restore_started": "Restore gestartet",
|
|
||||||
"backups.restore_failed": "Restore fehlgeschlagen",
|
|
||||||
"backups.restore_completed": "Restore abgeschlossen",
|
|
||||||
}
|
}
|
||||||
return labels.get(action, action)
|
return labels.get(action, action)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +1,5 @@
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from decimal import Decimal
|
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
@ -9,7 +8,6 @@ from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||||
from app.models.repair_estimate import RepairEstimate
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.inventory_repository import InventoryRepository
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
from app.schemas.inventory import (
|
from app.schemas.inventory import (
|
||||||
|
|
@ -196,89 +194,6 @@ class InventoryService:
|
||||||
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
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)
|
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
|
@staticmethod
|
||||||
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||||
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||||
|
|
@ -345,18 +260,6 @@ class InventoryService:
|
||||||
if payload.quantity <= 0:
|
if payload.quantity <= 0:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
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
|
@staticmethod
|
||||||
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||||
InventoryService._recalculate_available(item)
|
InventoryService._recalculate_available(item)
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -10,7 +10,6 @@ from starlette.requests import Request
|
||||||
from app.models.repair import Repair
|
from app.models.repair import Repair
|
||||||
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
||||||
from app.models.user import User
|
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_estimate_repository import RepairEstimateRepository
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
from app.schemas.repair import RepairStatusUpdate
|
from app.schemas.repair import RepairStatusUpdate
|
||||||
|
|
@ -25,7 +24,6 @@ from app.schemas.repair_estimate import (
|
||||||
calculate_tax,
|
calculate_tax,
|
||||||
)
|
)
|
||||||
from app.services.audit_service import write_audit_log
|
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.repair_public_link_service import RepairPublicLinkService
|
||||||
from app.services.system_settings_service import SystemSettingsService
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
|
|
@ -55,21 +53,9 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
||||||
"approved_at": estimate.approved_at,
|
"approved_at": estimate.approved_at,
|
||||||
"declined_at": estimate.declined_at,
|
"declined_at": estimate.declined_at,
|
||||||
"created_by_user_id": estimate.created_by_user_id,
|
"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:
|
class RepairEstimateService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
|
@ -80,8 +66,7 @@ class RepairEstimateService:
|
||||||
status="draft",
|
status="draft",
|
||||||
created_by_user_id=actor.id,
|
created_by_user_id=actor.id,
|
||||||
)
|
)
|
||||||
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
RepairEstimateService._apply_payload(estimate, payload)
|
||||||
RepairEstimateService._apply_payload(db, estimate, payload)
|
|
||||||
try:
|
try:
|
||||||
db.add(estimate)
|
db.add(estimate)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
@ -107,7 +92,6 @@ class RepairEstimateService:
|
||||||
after_data=_audit_estimate_data(estimate),
|
after_data=_audit_estimate_data(estimate),
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
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
|
return estimate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -115,24 +99,7 @@ class RepairEstimateService:
|
||||||
if estimate.status not in {"draft", "sent"}:
|
if estimate.status not in {"draft", "sent"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
||||||
before_data = _audit_estimate_data(estimate)
|
before_data = _audit_estimate_data(estimate)
|
||||||
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
RepairEstimateService._apply_payload(estimate, payload)
|
||||||
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)
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(estimate)
|
db.refresh(estimate)
|
||||||
|
|
@ -149,27 +116,14 @@ class RepairEstimateService:
|
||||||
after_data=_audit_estimate_data(updated),
|
after_data=_audit_estimate_data(updated),
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
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
|
return updated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||||
if estimate.status not in {"draft", "sent", "cancelled"}:
|
if estimate.status not in {"draft", "cancelled"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden")
|
||||||
before_data = _audit_estimate_data(estimate)
|
before_data = _audit_estimate_data(estimate)
|
||||||
label = _estimate_label(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)
|
RepairEstimateRepository.delete(db, estimate)
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
|
|
@ -191,10 +145,6 @@ class RepairEstimateService:
|
||||||
if not repair.customer_email:
|
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")
|
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(
|
link = RepairPublicLinkService.create_with_audit(
|
||||||
db,
|
db,
|
||||||
repair,
|
repair,
|
||||||
|
|
@ -205,18 +155,6 @@ class RepairEstimateService:
|
||||||
public_status_url = link.public_status_path
|
public_status_url = link.public_status_path
|
||||||
estimate = RepairEstimateRepository.mark_sent(db, estimate)
|
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)
|
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)
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
mail_sent = False
|
mail_sent = False
|
||||||
if smtp_config.is_configured:
|
if smtp_config.is_configured:
|
||||||
|
|
@ -260,11 +198,8 @@ class RepairEstimateService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
|
if estimate.status in {"approved", "declined", "cancelled"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
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"
|
estimate.status = "cancelled"
|
||||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -279,111 +214,8 @@ class RepairEstimateService:
|
||||||
request=request,
|
request=request,
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
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
|
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
|
@staticmethod
|
||||||
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
||||||
if estimate is None:
|
if estimate is None:
|
||||||
|
|
@ -391,7 +223,6 @@ class RepairEstimateService:
|
||||||
return PublicEstimateResponse(
|
return PublicEstimateResponse(
|
||||||
estimate_number=estimate.estimate_number,
|
estimate_number=estimate.estimate_number,
|
||||||
status=estimate.status,
|
status=estimate.status,
|
||||||
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
|
|
||||||
title=estimate.title,
|
title=estimate.title,
|
||||||
customer_message=estimate.customer_message,
|
customer_message=estimate.customer_message,
|
||||||
subtotal_cents=estimate.subtotal_cents,
|
subtotal_cents=estimate.subtotal_cents,
|
||||||
|
|
@ -402,10 +233,6 @@ class RepairEstimateService:
|
||||||
items=[
|
items=[
|
||||||
PublicEstimateItemResponse(
|
PublicEstimateItemResponse(
|
||||||
item_type=item.item_type,
|
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,
|
title=item.title,
|
||||||
description=item.description,
|
description=item.description,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
|
|
@ -445,7 +272,6 @@ class RepairEstimateService:
|
||||||
repair_status = "waiting_for_customer"
|
repair_status = "waiting_for_customer"
|
||||||
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
||||||
audit_action = "repair_estimates.decline"
|
audit_action = "repair_estimates.decline"
|
||||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
|
|
||||||
else:
|
else:
|
||||||
event_type = "question"
|
event_type = "question"
|
||||||
repair_status = "waiting_for_customer"
|
repair_status = "waiting_for_customer"
|
||||||
|
|
@ -465,27 +291,17 @@ class RepairEstimateService:
|
||||||
request=request,
|
request=request,
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
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
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
||||||
estimate.title = payload.title
|
estimate.title = payload.title
|
||||||
estimate.customer_message = payload.customer_message
|
estimate.customer_message = payload.customer_message
|
||||||
estimate.internal_note = payload.internal_note
|
estimate.internal_note = payload.internal_note
|
||||||
estimate.tax_rate_percent = payload.tax_rate_percent
|
estimate.tax_rate_percent = payload.tax_rate_percent
|
||||||
estimate.currency = payload.currency
|
estimate.currency = payload.currency
|
||||||
estimate.valid_until = payload.valid_until
|
estimate.valid_until = payload.valid_until
|
||||||
items, subtotal = RepairEstimateService._build_items(db, payload.items)
|
items, subtotal = RepairEstimateService._build_items(payload.items)
|
||||||
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
||||||
estimate.subtotal_cents = subtotal
|
estimate.subtotal_cents = subtotal
|
||||||
estimate.tax_cents = tax_cents
|
estimate.tax_cents = tax_cents
|
||||||
|
|
@ -493,121 +309,26 @@ class RepairEstimateService:
|
||||||
estimate.items = items
|
estimate.items = items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_items(
|
def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]:
|
||||||
db: Session,
|
|
||||||
payload_items: list[RepairEstimateItemPayload],
|
|
||||||
) -> tuple[list[RepairEstimateItem], int]:
|
|
||||||
items: list[RepairEstimateItem] = []
|
items: list[RepairEstimateItem] = []
|
||||||
subtotal = 0
|
subtotal = 0
|
||||||
for index, payload in enumerate(payload_items, start=1):
|
for index, payload in enumerate(payload_items, start=1):
|
||||||
item_type = payload.item_type
|
total = calculate_item_total(payload.quantity, payload.unit_price_cents)
|
||||||
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
|
subtotal += total
|
||||||
items.append(
|
items.append(
|
||||||
RepairEstimateItem(
|
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,
|
position=index,
|
||||||
item_type=item_type,
|
item_type=payload.item_type,
|
||||||
title=title,
|
title=payload.title,
|
||||||
description=description,
|
description=payload.description,
|
||||||
quantity=payload.quantity,
|
quantity=payload.quantity,
|
||||||
unit=unit,
|
unit=payload.unit,
|
||||||
unit_price_cents=unit_price_cents,
|
unit_price_cents=payload.unit_price_cents,
|
||||||
total_cents=total,
|
total_cents=total,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items, subtotal
|
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
|
@staticmethod
|
||||||
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
return (
|
return (
|
||||||
|
|
@ -633,30 +354,4 @@ class RepairEstimateService:
|
||||||
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
||||||
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
|
<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>
|
<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>"""
|
</td></tr></table></body></html>"""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
APP_NAME: Hermes API
|
APP_NAME: Hermes API
|
||||||
APP_VERSION: 0.9.1
|
APP_VERSION: 0.1.0
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||||
|
|
@ -22,9 +22,6 @@ services:
|
||||||
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
||||||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
||||||
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
|
|
||||||
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
|
|
||||||
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@ FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
APP_NAME: Hermes API
|
APP_NAME: Hermes API
|
||||||
APP_VERSION: 0.9.1
|
APP_VERSION: 0.1.0
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||||
|
|
@ -35,9 +35,6 @@ services:
|
||||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
|
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
|
||||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||||
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
|
|
||||||
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
|
|
||||||
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||||
|
|
|
||||||
|
|
@ -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,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,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,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}/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,415 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
|
||||||
import SummaryCard from "@/components/common/SummaryCard";
|
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { hasPermission } from "@/lib/permissions";
|
|
||||||
import type { ApiSuccess } from "@/types/audit";
|
|
||||||
import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup";
|
|
||||||
import type { CurrentUser } from "@/types/rbac";
|
|
||||||
|
|
||||||
const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO";
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
|
||||||
if (typeof error === "object" && error !== null && "response" in error) {
|
|
||||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
|
||||||
return response?.data?.detail ?? response?.data?.message ?? fallback;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
|
||||||
if (bytes < 1024) {
|
|
||||||
return `${bytes} B`;
|
|
||||||
}
|
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
|
||||||
let value = bytes / 1024;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
value /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "Unbekannt";
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BackupsPage() {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [data, setData] = useState<BackupListResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<BackupSummary | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [validationByFile, setValidationByFile] = useState<Record<string, BackupValidationResponse>>({});
|
|
||||||
const [validatingFile, setValidatingFile] = useState("");
|
|
||||||
const [restoreTarget, setRestoreTarget] = useState<BackupSummary | null>(null);
|
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState("");
|
|
||||||
const [restoring, setRestoring] = useState(false);
|
|
||||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
|
||||||
|
|
||||||
const loadBackups = useCallback(async () => {
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const response = await api.get<ApiSuccess<BackupListResponse>>("/backups");
|
|
||||||
setData(response.data.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(getErrorMessage(err, "Backups konnten nicht geladen werden."));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void loadBackups();
|
|
||||||
});
|
|
||||||
}, [loadBackups]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get<CurrentUser>("/me");
|
|
||||||
setCurrentUser(response.data);
|
|
||||||
} catch {
|
|
||||||
setCurrentUser(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cards = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ label: "Backups", value: data.total_count },
|
|
||||||
{ label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) },
|
|
||||||
{ label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" },
|
|
||||||
];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const canCreate = hasPermission(currentUser, "backup.create");
|
|
||||||
const canDownload = hasPermission(currentUser, "backup.download");
|
|
||||||
const canDelete = hasPermission(currentUser, "backup.delete");
|
|
||||||
const canRestore = hasPermission(currentUser, "backup.restore");
|
|
||||||
|
|
||||||
async function createBackup() {
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
await api.post<ApiSuccess<BackupSummary>>("/backups/create");
|
|
||||||
await loadBackups();
|
|
||||||
showToast({ type: "success", title: "Backup erstellt" });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "error",
|
|
||||||
title: "Backup konnte nicht erstellt werden",
|
|
||||||
description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateBackup(filename: string) {
|
|
||||||
setValidatingFile(filename);
|
|
||||||
try {
|
|
||||||
const response = await api.post<ApiSuccess<BackupValidationResponse>>(`/backups/${encodeURIComponent(filename)}/validate`);
|
|
||||||
setValidationByFile((current) => ({ ...current, [filename]: response.data.data }));
|
|
||||||
showToast({
|
|
||||||
type: response.data.data.valid ? "success" : "error",
|
|
||||||
title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen",
|
|
||||||
description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "error",
|
|
||||||
title: "Backup konnte nicht validiert werden",
|
|
||||||
description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setValidatingFile("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteBackup() {
|
|
||||||
if (!deleteTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
await loadBackups();
|
|
||||||
showToast({ type: "success", title: "Backup geloescht" });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "error",
|
|
||||||
title: "Backup konnte nicht geloescht werden",
|
|
||||||
description: getErrorMessage(err, "Bitte versuche es erneut."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreBackup() {
|
|
||||||
if (!restoreTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRestoring(true);
|
|
||||||
try {
|
|
||||||
await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, {
|
|
||||||
confirm_text: restoreConfirm,
|
|
||||||
});
|
|
||||||
showToast({ type: "success", title: "Restore abgeschlossen" });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "info",
|
|
||||||
title: "CLI-Restore erforderlich",
|
|
||||||
description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setRestoring(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Backups werden geladen...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Backups verfuegbar"}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-950">Backups</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => void loadBackups()}>
|
|
||||||
<RefreshCcw />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
{canCreate && (
|
|
||||||
<Button type="button" onClick={() => void createBackup()} disabled={creating}>
|
|
||||||
<Archive />
|
|
||||||
{creating ? "Backup wird erstellt..." : "Backup erstellen"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{cards.map((card) => (
|
|
||||||
<SummaryCard key={card.label} label={card.label} value={card.value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="rounded-lg border bg-white">
|
|
||||||
<div className="border-b px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-950">Verfuegbare Backup-Dateien</h2>
|
|
||||||
</div>
|
|
||||||
{data.items.length === 0 ? (
|
|
||||||
<div className="px-6 py-10 text-sm text-slate-500">Noch keine Backup-Dateien vorhanden.</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{data.items.map((backup) => {
|
|
||||||
const validation = validationByFile[backup.filename];
|
|
||||||
return (
|
|
||||||
<div key={backup.filename} className="px-6 py-5">
|
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<p className="truncate font-semibold text-slate-950">{backup.filename}</p>
|
|
||||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
backup.validation_status === "valid"
|
|
||||||
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
|
|
||||||
: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20"
|
|
||||||
}`}>
|
|
||||||
{backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Erstellt</p>
|
|
||||||
<p>{formatDate(backup.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Groesse</p>
|
|
||||||
<p>{formatBytes(backup.size_bytes)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Version</p>
|
|
||||||
<p>{backup.app_version || "Unbekannt"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Ersteller</p>
|
|
||||||
<p>{backup.created_by_username || "System"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Datenbank</p>
|
|
||||||
<p>{backup.database_name || "Unbekannt"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Dateien</p>
|
|
||||||
<p>{backup.file_count}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Storage-Pfad</p>
|
|
||||||
<p className="break-all">{backup.storage_base_path || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{validation && (
|
|
||||||
<div className={`mt-4 rounded-lg border p-4 text-sm ${
|
|
||||||
validation.valid
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-950"
|
|
||||||
: "border-amber-200 bg-amber-50 text-amber-950"
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{validation.valid ? <RotateCcw className="mt-0.5 h-4 w-4" /> : <AlertTriangle className="mt-0.5 h-4 w-4" />}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-medium">{validation.message}</p>
|
|
||||||
<p>Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}</p>
|
|
||||||
{!validation.valid && validation.issues.length > 0 && (
|
|
||||||
<p>{validation.issues.join(" | ")}</p>
|
|
||||||
)}
|
|
||||||
{validation.requires_cli_restore && (
|
|
||||||
<p>Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
|
||||||
{canDownload && (
|
|
||||||
<a
|
|
||||||
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
|
|
||||||
className={buttonVariants({ variant: "outline" })}
|
|
||||||
>
|
|
||||||
<Download />
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{canRestore && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void validateBackup(backup.filename)}
|
|
||||||
disabled={validatingFile === backup.filename}
|
|
||||||
>
|
|
||||||
<ShieldAlert />
|
|
||||||
{validatingFile === backup.filename ? "Prueft..." : "Validieren"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setRestoreTarget(backup);
|
|
||||||
setRestoreConfirm("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCcw />
|
|
||||||
Restore vorbereiten
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{canDelete && (
|
|
||||||
<Button type="button" variant="destructive" onClick={() => setDeleteTarget(backup)}>
|
|
||||||
<Trash2 />
|
|
||||||
Loeschen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={deleteTarget !== null}
|
|
||||||
title="Backup loeschen"
|
|
||||||
description="Die Backup-Datei wird dauerhaft entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden."
|
|
||||||
confirmLabel="Backup loeschen"
|
|
||||||
pending={deleting}
|
|
||||||
pendingLabel="Backup wird geloescht..."
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setDeleteTarget(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onConfirm={() => void deleteBackup()}
|
|
||||||
>
|
|
||||||
{deleteTarget && (
|
|
||||||
<div className="space-y-1 text-sm text-slate-700">
|
|
||||||
<p className="font-medium">{deleteTarget.filename}</p>
|
|
||||||
<p>{formatBytes(deleteTarget.size_bytes)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={restoreTarget !== null}
|
|
||||||
title="Restore vorbereiten"
|
|
||||||
description="Restore ist ein Hochrisiko-Vorgang. Bitte bestaetige den Text exakt, bevor Olympus den CLI-Restore vorbereitet."
|
|
||||||
confirmLabel="Restore starten"
|
|
||||||
pending={restoring}
|
|
||||||
pendingLabel="Restore wird vorbereitet..."
|
|
||||||
confirmDisabled={restoreConfirm !== RESTORE_CONFIRM_TEXT}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRestoreTarget(null);
|
|
||||||
setRestoreConfirm("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onConfirm={() => void restoreBackup()}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
||||||
<p>Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-medium text-slate-700">Bestaetigungstext</p>
|
|
||||||
<Input
|
|
||||||
value={restoreConfirm}
|
|
||||||
onChange={(event) => setRestoreConfirm(event.target.value)}
|
|
||||||
placeholder={RESTORE_CONFIRM_TEXT}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{restoreTarget && (
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
Ziel-Backup: <span className="font-medium text-slate-900">{restoreTarget.filename}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -175,8 +175,6 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
|
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
|
||||||
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
|
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
|
||||||
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
|
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
|
||||||
const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke");
|
|
||||||
const canLexwareExport = hasPermission(currentUser, "lexware.export");
|
|
||||||
|
|
||||||
async function createPublicLink() {
|
async function createPublicLink() {
|
||||||
if (!repair || !canManagePublicLink) return;
|
if (!repair || !canManagePublicLink) return;
|
||||||
|
|
@ -444,8 +442,6 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
canUpdate={canUpdateEstimates}
|
canUpdate={canUpdateEstimates}
|
||||||
canDelete={canDeleteEstimates}
|
canDelete={canDeleteEstimates}
|
||||||
canSend={canSendEstimates}
|
canSend={canSendEstimates}
|
||||||
canRevoke={canRevokeEstimates}
|
|
||||||
canLexwareExport={canLexwareExport}
|
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,12 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react";
|
import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
|
||||||
LexwareSettings,
|
|
||||||
LexwareSettingsPayload,
|
|
||||||
LexwareTestConnectionResponse,
|
|
||||||
} from "@/types/lexware";
|
|
||||||
import type {
|
import type {
|
||||||
PublicLinksSettings,
|
PublicLinksSettings,
|
||||||
SettingsSource,
|
SettingsSource,
|
||||||
|
|
@ -21,7 +16,7 @@ import type {
|
||||||
SmtpTestResponse,
|
SmtpTestResponse,
|
||||||
} from "@/types/system-settings";
|
} from "@/types/system-settings";
|
||||||
|
|
||||||
type SettingsTab = "smtp" | "public-links" | "lexware";
|
type SettingsTab = "smtp" | "public-links";
|
||||||
|
|
||||||
const sourceLabels: Record<SettingsSource, string> = {
|
const sourceLabels: Record<SettingsSource, string> = {
|
||||||
database: "Admin-Konfiguration",
|
database: "Admin-Konfiguration",
|
||||||
|
|
@ -51,18 +46,6 @@ function emptySmtpSettings(): SmtpSettings {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyLexwareSettings(): LexwareSettings {
|
|
||||||
return {
|
|
||||||
enabled: false,
|
|
||||||
api_base_url: "https://api.lexware.io",
|
|
||||||
api_key_is_set: false,
|
|
||||||
organization_name: "",
|
|
||||||
default_tax_rate: "19.00",
|
|
||||||
default_payment_terms_days: 14,
|
|
||||||
source: "missing",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
|
||||||
|
|
@ -73,28 +56,22 @@ export default function SettingsPage() {
|
||||||
repair_status_base_url: "",
|
repair_status_base_url: "",
|
||||||
source: "missing",
|
source: "missing",
|
||||||
});
|
});
|
||||||
const [lexware, setLexware] = useState<LexwareSettings>(emptyLexwareSettings());
|
|
||||||
const [newLexwareApiKey, setNewLexwareApiKey] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingSmtp, setSavingSmtp] = useState(false);
|
const [savingSmtp, setSavingSmtp] = useState(false);
|
||||||
const [sendingTest, setSendingTest] = useState(false);
|
const [sendingTest, setSendingTest] = useState(false);
|
||||||
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
|
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
|
||||||
const [savingLexware, setSavingLexware] = useState(false);
|
|
||||||
const [testingLexware, setTestingLexware] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([
|
const [smtpResponse, publicLinksResponse] = await Promise.all([
|
||||||
api.get<SmtpSettings>("/system-settings/smtp"),
|
api.get<SmtpSettings>("/system-settings/smtp"),
|
||||||
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
||||||
api.get<LexwareSettings>("/lexware/settings"),
|
|
||||||
]);
|
]);
|
||||||
setSmtp(smtpResponse.data);
|
setSmtp(smtpResponse.data);
|
||||||
setPublicLinks(publicLinksResponse.data);
|
setPublicLinks(publicLinksResponse.data);
|
||||||
setLexware(lexwareResponse.data);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
|
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -181,56 +158,6 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLexware() {
|
|
||||||
setSavingLexware(true);
|
|
||||||
try {
|
|
||||||
const payload: LexwareSettingsPayload = {
|
|
||||||
enabled: lexware.enabled,
|
|
||||||
api_base_url: lexware.api_base_url,
|
|
||||||
organization_name: lexware.organization_name,
|
|
||||||
default_tax_rate: lexware.default_tax_rate,
|
|
||||||
default_payment_terms_days: lexware.default_payment_terms_days,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (newLexwareApiKey) {
|
|
||||||
payload.api_key = newLexwareApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.put<LexwareSettings>("/lexware/settings", payload);
|
|
||||||
setLexware(response.data);
|
|
||||||
setNewLexwareApiKey("");
|
|
||||||
showToast({ type: "success", title: "Lexware-Konfiguration gespeichert" });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "error",
|
|
||||||
title: "Lexware konnte nicht gespeichert werden",
|
|
||||||
description: getErrorMessage(err, "Bitte prüfe die Lexware-Einstellungen."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSavingLexware(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testLexwareConnection() {
|
|
||||||
setTestingLexware(true);
|
|
||||||
try {
|
|
||||||
const response = await api.post<LexwareTestConnectionResponse>("/lexware/test-connection");
|
|
||||||
showToast({
|
|
||||||
type: response.data.success ? "success" : "error",
|
|
||||||
title: response.data.success ? "Lexware-Verbindung erfolgreich" : "Lexware-Verbindung fehlgeschlagen",
|
|
||||||
description: response.data.message,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
type: "error",
|
|
||||||
title: "Lexware-Verbindung fehlgeschlagen",
|
|
||||||
description: getErrorMessage(err, "Die Verbindung konnte nicht geprüft werden."),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTestingLexware(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +171,7 @@ export default function SettingsPage() {
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten</p>
|
<p className="mt-1 text-sm text-slate-500">SMTP-Versand und öffentliche Statuslinks verwalten</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
||||||
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
||||||
|
|
@ -253,9 +180,6 @@ export default function SettingsPage() {
|
||||||
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
||||||
Öffentliche Links
|
Öffentliche Links
|
||||||
</TabButton>
|
</TabButton>
|
||||||
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
|
|
||||||
Lexware Office
|
|
||||||
</TabButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -363,7 +287,7 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : activeTab === "public-links" ? (
|
) : (
|
||||||
<section className="rounded-lg border bg-white p-6">
|
<section className="rounded-lg border bg-white p-6">
|
||||||
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -395,86 +319,6 @@ export default function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
|
||||||
<section className="rounded-lg border bg-white p-6">
|
|
||||||
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ReceiptText className="h-5 w-5 text-slate-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-slate-950">Lexware Office</h2>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Quelle: {sourceLabels[lexware.source]}
|
|
||||||
{lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => void testLexwareConnection()} disabled={testingLexware}>
|
|
||||||
<ShieldCheck />
|
|
||||||
{testingLexware ? "Prüft..." : "Verbindung testen"}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={() => void saveLexware()} disabled={savingLexware}>
|
|
||||||
<Save />
|
|
||||||
{savingLexware ? "Speichert..." : "Speichern"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
|
||||||
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-slate-300"
|
|
||||||
checked={lexware.enabled}
|
|
||||||
onChange={(event) => setLexware((current) => ({ ...current, enabled: event.target.checked }))}
|
|
||||||
/>
|
|
||||||
Lexware-Integration aktivieren
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Field label="Organisation">
|
|
||||||
<Input value={lexware.organization_name} onChange={(event) => setLexware((current) => ({ ...current, organization_name: event.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="API Base URL">
|
|
||||||
<Input value={lexware.api_base_url} onChange={(event) => setLexware((current) => ({ ...current, api_base_url: event.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={lexware.api_key_is_set ? "Neuen API-Key setzen" : "API-Key"}>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={newLexwareApiKey}
|
|
||||||
placeholder={lexware.api_key_is_set ? "Leer lassen, um aktuellen API-Key zu behalten" : ""}
|
|
||||||
onChange={(event) => setNewLexwareApiKey(event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Standard MwSt. %">
|
|
||||||
<Input
|
|
||||||
inputMode="decimal"
|
|
||||||
value={lexware.default_tax_rate}
|
|
||||||
onChange={(event) => setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Zahlungsziel Tage">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={365}
|
|
||||||
value={lexware.default_payment_terms_days}
|
|
||||||
onChange={(event) => setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex items-start gap-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
|
||||||
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
Olympus bereitet Werkstatt- und KV-Daten vor. Lexware Office bleibt führend für Buchhaltung,
|
|
||||||
Rechnungen, Steuer und DATEV/EÜR. Der API-Key wird nicht angezeigt und nicht an den Browser zurückgegeben.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
HardDriveDownload,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -79,12 +78,6 @@ const menu = [
|
||||||
name: "Dokumente",
|
name: "Dokumente",
|
||||||
href: "/documents",
|
href: "/documents",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: HardDriveDownload,
|
|
||||||
name: "Backups",
|
|
||||||
href: "/backups",
|
|
||||||
permission: "backup.read",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
name: "Einstellungen",
|
name: "Einstellungen",
|
||||||
|
|
@ -142,7 +135,7 @@ export default function Sidebar() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
||||||
Olympus CRM v0.9.1
|
Olympus CRM v0.1
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ type Props = {
|
||||||
description: string;
|
description: string;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
pendingLabel?: string;
|
|
||||||
confirmDisabled?: boolean;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
|
@ -31,8 +29,6 @@ export default function ConfirmDialog({
|
||||||
description,
|
description,
|
||||||
confirmLabel = "Löschen",
|
confirmLabel = "Löschen",
|
||||||
pending = false,
|
pending = false,
|
||||||
pendingLabel = "Wird gelöscht...",
|
|
||||||
confirmDisabled = false,
|
|
||||||
children,
|
children,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
|
@ -60,9 +56,9 @@ export default function ConfirmDialog({
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={pending || confirmDisabled}
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{pending ? pendingLabel : confirmLabel}
|
{pending ? "Wird gelöscht..." : confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
|
@ -17,11 +17,6 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
|
||||||
InventoryCategory,
|
|
||||||
InventoryItem,
|
|
||||||
} from "@/types/inventory";
|
|
||||||
import type { LexwareInvoicePreparation } from "@/types/lexware";
|
|
||||||
import type {
|
import type {
|
||||||
RepairEstimate,
|
RepairEstimate,
|
||||||
RepairEstimateItemType,
|
RepairEstimateItemType,
|
||||||
|
|
@ -43,21 +38,6 @@ const statusLabels: Record<string, string> = {
|
||||||
declined: "Abgelehnt",
|
declined: "Abgelehnt",
|
||||||
expired: "Abgelaufen",
|
expired: "Abgelaufen",
|
||||||
cancelled: "Storniert",
|
cancelled: "Storniert",
|
||||||
revoked: "Zurückgenommen",
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountingStatusLabels: Record<string, string> = {
|
|
||||||
prepared: "Vorbereitet",
|
|
||||||
transferred: "Übertragen",
|
|
||||||
booked: "Gebucht",
|
|
||||||
cancelled: "Storniert",
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountingStatusClasses: Record<string, string> = {
|
|
||||||
prepared: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
|
||||||
transferred: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
|
||||||
booked: "bg-slate-900 text-white ring-slate-900/20",
|
|
||||||
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function humanizeValidationDetail(detail: unknown): string | null {
|
function humanizeValidationDetail(detail: unknown): string | null {
|
||||||
|
|
@ -109,12 +89,6 @@ function dateTime(value: string | null) {
|
||||||
|
|
||||||
type EstimateFormItem = {
|
type EstimateFormItem = {
|
||||||
item_type: RepairEstimateItemType;
|
item_type: RepairEstimateItemType;
|
||||||
inventory_item_id: number | null;
|
|
||||||
inventory_snapshot_name: string;
|
|
||||||
inventory_snapshot_sku: string;
|
|
||||||
inventory_snapshot_manufacturer: string | null;
|
|
||||||
inventory_snapshot_part_number: string | null;
|
|
||||||
inventory_price_overridden: boolean;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
|
|
@ -144,12 +118,6 @@ function centsToEuroInput(cents: number): string {
|
||||||
function emptyItem(): EstimateFormItem {
|
function emptyItem(): EstimateFormItem {
|
||||||
return {
|
return {
|
||||||
item_type: "labor",
|
item_type: "labor",
|
||||||
inventory_item_id: null,
|
|
||||||
inventory_snapshot_name: "",
|
|
||||||
inventory_snapshot_sku: "",
|
|
||||||
inventory_snapshot_manufacturer: null,
|
|
||||||
inventory_snapshot_part_number: null,
|
|
||||||
inventory_price_overridden: false,
|
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
quantity: "1.00",
|
quantity: "1.00",
|
||||||
|
|
@ -178,8 +146,6 @@ type Props = {
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
canRevoke: boolean;
|
|
||||||
canLexwareExport: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RepairEstimatesSection({
|
export default function RepairEstimatesSection({
|
||||||
|
|
@ -190,8 +156,6 @@ export default function RepairEstimatesSection({
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canSend,
|
canSend,
|
||||||
canRevoke,
|
|
||||||
canLexwareExport,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
||||||
|
|
@ -203,20 +167,6 @@ export default function RepairEstimatesSection({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||||
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
|
|
||||||
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
|
||||||
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
|
|
||||||
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
|
|
||||||
const [accountingTarget, setAccountingTarget] = useState<RepairEstimate | null>(null);
|
|
||||||
const [accountingNote, setAccountingNote] = useState("");
|
|
||||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
|
||||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
|
||||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
|
||||||
const [inventorySearch, setInventorySearch] = useState("");
|
|
||||||
const [inventoryCategory, setInventoryCategory] = useState("all");
|
|
||||||
const [inventoryManufacturer, setInventoryManufacturer] = useState("");
|
|
||||||
const [inventoryLoading, setInventoryLoading] = useState(false);
|
|
||||||
const [inventoryError, setInventoryError] = useState("");
|
|
||||||
|
|
||||||
const loadEstimates = useCallback(async () => {
|
const loadEstimates = useCallback(async () => {
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
|
|
@ -241,38 +191,6 @@ export default function RepairEstimatesSection({
|
||||||
});
|
});
|
||||||
}, [loadEstimates]);
|
}, [loadEstimates]);
|
||||||
|
|
||||||
const searchInventoryItems = useCallback(async () => {
|
|
||||||
setInventoryLoading(true);
|
|
||||||
setInventoryError("");
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("limit", "20");
|
|
||||||
if (inventorySearch.trim()) params.set("q", inventorySearch.trim());
|
|
||||||
if (inventoryCategory !== "all") params.set("category", inventoryCategory);
|
|
||||||
if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim());
|
|
||||||
const [itemsResponse, categoriesResponse] = await Promise.all([
|
|
||||||
api.get<InventoryItem[]>(`/inventory/items/search?${params.toString()}`),
|
|
||||||
api.get<InventoryCategory[]>("/inventory/categories"),
|
|
||||||
]);
|
|
||||||
setInventoryItems(itemsResponse.data);
|
|
||||||
setInventoryCategories(categoriesResponse.data);
|
|
||||||
} catch (err) {
|
|
||||||
setInventoryError(getErrorMessage(err));
|
|
||||||
} finally {
|
|
||||||
setInventoryLoading(false);
|
|
||||||
}
|
|
||||||
}, [inventoryCategory, inventoryManufacturer, inventorySearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inventoryDialogOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = window.setTimeout(() => {
|
|
||||||
void searchInventoryItems();
|
|
||||||
}, 200);
|
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}, [inventoryDialogOpen, searchInventoryItems]);
|
|
||||||
|
|
||||||
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
||||||
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
||||||
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
||||||
|
|
@ -297,12 +215,6 @@ export default function RepairEstimatesSection({
|
||||||
valid_until: estimate.valid_until,
|
valid_until: estimate.valid_until,
|
||||||
items: estimate.items.map((item) => ({
|
items: estimate.items.map((item) => ({
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
inventory_item_id: item.inventory_item_id ?? null,
|
|
||||||
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,
|
|
||||||
inventory_price_overridden: false,
|
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
quantity: String(item.quantity),
|
quantity: String(item.quantity),
|
||||||
|
|
@ -320,25 +232,6 @@ export default function RepairEstimatesSection({
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addInventoryItem(item: InventoryItem) {
|
|
||||||
const formItem: EstimateFormItem = {
|
|
||||||
item_type: "part",
|
|
||||||
inventory_item_id: item.id,
|
|
||||||
inventory_snapshot_name: item.name,
|
|
||||||
inventory_snapshot_sku: item.sku,
|
|
||||||
inventory_snapshot_manufacturer: item.manufacturer,
|
|
||||||
inventory_snapshot_part_number: item.manufacturer_part_number,
|
|
||||||
inventory_price_overridden: false,
|
|
||||||
title: item.name,
|
|
||||||
description: item.description ?? "",
|
|
||||||
quantity: "1",
|
|
||||||
unit: item.unit,
|
|
||||||
unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0),
|
|
||||||
};
|
|
||||||
setPayload((current) => ({ ...current, items: [...current.items, formItem] }));
|
|
||||||
setInventoryDialogOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEstimate() {
|
async function saveEstimate() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -361,8 +254,6 @@ export default function RepairEstimatesSection({
|
||||||
valid_until: payload.valid_until || null,
|
valid_until: payload.valid_until || null,
|
||||||
items: payload.items.map((item) => ({
|
items: payload.items.map((item) => ({
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
inventory_item_id: item.inventory_item_id,
|
|
||||||
inventory_price_overridden: item.inventory_price_overridden,
|
|
||||||
title: item.title.trim(),
|
title: item.title.trim(),
|
||||||
description: item.description?.trim() || null,
|
description: item.description?.trim() || null,
|
||||||
quantity: item.quantity.replace(",", "."),
|
quantity: item.quantity.replace(",", "."),
|
||||||
|
|
@ -398,13 +289,11 @@ export default function RepairEstimatesSection({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelEstimate() {
|
async function cancelEstimate(estimate: RepairEstimate) {
|
||||||
if (!cancelTarget) return;
|
setPendingId(estimate.id);
|
||||||
setPendingId(cancelTarget.id);
|
|
||||||
try {
|
try {
|
||||||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`);
|
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/cancel`);
|
||||||
await loadEstimates();
|
await loadEstimates();
|
||||||
setCancelTarget(null);
|
|
||||||
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
|
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
|
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
|
||||||
|
|
@ -413,68 +302,6 @@ export default function RepairEstimatesSection({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeEstimate() {
|
|
||||||
if (!revokeTarget) return;
|
|
||||||
setPendingId(revokeTarget.id);
|
|
||||||
try {
|
|
||||||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${revokeTarget.id}/revoke`);
|
|
||||||
await loadEstimates();
|
|
||||||
setRevokeTarget(null);
|
|
||||||
showToast({ type: "success", title: "Freigabe zurückgenommen", description: "Die Reservierungen wurden freigegeben." });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({ type: "error", title: "Freigabe konnte nicht zurückgenommen werden", description: getErrorMessage(err) });
|
|
||||||
} finally {
|
|
||||||
setPendingId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareLexwareInvoice(estimate: RepairEstimate) {
|
|
||||||
setPendingId(estimate.id);
|
|
||||||
try {
|
|
||||||
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
|
|
||||||
setLexwareResult(response.data);
|
|
||||||
setAccountingTarget(estimate);
|
|
||||||
setAccountingNote(response.data.accounting_note || estimate.accounting_note || "");
|
|
||||||
setLexwareDialogOpen(true);
|
|
||||||
showToast({
|
|
||||||
type: response.data.ready_for_export ? "success" : "error",
|
|
||||||
title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen",
|
|
||||||
description: response.data.ready_for_export ? "Die Daten stehen für die Übernahme in die Buchhaltung bereit." : "Bitte prüfe die Hinweise vor der Übernahme.",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) });
|
|
||||||
} finally {
|
|
||||||
setPendingId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAccountingTransferred() {
|
|
||||||
if (!accountingTarget || !lexwareResult) return;
|
|
||||||
setPendingId(accountingTarget.id);
|
|
||||||
try {
|
|
||||||
const response = await api.post<LexwareInvoicePreparation>(
|
|
||||||
`/repairs/${repairId}/estimates/${accountingTarget.id}/accounting/mark-transferred`,
|
|
||||||
{ accounting_note: accountingNote },
|
|
||||||
);
|
|
||||||
setLexwareResult(response.data);
|
|
||||||
await loadEstimates();
|
|
||||||
showToast({ type: "success", title: "Als übertragen markiert", description: "Die Übergabe an die Buchhaltung wurde dokumentiert." });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({ type: "error", title: "Status konnte nicht aktualisiert werden", description: getErrorMessage(err) });
|
|
||||||
} finally {
|
|
||||||
setPendingId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyText(label: string, text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
showToast({ type: "success", title: `${label} kopiert` });
|
|
||||||
} catch {
|
|
||||||
showToast({ type: "error", title: "Kopieren fehlgeschlagen", description: "Bitte markiere den Text manuell." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEstimate() {
|
async function deleteEstimate() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setPendingId(deleteTarget.id);
|
setPendingId(deleteTarget.id);
|
||||||
|
|
@ -539,30 +366,12 @@ export default function RepairEstimatesSection({
|
||||||
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
|
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{estimate.accounting_export_status && (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
|
|
||||||
<ReceiptText className="h-4 w-4 text-slate-500" />
|
|
||||||
<span className="font-medium text-slate-700">Buchhaltung</span>
|
|
||||||
<AccountingBadge status={estimate.accounting_export_status} />
|
|
||||||
{estimate.accounting_transferred_at && <span className="text-xs text-slate-500">{dateTime(estimate.accounting_transferred_at)}</span>}
|
|
||||||
{estimate.accounting_note && <span className="break-words text-xs text-slate-500">{estimate.accounting_note}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 divide-y rounded-lg border">
|
<div className="mt-4 divide-y rounded-lg border">
|
||||||
{estimate.items.map((item) => (
|
{estimate.items.map((item) => (
|
||||||
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</p>
|
||||||
{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""}
|
|
||||||
{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}
|
|
||||||
</p>
|
|
||||||
{item.inventory_item_id && (
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -572,9 +381,7 @@ export default function RepairEstimatesSection({
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
|
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
|
||||||
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
||||||
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => void cancelEstimate(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
||||||
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
|
|
||||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />In Buchhaltung übernehmen</Button>}
|
|
||||||
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
|
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -602,32 +409,18 @@ export default function RepairEstimatesSection({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setInventoryDialogOpen(true)}><PackageSearch />Lagerartikel auswählen</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{payload.items.map((item, index) => (
|
{payload.items.map((item, index) => (
|
||||||
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
||||||
{item.inventory_item_id && (
|
|
||||||
<div className="mb-3 flex flex-col gap-2 rounded-lg border bg-white p-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold text-slate-950">Lagerartikel</span> · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
|
||||||
<input type="checkbox" checked={item.inventory_price_overridden} onChange={(event) => updateItem(index, { inventory_price_overridden: event.target.checked })} />
|
|
||||||
Preis manuell überschreiben
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
||||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} disabled={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
||||||
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<Input placeholder="Titel" value={item.title} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
<Input placeholder="Titel" value={item.title} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
||||||
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
||||||
<Input placeholder="Einheit" value={item.unit} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
<Input placeholder="Einheit" value={item.unit} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
||||||
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} readOnly={Boolean(item.inventory_item_id && !item.inventory_price_overridden)} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
||||||
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
||||||
</div>
|
</div>
|
||||||
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
||||||
|
|
@ -649,198 +442,6 @@ export default function RepairEstimatesSection({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={inventoryDialogOpen} onOpenChange={setInventoryDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-4xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Lagerartikel auswählen</DialogTitle>
|
|
||||||
<DialogDescription>Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_220px_180px]">
|
|
||||||
<Input placeholder="SKU, Name, Hersteller oder Gerät suchen" value={inventorySearch} onChange={(event) => setInventorySearch(event.target.value)} />
|
|
||||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={inventoryCategory} onChange={(event) => setInventoryCategory(event.target.value)}>
|
|
||||||
<option value="all">Alle Kategorien</option>
|
|
||||||
{inventoryCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<Input placeholder="Hersteller" value={inventoryManufacturer} onChange={(event) => setInventoryManufacturer(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inventoryError ? (
|
|
||||||
<p className="rounded-lg border bg-red-50 p-3 text-sm text-red-700">{inventoryError}</p>
|
|
||||||
) : inventoryLoading ? (
|
|
||||||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Lagerartikel werden geladen...</p>
|
|
||||||
) : inventoryItems.length === 0 ? (
|
|
||||||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Keine aktiven Lagerartikel gefunden.</p>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-[48vh] divide-y overflow-y-auto rounded-lg border">
|
|
||||||
{inventoryItems.map((item) => {
|
|
||||||
const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className="grid w-full gap-2 p-3 text-left hover:bg-slate-50 md:grid-cols-[1fr_auto]"
|
|
||||||
onClick={() => addInventoryItem(item)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-950">{item.sku} · {item.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{item.manufacturer || "Hersteller nicht angegeben"} · Verfügbar {item.quantity_available} {item.unit} · {item.location?.name ?? "Kein Lagerort"}
|
|
||||||
</p>
|
|
||||||
{lowAfterSelection && (
|
|
||||||
<p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-amber-700">
|
|
||||||
<AlertTriangle size={14} /> Auswahl erreicht oder unterschreitet den Mindestbestand.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-slate-950">{money(item.selling_price_cents ?? 0, item.currency)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setInventoryDialogOpen(false)}>Schließen</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>In Buchhaltung übernehmen</DialogTitle>
|
|
||||||
<DialogDescription>Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{lexwareResult && (
|
|
||||||
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
|
|
||||||
<div className="rounded-lg border bg-slate-50 p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-semibold text-slate-950">Exportstatus</span>
|
|
||||||
<AccountingBadge status={lexwareResult.export_status} />
|
|
||||||
<span className="text-slate-400">→</span>
|
|
||||||
<AccountingBadge status="transferred" muted={lexwareResult.export_status !== "transferred" && lexwareResult.export_status !== "booked"} />
|
|
||||||
<span className="text-slate-400">→</span>
|
|
||||||
<AccountingBadge status="booked" muted={lexwareResult.export_status !== "booked"} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">Rechnungsvorbereitung #{lexwareResult.sync_record_id}</p>
|
|
||||||
{lexwareResult.transferred_at && <p className="mt-1 text-xs text-slate-500">Übertragen am {dateTime(lexwareResult.transferred_at)}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lexwareResult.warnings.length > 0 && (
|
|
||||||
<div className="rounded-lg border bg-amber-50 p-4 text-amber-950">
|
|
||||||
<p className="font-semibold">Warnungen</p>
|
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
|
||||||
{lexwareResult.warnings.map((warning) => <li key={warning}>{warning}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-lg border">
|
|
||||||
<StepHeader step="1" title="Kundendaten kopieren" />
|
|
||||||
<CopyBlock
|
|
||||||
text={[
|
|
||||||
`Kunde: ${lexwareResult.customer_mapping.name}`,
|
|
||||||
`E-Mail: ${lexwareResult.customer_mapping.email || "-"}`,
|
|
||||||
`Telefon: ${lexwareResult.customer_mapping.phone || "-"}`,
|
|
||||||
].join("\n")}
|
|
||||||
onCopy={(text) => void copyText("Kundendaten", text)}
|
|
||||||
/>
|
|
||||||
<StepHeader step="2" title="In Buchhaltungssoftware einfügen" muted />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border">
|
|
||||||
<StepHeader step="3" title="Positionen kopieren" />
|
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
|
|
||||||
<span>Position</span>
|
|
||||||
<span>Steuer</span>
|
|
||||||
<span>Summe</span>
|
|
||||||
</div>
|
|
||||||
{lexwareResult.line_item_mapping.map((item, index) => (
|
|
||||||
<div key={`${item.title}-${index}`} className="grid grid-cols-[1fr_auto_auto] gap-3 border-b p-3 last:border-b-0">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-950">{item.title}</p>
|
|
||||||
<p className="text-xs text-slate-500">{item.quantity} {item.unit} × {item.unit_price} €</p>
|
|
||||||
</div>
|
|
||||||
<span>{item.tax_rate} %</span>
|
|
||||||
<span className="font-medium text-slate-950">{item.total} €</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<CopyBlock
|
|
||||||
text={lexwareResult.line_item_mapping.map((item) => (
|
|
||||||
`${item.title}; ${item.description || "-"}; ${item.quantity} ${item.unit}; ${item.unit_price} EUR; ${item.tax_rate}%; ${item.total} EUR`
|
|
||||||
)).join("\n")}
|
|
||||||
onCopy={(text) => void copyText("Positionen", text)}
|
|
||||||
/>
|
|
||||||
<StepHeader step="4" title="In Buchhaltungssoftware einfügen" muted />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
|
|
||||||
<Meta label="Netto" value={`${String(lexwareResult.payload_summary.subtotal ?? "-")} €`} />
|
|
||||||
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")} €`} />
|
|
||||||
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")} €`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white">
|
|
||||||
<StepHeader step="5" title="Rechnung speichern" muted />
|
|
||||||
<StepHeader step="6" title="Zurück zu Olympus" muted />
|
|
||||||
<div className="border-t p-4">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Buchhaltungsnotiz</span>
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
|
|
||||||
placeholder="z. B. Lexware RG-2026-154 oder Rechnung in sevdesk erstellt"
|
|
||||||
value={accountingNote}
|
|
||||||
onChange={(event) => setAccountingNote(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void markAccountingTransferred()}
|
|
||||||
disabled={!accountingTarget || pendingId === accountingTarget.id || lexwareResult.export_status === "transferred"}
|
|
||||||
>
|
|
||||||
<CheckCircle2 />
|
|
||||||
Als übertragen markieren
|
|
||||||
</Button>
|
|
||||||
{lexwareResult.export_status === "transferred" && <span className="text-sm font-medium text-emerald-700">Bereits übertragen</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setLexwareDialogOpen(false)}>Schließen</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(cancelTarget)}
|
|
||||||
title="Kostenvoranschlag stornieren?"
|
|
||||||
description="Der Kostenvoranschlag wird storniert. Reservierte Lagerartikel werden freigegeben."
|
|
||||||
confirmLabel="Stornieren"
|
|
||||||
pending={pendingId === cancelTarget?.id}
|
|
||||||
onOpenChange={(open) => !open && setCancelTarget(null)}
|
|
||||||
onConfirm={() => void cancelEstimate()}
|
|
||||||
>
|
|
||||||
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(revokeTarget)}
|
|
||||||
title="Freigabe zurücknehmen?"
|
|
||||||
description="Möchten Sie den bereits freigegebenen Kostenvoranschlag wirklich zurücknehmen? Die Lagerreservierungen werden freigegeben und der Kunde erhält eine Benachrichtigung."
|
|
||||||
confirmLabel="Freigabe zurücknehmen"
|
|
||||||
pending={pendingId === revokeTarget?.id}
|
|
||||||
onOpenChange={(open) => !open && setRevokeTarget(null)}
|
|
||||||
onConfirm={() => void revokeEstimate()}
|
|
||||||
>
|
|
||||||
{revokeTarget && <p className="text-sm text-slate-600">{revokeTarget.estimate_number} · {revokeTarget.title}</p>}
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title="Kostenvoranschlag löschen?"
|
title="Kostenvoranschlag löschen?"
|
||||||
|
|
@ -864,37 +465,6 @@ function Meta({ label, value }: { label: string; value: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountingBadge({ status, muted = false }: { status: string; muted?: boolean }) {
|
|
||||||
const label = accountingStatusLabels[status] ?? status;
|
|
||||||
const activeClass = accountingStatusClasses[status] ?? "bg-slate-100 text-slate-700 ring-slate-600/20";
|
|
||||||
return (
|
|
||||||
<span className={`w-fit rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${muted ? "bg-slate-100 text-slate-400 ring-slate-200" : activeClass}`}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepHeader({ step, title, muted = false }: { step: string; title: string; muted?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-3 border-b p-3 ${muted ? "bg-slate-50 text-slate-500" : "bg-white text-slate-950"}`}>
|
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{step}</span>
|
|
||||||
<p className="font-medium">{title}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CopyBlock({ text, onCopy }: { text: string; onCopy: (text: string) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 p-3">
|
|
||||||
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-white whitespace-pre-wrap">{text}</pre>
|
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => onCopy(text)}>
|
|
||||||
<Copy />
|
|
||||||
Kopieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ export const config = {
|
||||||
"/roles/:path*",
|
"/roles/:path*",
|
||||||
"/customers/:path*",
|
"/customers/:path*",
|
||||||
"/repairs/:path*",
|
"/repairs/:path*",
|
||||||
"/backups/:path*",
|
|
||||||
"/settings/:path*",
|
"/settings/:path*",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
export interface BackupManifest {
|
|
||||||
backup_id: string;
|
|
||||||
created_at: string;
|
|
||||||
app_version: string;
|
|
||||||
backup_type: string;
|
|
||||||
database_url_host_anonymized: string;
|
|
||||||
database_name: string;
|
|
||||||
storage_base_path: string;
|
|
||||||
included_sections: string[];
|
|
||||||
file_count: number;
|
|
||||||
total_size_bytes: number;
|
|
||||||
checksum_sha256: string;
|
|
||||||
created_by_user_id: number | null;
|
|
||||||
created_by_username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupSummary {
|
|
||||||
filename: string;
|
|
||||||
size_bytes: number;
|
|
||||||
created_at: string | null;
|
|
||||||
app_version: string;
|
|
||||||
backup_type: string;
|
|
||||||
database_name: string;
|
|
||||||
storage_base_path: string;
|
|
||||||
file_count: number;
|
|
||||||
total_size_bytes: number;
|
|
||||||
created_by_user_id: number | null;
|
|
||||||
created_by_username: string;
|
|
||||||
validation_status: string;
|
|
||||||
validation_message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupListResponse {
|
|
||||||
items: BackupSummary[];
|
|
||||||
total_count: number;
|
|
||||||
total_size_bytes: number;
|
|
||||||
latest_backup_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupValidationResponse {
|
|
||||||
filename: string;
|
|
||||||
valid: boolean;
|
|
||||||
message: string;
|
|
||||||
issues: string[];
|
|
||||||
checksum_valid: boolean;
|
|
||||||
restore_supported: boolean;
|
|
||||||
requires_cli_restore: boolean;
|
|
||||||
manifest: BackupManifest | null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import type { SettingsSource } from "@/types/system-settings";
|
|
||||||
|
|
||||||
export interface LexwareSettings {
|
|
||||||
enabled: boolean;
|
|
||||||
api_base_url: string;
|
|
||||||
api_key_is_set: boolean;
|
|
||||||
organization_name: string;
|
|
||||||
default_tax_rate: string;
|
|
||||||
default_payment_terms_days: number;
|
|
||||||
source: SettingsSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LexwareSettingsPayload {
|
|
||||||
enabled: boolean;
|
|
||||||
api_base_url: string;
|
|
||||||
api_key?: string;
|
|
||||||
organization_name: string;
|
|
||||||
default_tax_rate: string;
|
|
||||||
default_payment_terms_days: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LexwareTestConnectionResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
source: SettingsSource;
|
|
||||||
api_base_url: string;
|
|
||||||
organization_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LexwareCustomerMapping {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
search_strategy: string;
|
|
||||||
create_payload: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LexwareLineItemMapping {
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
quantity: string;
|
|
||||||
unit: string;
|
|
||||||
unit_price: string;
|
|
||||||
tax_rate: string;
|
|
||||||
total: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LexwareInvoicePreparation {
|
|
||||||
ready_for_export: boolean;
|
|
||||||
export_status: "prepared" | "transferred" | "booked" | "cancelled";
|
|
||||||
payload_summary: Record<string, unknown>;
|
|
||||||
customer_mapping: LexwareCustomerMapping;
|
|
||||||
line_item_mapping: LexwareLineItemMapping[];
|
|
||||||
tax_mapping: Record<string, unknown>;
|
|
||||||
warnings: string[];
|
|
||||||
sync_record_id: number;
|
|
||||||
accounting_note: string;
|
|
||||||
transferred_at: string | null;
|
|
||||||
transferred_by_user_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,7 @@ export type RepairDocumentType =
|
||||||
| "shipping"
|
| "shipping"
|
||||||
| "other";
|
| "other";
|
||||||
export type RepairDocumentVisibility = "internal" | "customer";
|
export type RepairDocumentVisibility = "internal" | "customer";
|
||||||
export type RepairEstimateStatus = "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled" | "revoked";
|
export type RepairEstimateStatus = "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled";
|
||||||
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
|
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
|
||||||
|
|
||||||
export interface Repair {
|
export interface Repair {
|
||||||
|
|
@ -166,8 +166,6 @@ export interface RepairDocument {
|
||||||
|
|
||||||
export interface RepairEstimateItemPayload {
|
export interface RepairEstimateItemPayload {
|
||||||
item_type: RepairEstimateItemType;
|
item_type: RepairEstimateItemType;
|
||||||
inventory_item_id?: number | null;
|
|
||||||
inventory_price_overridden?: boolean;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
|
|
@ -188,10 +186,6 @@ export interface RepairEstimatePayload {
|
||||||
export interface RepairEstimateItem extends RepairEstimateItemPayload {
|
export interface RepairEstimateItem extends RepairEstimateItemPayload {
|
||||||
id: number;
|
id: number;
|
||||||
estimate_id: number;
|
estimate_id: number;
|
||||||
inventory_snapshot_name: string;
|
|
||||||
inventory_snapshot_sku: string;
|
|
||||||
inventory_snapshot_manufacturer: string | null;
|
|
||||||
inventory_snapshot_part_number: string | null;
|
|
||||||
position: number;
|
position: number;
|
||||||
total_cents: number;
|
total_cents: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -216,14 +210,6 @@ export interface RepairEstimate {
|
||||||
approved_at: string | null;
|
approved_at: string | null;
|
||||||
declined_at: string | null;
|
declined_at: string | null;
|
||||||
customer_response_message: string | null;
|
customer_response_message: string | null;
|
||||||
lexware_invoice_id: string | null;
|
|
||||||
lexware_invoice_number: string | null;
|
|
||||||
lexware_invoice_status: string | null;
|
|
||||||
lexware_synced_at: string | null;
|
|
||||||
accounting_export_status: "prepared" | "transferred" | "booked" | "cancelled" | null;
|
|
||||||
accounting_note: string | null;
|
|
||||||
accounting_transferred_at: string | null;
|
|
||||||
accounting_transferred_by_user_id: number | null;
|
|
||||||
created_by_user_id: number | null;
|
created_by_user_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
|
||||||
150
scripts/backup.sh
Normal file → Executable file
150
scripts/backup.sh
Normal file → Executable file
|
|
@ -1,139 +1,33 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
cd "$(dirname "$0")/.."
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||||
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
||||||
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
|
|
||||||
APP_VERSION="${APP_VERSION:-0.9.1}"
|
|
||||||
DATABASE_URL="${DATABASE_URL:-}"
|
|
||||||
|
|
||||||
if [[ -z "$DATABASE_URL" ]]; then
|
|
||||||
echo "DATABASE_URL ist erforderlich." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v pg_dump >/dev/null 2>&1; then
|
|
||||||
echo "pg_dump ist nicht verfuegbar." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR" "$STORAGE_HOST_PATH"
|
|
||||||
|
|
||||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
FILENAME="olympus-backup-${TIMESTAMP}.zip"
|
TARGET_DIR="${BACKUP_DIR}/${TIMESTAMP}"
|
||||||
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")"
|
|
||||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
|
||||||
|
|
||||||
export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME
|
mkdir -p "${TARGET_DIR}"
|
||||||
|
|
||||||
python3 - <<'PY'
|
echo "==> Creating backup in ${TARGET_DIR}"
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
source = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
|
||||||
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
echo "==> Creating PostgreSQL dump from container ${POSTGRES_CONTAINER}"
|
||||||
target = Path(os.environ["WORK_DIR"]).resolve() / "storage"
|
docker exec "${POSTGRES_CONTAINER}" pg_dump -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" > "${TARGET_DIR}/postgres.sql"
|
||||||
source.mkdir(parents=True, exist_ok=True)
|
elif command -v pg_dump >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
echo "==> Creating PostgreSQL dump from DATABASE_URL"
|
||||||
|
pg_dump "${DATABASE_URL}" > "${TARGET_DIR}/postgres.sql"
|
||||||
|
else
|
||||||
|
echo "WARN: PostgreSQL dump skipped. Set POSTGRES_CONTAINER or install pg_dump with DATABASE_URL."
|
||||||
|
fi
|
||||||
|
|
||||||
for source_path in sorted(source.rglob("*")):
|
if [[ -d "${STORAGE_HOST_PATH}" ]]; then
|
||||||
if source_path == backup_dir or backup_dir in source_path.parents:
|
echo "==> Archiving storage directory"
|
||||||
continue
|
tar -czf "${TARGET_DIR}/storage.tar.gz" -C "${STORAGE_HOST_PATH}" .
|
||||||
relative = source_path.relative_to(source)
|
else
|
||||||
destination = target / relative
|
echo "WARN: Storage directory ${STORAGE_HOST_PATH} not found; storage backup skipped."
|
||||||
if source_path.is_dir():
|
fi
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
|
||||||
continue
|
|
||||||
if source_path.is_file():
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(source_path, destination)
|
|
||||||
PY
|
|
||||||
|
|
||||||
PG_DUMP_URL="$(python3 - <<'PY'
|
echo "INFO: .env is not copied automatically. Store production secrets separately and securely."
|
||||||
import os
|
echo "==> Backup completed"
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
database_url = os.environ["DATABASE_URL"]
|
|
||||||
parts = urlsplit(database_url)
|
|
||||||
scheme = parts.scheme.split("+", 1)[0]
|
|
||||||
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
|
|
||||||
pg_dump \
|
|
||||||
--format=custom \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
--file="$WORK_DIR/database.dump" \
|
|
||||||
--dbname="$PG_DUMP_URL"
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
from datetime import datetime, UTC
|
|
||||||
from hashlib import sha256
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
work_dir = Path(os.environ["WORK_DIR"]).resolve()
|
|
||||||
storage_dir = work_dir / "storage"
|
|
||||||
dump_path = work_dir / "database.dump"
|
|
||||||
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
|
||||||
filename = os.environ["FILENAME"]
|
|
||||||
archive_path = backup_dir / filename
|
|
||||||
database_url = urlsplit(os.environ["DATABASE_URL"])
|
|
||||||
|
|
||||||
def update_digest_from_file(digest, file_path: Path) -> None:
|
|
||||||
with file_path.open("rb") as handle:
|
|
||||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
|
|
||||||
checksum = sha256()
|
|
||||||
checksum.update(b"database.dump")
|
|
||||||
update_digest_from_file(checksum, dump_path)
|
|
||||||
|
|
||||||
file_count = 1
|
|
||||||
total_size = dump_path.stat().st_size
|
|
||||||
for file_path in sorted(storage_dir.rglob("*")):
|
|
||||||
if file_path.is_dir():
|
|
||||||
continue
|
|
||||||
checksum.update(file_path.relative_to(work_dir).as_posix().encode("utf-8"))
|
|
||||||
update_digest_from_file(checksum, file_path)
|
|
||||||
file_count += 1
|
|
||||||
total_size += file_path.stat().st_size
|
|
||||||
|
|
||||||
host = database_url.hostname or "unknown"
|
|
||||||
manifest = {
|
|
||||||
"backup_id": sha256(f"{filename}:{datetime.now(UTC).isoformat()}".encode("utf-8")).hexdigest()[:24],
|
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
|
||||||
"app_version": os.environ["APP_VERSION"],
|
|
||||||
"backup_type": "full",
|
|
||||||
"database_url_host_anonymized": f"sha256:{sha256(host.encode('utf-8')).hexdigest()[:12]}",
|
|
||||||
"database_name": database_url.path.rsplit("/", 1)[-1] or "unknown",
|
|
||||||
"storage_base_path": os.environ["STORAGE_HOST_PATH"],
|
|
||||||
"included_sections": ["database", "storage"],
|
|
||||||
"file_count": file_count,
|
|
||||||
"total_size_bytes": total_size,
|
|
||||||
"checksum_sha256": checksum.hexdigest(),
|
|
||||||
"created_by_user_id": None,
|
|
||||||
"created_by_username": "cli",
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_path = work_dir / "manifest.json"
|
|
||||||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=True), encoding="utf-8")
|
|
||||||
|
|
||||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
||||||
archive.writestr("storage/", "")
|
|
||||||
archive.write(manifest_path, "manifest.json")
|
|
||||||
archive.write(dump_path, "database.dump")
|
|
||||||
for file_path in sorted(storage_dir.rglob("*")):
|
|
||||||
if file_path.is_dir():
|
|
||||||
continue
|
|
||||||
archive.write(file_path, file_path.relative_to(work_dir).as_posix())
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "Backup erstellt: ${BACKUP_DIR%/}/${FILENAME}"
|
|
||||||
|
|
|
||||||
161
scripts/restore.sh
Normal file → Executable file
161
scripts/restore.sh
Normal file → Executable file
|
|
@ -1,151 +1,46 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
cd "$(dirname "$0")/.."
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
BACKUP_SOURCE="${1:-}"
|
BACKUP_SOURCE="${1:-}"
|
||||||
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
||||||
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
|
|
||||||
DATABASE_URL="${DATABASE_URL:-}"
|
|
||||||
STOPPED_SERVICES=0
|
|
||||||
|
|
||||||
if [[ -z "$BACKUP_SOURCE" || ! -f "$BACKUP_SOURCE" ]]; then
|
if [[ -z "${BACKUP_SOURCE}" || ! -d "${BACKUP_SOURCE}" ]]; then
|
||||||
echo "Usage: scripts/restore.sh <backup-zip>" >&2
|
echo "Usage: scripts/restore.sh <backup-directory>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$DATABASE_URL" ]]; then
|
echo "This restore can overwrite database and storage state."
|
||||||
echo "DATABASE_URL ist erforderlich." >&2
|
echo "Backup source: ${BACKUP_SOURCE}"
|
||||||
exit 1
|
echo "Storage target: ${STORAGE_HOST_PATH}"
|
||||||
fi
|
read -r -p "Type RESTORE to continue: " confirmation
|
||||||
|
|
||||||
if ! command -v pg_restore >/dev/null 2>&1; then
|
if [[ "${confirmation}" != "RESTORE" ]]; then
|
||||||
echo "pg_restore ist nicht verfuegbar." >&2
|
echo "Restore cancelled"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$STORAGE_HOST_PATH" "$BACKUP_DIR"
|
|
||||||
|
|
||||||
python3 - "$BACKUP_SOURCE" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
path = Path(sys.argv[1]).resolve()
|
|
||||||
required = {"manifest.json", "database.dump"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(path) as archive:
|
|
||||||
names = set(archive.namelist())
|
|
||||||
except zipfile.BadZipFile as exc:
|
|
||||||
raise SystemExit(f"Ungueltige ZIP-Datei: {exc}") from exc
|
|
||||||
|
|
||||||
missing = [name for name in required if name not in names]
|
|
||||||
if not any(name == "storage/" or name.startswith("storage/") for name in names):
|
|
||||||
missing.append("storage/")
|
|
||||||
if missing:
|
|
||||||
raise SystemExit(f"Backup unvollstaendig: {', '.join(missing)}")
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "WARNUNG: Dieser Restore kann Datenbank- und Storage-Daten dauerhaft ueberschreiben."
|
|
||||||
echo "Backup: $BACKUP_SOURCE"
|
|
||||||
read -r -p "Bitte exakt 'ICH VERSTEHE DAS RISIKO' eingeben: " confirmation
|
|
||||||
|
|
||||||
if [[ "$confirmation" != "ICH VERSTEHE DAS RISIKO" ]]; then
|
|
||||||
echo "Restore abgebrochen."
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if [[ -f "${BACKUP_SOURCE}/postgres.sql" ]]; then
|
||||||
read -r -p "Olympus-Dienste jetzt per docker compose stoppen? [y/N] " stop_reply
|
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
|
||||||
if [[ "$stop_reply" =~ ^[Yy]$ ]]; then
|
echo "==> Restoring PostgreSQL dump into container ${POSTGRES_CONTAINER}"
|
||||||
docker compose stop athena hermes
|
docker exec -i "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" < "${BACKUP_SOURCE}/postgres.sql"
|
||||||
STOPPED_SERVICES=1
|
elif command -v psql >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
|
||||||
|
echo "==> Restoring PostgreSQL dump from DATABASE_URL"
|
||||||
|
psql "${DATABASE_URL}" < "${BACKUP_SOURCE}/postgres.sql"
|
||||||
|
else
|
||||||
|
echo "WARN: PostgreSQL restore skipped. Set POSTGRES_CONTAINER or install psql with DATABASE_URL."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo "WARN: postgres.sql not found; database restore skipped."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.restore.XXXXXX")"
|
if [[ -f "${BACKUP_SOURCE}/storage.tar.gz" ]]; then
|
||||||
trap 'rm -rf "$WORK_DIR"; if [[ "$STOPPED_SERVICES" -eq 1 ]]; then docker compose start hermes athena; fi' EXIT
|
mkdir -p "${STORAGE_HOST_PATH}"
|
||||||
|
echo "==> Restoring storage archive"
|
||||||
|
tar -xzf "${BACKUP_SOURCE}/storage.tar.gz" -C "${STORAGE_HOST_PATH}"
|
||||||
|
else
|
||||||
|
echo "WARN: storage.tar.gz not found; storage restore skipped."
|
||||||
|
fi
|
||||||
|
|
||||||
python3 - "$BACKUP_SOURCE" "$WORK_DIR" <<'PY'
|
echo "==> Restore completed"
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
archive_path = Path(sys.argv[1]).resolve()
|
|
||||||
target_dir = Path(sys.argv[2]).resolve()
|
|
||||||
|
|
||||||
with zipfile.ZipFile(archive_path) as archive:
|
|
||||||
archive.extractall(target_dir)
|
|
||||||
PY
|
|
||||||
|
|
||||||
SNAPSHOT_PATH="${BACKUP_DIR%/}/pre-restore-storage-$(date +%Y%m%d-%H%M%S).tar.gz"
|
|
||||||
export STORAGE_HOST_PATH SNAPSHOT_PATH
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
storage = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
|
||||||
snapshot = Path(os.environ["SNAPSHOT_PATH"]).resolve()
|
|
||||||
storage.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with tarfile.open(snapshot, "w:gz") as archive:
|
|
||||||
for path in sorted(storage.rglob("*")):
|
|
||||||
if path.name == "backups" and path.is_dir():
|
|
||||||
continue
|
|
||||||
if "backups" in path.parts:
|
|
||||||
continue
|
|
||||||
archive.add(path, arcname=path.relative_to(storage))
|
|
||||||
PY
|
|
||||||
|
|
||||||
PG_RESTORE_URL="$(python3 - <<'PY'
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
database_url = os.environ["DATABASE_URL"]
|
|
||||||
parts = urlsplit(database_url)
|
|
||||||
scheme = parts.scheme.split("+", 1)[0]
|
|
||||||
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
|
|
||||||
pg_restore \
|
|
||||||
--clean \
|
|
||||||
--if-exists \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
--dbname="$PG_RESTORE_URL" \
|
|
||||||
"$WORK_DIR/database.dump"
|
|
||||||
|
|
||||||
export WORK_DIR
|
|
||||||
python3 - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
storage_target = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
|
||||||
storage_source = (Path(os.environ["WORK_DIR"]).resolve() / "storage")
|
|
||||||
storage_target.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for path in storage_target.iterdir():
|
|
||||||
if path.name == "backups":
|
|
||||||
continue
|
|
||||||
if path.is_dir():
|
|
||||||
shutil.rmtree(path)
|
|
||||||
else:
|
|
||||||
path.unlink()
|
|
||||||
|
|
||||||
for source_path in sorted(storage_source.rglob("*")):
|
|
||||||
relative = source_path.relative_to(storage_source)
|
|
||||||
destination = storage_target / relative
|
|
||||||
if source_path.is_dir():
|
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
|
||||||
continue
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(source_path, destination)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "Restore abgeschlossen. Storage-Snapshot: ${SNAPSHOT_PATH}"
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue