Compare commits

..

No commits in common. "main" and "v0.8.8.1" have entirely different histories.

57 changed files with 82 additions and 3486 deletions

View file

@ -20,7 +20,6 @@ STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
KNOWLEDGE_STORAGE_PATH=/data/knowledge
@ -45,8 +44,3 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
# Env-Fallback fuer Lexware Office. Bevorzugt wird die Admin-Konfiguration unter /settings.
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=

2
.gitignore vendored
View file

@ -43,8 +43,6 @@ out/
# Runtime storage
# ===========================
storage/
/backups/
*.dump
# ===========================
# Coverage

View file

@ -166,58 +166,6 @@ Hermes
Athena ist die einzige API-Oberflaeche fuer den Browser.
## Lexware Office Integration
Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation.
Verantwortlichkeiten:
- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege.
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt.
Komponenten:
- Hermes speichert Lexware-Konfiguration in `system_settings`.
- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`.
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
Datenfluss:
```text
Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office
Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL
```
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
Buchhaltungsworkflow:
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
Neue zentrale Endpunkte:
- `GET /lexware/settings`
- `PUT /lexware/settings`
- `POST /lexware/test-connection`
- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice`
RBAC:
- `lexware.read`
- `lexware.manage`
- `lexware.export`
Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen.
## Dashboard
Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`.
@ -1060,29 +1008,9 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
### Backup-Modul ab v0.9.1
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
- Hermes-API: `backend/hermes/app/api/backups.py`
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
- Athena-BFF: `/api/backups/...`
Sicherheitsregeln:
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
- Hermes anonymisiert den Datenbank-Host im Manifest.
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
### Knowledge-RBAC

View file

@ -42,15 +42,10 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=
```
`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
`LEXWARE_*` ist ab v0.8.9 nur ein Env-Fallback. Bevorzugt wird die Lexware-Konfiguration im Adminbereich unter `/settings -> Lexware Office`. Der API-Key darf nicht ins Git und wird nie an Athena zurueckgegeben.
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
## Docker Netzwerk
@ -134,46 +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.
## Backup und Restore
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
Ablage:
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
- Backup-Dateien gehoeren nie ins Git
Inhalt eines Backups:
- `manifest.json`
- `database.dump`
- `storage/`
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
- `GET /api/backups`
- `POST /api/backups/create`
- `GET /api/backups/[filename]/download`
- `POST /api/backups/[filename]/validate`
- `POST /api/backups/[filename]/restore`
- `DELETE /api/backups/[filename]/delete`
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
1. Backup validieren.
2. Sicherheitsbestaetigung pruefen.
3. Restore ueber `scripts/restore.sh <backup-zip>` ausfuehren.
4. Ergebnis und Audit Logs kontrollieren.
Empfehlung fuer den Betrieb:
- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
- Backup-Dateien vor Offsite-Kopie verschluesseln.
- Restore nur in Wartungsfenstern ausfuehren.
## Knowledge Workflow
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
@ -224,41 +179,6 @@ Statuslink-Konzept:
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
## Lexware Office Foundation
Ab v0.8.9 ist eine Lexware-Office-Grundlage vorbereitet.
Rollenverteilung:
- Olympus bleibt Werkstatt-ERP und verwaltet Kunden-, Reparatur-, Lager- und KV-Daten.
- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR.
Konfiguration:
- Adminbereich: `/settings -> Lexware Office`
- Hermes-Endpunkte: `GET|PUT /lexware/settings`, `POST /lexware/test-connection`
- Athena-BFF: `/api/lexware/settings`, `/api/lexware/test-connection`
- Env-Fallback: `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
Der Verbindungstest nutzt serverseitig `GET {LEXWARE_API_BASE_URL}/v1/profile` mit Bearer API-Key. Browser rufen weder Hermes noch Lexware direkt auf.
Freigegebene Kostenvoranschlaege koennen manuell fuer Lexware vorbereitet werden:
```text
POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
```
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
Exportstatus:
- `prepared`
- `transferred`
- `booked`
- `cancelled`
Benachrichtigungen:
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.

View file

@ -176,49 +176,13 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv
- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures
## v0.8.9 - Lexware Office Integration Foundation
- Lexware Office als fuehrendes System fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR dokumentiert
- Olympus bleibt Werkstatt-ERP fuer Reparaturen, KVs, Lager und operative Stammdaten
- Lexware-Konfiguration unter `/settings -> Lexware Office`
- System-Settings fuer `lexware.enabled`, `lexware.api_base_url`, `lexware.api_key`, Organisationsname, Standard-MwSt. und Zahlungsziel
- Env-Fallbacks `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL`, `LEXWARE_API_KEY`
- API-Key wird nicht an Athena zurueckgegeben
- Server-seitiger Verbindungstest gegen `GET /v1/profile`
- Neue Tabelle `lexware_sync_records`
- Optionale Lexware-Rechnungsreferenzen an `repair_estimates`
- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
## v0.9.0 - Lexware Rechnungserstellung, geplant
- Echte Rechnungserstellung in Lexware nach final geprueftem API-Mapping
- Kontaktabgleich und Kontaktanlage in Lexware produktionsreif ausbauen
- Exportstatus und Fehlerbehebung im Olympus UI erweitern
- Optionaler Download/Link zur Lexware-Rechnung
## v0.9.1 - Backup und Restore
- Backup-Modul mit Athena-BFF und Hermes-Service-Layer
- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/`
- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups`
- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore`
- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus
- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung
- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh`
- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad
## v0.9.2 - Kundenportal, geplant
## v0.8.9 - Kundenportal, geplant
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
## v0.9.3 - Tickets, geplant
## v0.9.0 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
@ -226,10 +190,10 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen
## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant
## v0.10.0 - Integrationen Paperless/Lexoffice, geplant
- Paperless-ngx Connector fuer Wissensdokumente
- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
- Sichere Connector-Konfiguration ohne Browser-Secrets
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen

View file

@ -31,8 +31,3 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
LEXWARE_ENABLED=false
LEXWARE_API_BASE_URL=https://api.lexware.io
LEXWARE_API_KEY=

View file

@ -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))

View file

@ -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")

View file

@ -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")

View file

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

View file

@ -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")

View file

@ -4,12 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.rbac import Role
from app.models.audit import AuditLog
from app.models.lexware import LexwareSyncRecord
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.inventory_repository import InventoryRepository
@ -17,7 +15,6 @@ from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
from app.services.backup_service import BackupService
from app.services.system_settings_service import SystemSettingsService
logger = logging.getLogger(__name__)
@ -85,35 +82,6 @@ def get_dashboard_summary(
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)),
MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
])
if "lexware.read" in permissions:
repairs.extend([
MetricCard(
label="Vorbereitete Rechnungen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
MetricCard(
label="An Buchhaltung übergeben",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "transferred")
) or 0,
),
MetricCard(
label="Noch nicht übertragen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
])
if "inventory.read" in permissions:
@ -164,27 +132,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})
return DashboardSummary(

View file

@ -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)

View file

@ -124,19 +124,6 @@ def cancel_estimate(
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse)
def revoke_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.revoke")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
def list_estimate_events(
repair_id: int,

View file

@ -9,7 +9,7 @@ class Settings(BaseSettings):
secret_key: str
app_name: str = "Hermes API"
app_version: str = "0.9.1"
app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
@ -32,9 +32,6 @@ class Settings(BaseSettings):
smtp_from_email: str | None = None
smtp_from_name: str = "Funktechnik Schubert"
smtp_use_tls: bool = True
lexware_enabled: bool = False
lexware_api_base_url: str = "https://api.lexware.io"
lexware_api_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -25,11 +25,9 @@ import app.models.customer
import app.models.knowledge
import app.models.audit
import app.models.user
import app.models.inventory
import app.models.repair
import app.models.repair_estimate
import app.models.system_setting
import app.models.lexware
def get_db():

View file

@ -11,12 +11,10 @@ from starlette.requests import Request
from app.api.auth import router as auth_router
from app.api.audit import router as audit_router
from app.api.backups import router as backups_router
from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.inventory import router as inventory_router
from app.api.knowledge import router as knowledge_router
from app.api.lexware import router as lexware_router
from app.api.permissions import router as permissions_router
from app.api.repairs import router as repairs_router
from app.api.repair_estimates import router as repair_estimates_router
@ -34,13 +32,12 @@ configure_logging()
app = FastAPI(
title="Hermes API",
version="0.9.1",
version="0.1.0",
description="Backend von Olympus",
)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(backups_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
@ -51,7 +48,6 @@ app.include_router(repairs_router)
app.include_router(repair_estimates_router)
app.include_router(dashboard_router)
app.include_router(system_settings_router)
app.include_router(lexware_router)
logger = logging.getLogger(__name__)

View file

@ -103,11 +103,7 @@ class InventoryStockMovement(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
item: Mapped[InventoryItem] = relationship(back_populates="movements")
actor = relationship(
"User",
foreign_keys="InventoryStockMovement.actor_user_id",
lazy="joined",
)
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:

View file

@ -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)

View file

@ -56,11 +56,7 @@ class RepairStatusHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
actor = relationship(
"User",
foreign_keys="RepairStatusHistory.actor_user_id",
lazy="joined",
)
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
@ -109,11 +105,7 @@ class RepairDocument(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair: Mapped[Repair] = relationship(back_populates="documents")
uploaded_by = relationship(
"User",
foreign_keys="RepairDocument.uploaded_by_user_id",
lazy="joined",
)
uploaded_by = relationship("User", lazy="joined")
@property
def uploaded_by_username(self) -> str:

View file

@ -27,29 +27,12 @@ class RepairEstimate(Base):
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
lexware_invoice_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair = relationship("Repair", lazy="joined")
created_by = relationship(
"User",
foreign_keys="RepairEstimate.created_by_user_id",
lazy="joined",
)
accounting_transferred_by = relationship(
"User",
foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
lazy="joined",
)
created_by = relationship("User", lazy="joined")
items: Mapped[list["RepairEstimateItem"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
@ -101,8 +84,4 @@ class RepairEstimateEvent(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
actor = relationship(
"User",
foreign_keys="RepairEstimateEvent.actor_user_id",
lazy="joined",
)
actor = relationship("User", lazy="joined")

View file

@ -90,7 +90,6 @@ STANDARD_PERMISSIONS = [
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"),
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
@ -99,14 +98,6 @@ STANDARD_PERMISSIONS = [
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
]
ROLE_PERMISSION_NAMES = {
@ -128,7 +119,6 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"repair_estimates.revoke",
"inventory.read",
"inventory.create",
"inventory.update",
@ -136,12 +126,6 @@ ROLE_PERMISSION_NAMES = {
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
"lexware.read",
"lexware.manage",
"lexware.export",
"backup.read",
"backup.create",
"backup.download",
},
"sales": {
"dashboard.read",
@ -191,7 +175,6 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
"lexware.read",
},
"warehouse": {
"dashboard.read",

View file

@ -33,7 +33,7 @@ class RepairEstimateRepository:
select(RepairEstimate)
.options(selectinload(RepairEstimate.items))
.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())
.limit(1)
)
@ -123,14 +123,3 @@ class RepairEstimateRepository:
@staticmethod
def count_declined(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
@staticmethod
def count_revoked_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairEstimate.id))
.join(RepairEstimateEvent, RepairEstimateEvent.estimate_id == RepairEstimate.id)
.where(RepairEstimate.status == "revoked")
.where(RepairEstimateEvent.event_type == "revoked")
.where(func.date(RepairEstimateEvent.created_at) == today)
) or 0

View file

@ -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

View file

@ -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

View file

@ -4,10 +4,10 @@ from typing import Literal
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"]
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:
@ -142,14 +142,6 @@ class RepairEstimateResponse(BaseModel):
approved_at: datetime | None
declined_at: datetime | None
customer_response_message: str | None
lexware_invoice_id: str | None
lexware_invoice_number: str | None
lexware_invoice_status: str | None
lexware_synced_at: datetime | None
accounting_export_status: str | None
accounting_note: str | None
accounting_transferred_at: datetime | None
accounting_transferred_by_user_id: int | None
created_by_user_id: int | None
created_at: datetime
updated_at: datetime
@ -175,7 +167,6 @@ class PublicEstimateItemResponse(BaseModel):
class PublicEstimateResponse(BaseModel):
estimate_number: str
status: EstimateStatus
status_label: str
title: str
customer_message: str
subtotal_cents: int

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
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.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"inventory.items.create": "Lagerartikel erstellt",
"inventory.items.update": "Lagerartikel geändert",
@ -207,21 +206,6 @@ def action_title(action: str) -> str:
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
"lexware.settings.update": "Lexware Einstellungen geändert",
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
"backups.create": "Backup erstellt",
"backups.download": "Backup heruntergeladen",
"backups.delete": "Backup gelöscht",
"backups.validate": "Backup validiert",
"backups.restore_started": "Restore gestartet",
"backups.restore_failed": "Restore fehlgeschlagen",
"backups.restore_completed": "Restore abgeschlossen",
}
return labels.get(action, action)

View file

@ -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)

View file

@ -225,13 +225,7 @@ class InventoryService:
)
@staticmethod
def release_estimate_reservation(
db: Session,
estimate: RepairEstimate,
*,
actor_user_id: int | None,
reason: str = "estimate_released",
) -> None:
def release_estimate_reservation(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
@ -246,7 +240,7 @@ class InventoryService:
item_id=item.id,
movement_type="release",
quantity=quantity,
reason=reason,
reason="estimate_released",
reference_type="repair_estimate",
reference_id=estimate.id,
note=f"Kostenvoranschlag {estimate.estimate_number}",

View file

@ -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 {}

View file

@ -59,17 +59,6 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
}
ESTIMATE_STATUS_LABELS = {
"draft": "Entwurf",
"sent": "Wartet auf Freigabe",
"approved": "Freigegeben",
"declined": "Abgelehnt",
"expired": "Abgelaufen",
"cancelled": "Storniert",
"revoked": "Kostenvoranschlag wird überarbeitet",
}
class RepairEstimateService:
@staticmethod
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
@ -260,7 +249,7 @@ class RepairEstimateService:
@staticmethod
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")
was_sent = estimate.status == "sent"
if was_sent:
@ -292,98 +281,6 @@ class RepairEstimateService:
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status != "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
before_data = _audit_estimate_data(estimate)
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
estimate.status = "revoked"
RepairEstimateRepository.add_event(
db,
estimate_id=estimate.id,
event_type="revoked",
actor_type="user",
actor_user_id=actor.id,
note="Freigabe zurückgenommen",
commit=False,
)
db.commit()
db.refresh(estimate)
if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
RepairRepository.update_status(
db,
repair,
RepairStatusUpdate(
status="waiting_for_customer",
note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
),
actor_user_id=actor.id,
)
link = RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
subject = "Kostenvoranschlag wurde zurückgenommen"
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured and repair.customer_email:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=subject,
text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_revoked_mail",
channel="email",
recipient=repair.customer_email,
subject=subject,
template="repair_estimate_revoked",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.revoke",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_estimate_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
)
write_audit_log(
db,
action="inventory.estimate.release",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
)
return updated
@staticmethod
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
if estimate is None:
@ -391,7 +288,6 @@ class RepairEstimateService:
return PublicEstimateResponse(
estimate_number=estimate.estimate_number,
status=estimate.status,
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
title=estimate.title,
customer_message=estimate.customer_message,
subtotal_cents=estimate.subtotal_cents,
@ -633,30 +529,4 @@ class RepairEstimateService:
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
</td></tr></table></body></html>"""
@staticmethod
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
f"Reparatur: {repair.repair_number}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _revoked_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Status ansehen</a></p>
</td></tr></table></body></html>"""

View file

@ -7,7 +7,7 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API
APP_VERSION: 0.9.1
APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
@ -22,9 +22,6 @@ services:
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage

View file

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

View file

@ -10,7 +10,7 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API
APP_VERSION: 0.9.1
APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
@ -35,9 +35,6 @@ services:
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
LEXWARE_ENABLED: ${LEXWARE_ENABLED:-false}
LEXWARE_API_BASE_URL: ${LEXWARE_API_BASE_URL:-https://api.lexware.io}
LEXWARE_API_KEY: ${LEXWARE_API_KEY:-}
volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage

View file

@ -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)}`);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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>
);
}

View file

@ -175,8 +175,6 @@ export default function RepairDetailPage({ params }: Params) {
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke");
const canLexwareExport = hasPermission(currentUser, "lexware.export");
async function createPublicLink() {
if (!repair || !canManagePublicLink) return;
@ -444,8 +442,6 @@ export default function RepairDetailPage({ params }: Params) {
canUpdate={canUpdateEstimates}
canDelete={canDeleteEstimates}
canSend={canSendEstimates}
canRevoke={canRevokeEstimates}
canLexwareExport={canLexwareExport}
/>
</DetailSection>

View file

@ -2,17 +2,12 @@
import { useCallback, useEffect, useState } 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type {
LexwareSettings,
LexwareSettingsPayload,
LexwareTestConnectionResponse,
} from "@/types/lexware";
import type {
PublicLinksSettings,
SettingsSource,
@ -21,7 +16,7 @@ import type {
SmtpTestResponse,
} from "@/types/system-settings";
type SettingsTab = "smtp" | "public-links" | "lexware";
type SettingsTab = "smtp" | "public-links";
const sourceLabels: Record<SettingsSource, string> = {
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() {
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
@ -73,28 +56,22 @@ export default function SettingsPage() {
repair_status_base_url: "",
source: "missing",
});
const [lexware, setLexware] = useState<LexwareSettings>(emptyLexwareSettings());
const [newLexwareApiKey, setNewLexwareApiKey] = useState("");
const [loading, setLoading] = useState(true);
const [savingSmtp, setSavingSmtp] = useState(false);
const [sendingTest, setSendingTest] = useState(false);
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
const [savingLexware, setSavingLexware] = useState(false);
const [testingLexware, setTestingLexware] = useState(false);
const [error, setError] = useState("");
const loadSettings = useCallback(async () => {
setError("");
setLoading(true);
try {
const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([
const [smtpResponse, publicLinksResponse] = await Promise.all([
api.get<SmtpSettings>("/system-settings/smtp"),
api.get<PublicLinksSettings>("/system-settings/public-links"),
api.get<LexwareSettings>("/lexware/settings"),
]);
setSmtp(smtpResponse.data);
setPublicLinks(publicLinksResponse.data);
setLexware(lexwareResponse.data);
} catch (err) {
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
} 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) {
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>
<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 className="inline-flex w-fit rounded-lg border bg-white p-1">
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
@ -253,9 +180,6 @@ export default function SettingsPage() {
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
Öffentliche Links
</TabButton>
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
Lexware Office
</TabButton>
</div>
</div>
@ -363,7 +287,7 @@ export default function SettingsPage() {
</div>
</div>
</section>
) : activeTab === "public-links" ? (
) : (
<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>
@ -395,86 +319,6 @@ export default function SettingsPage() {
</p>
</div>
</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>
);

View file

@ -6,7 +6,6 @@ import {
BookOpen,
ClipboardList,
FileText,
HardDriveDownload,
LayoutDashboard,
Package,
Settings,
@ -79,12 +78,6 @@ const menu = [
name: "Dokumente",
href: "/documents",
},
{
icon: HardDriveDownload,
name: "Backups",
href: "/backups",
permission: "backup.read",
},
{
icon: Settings,
name: "Einstellungen",
@ -142,7 +135,7 @@ export default function Sidebar() {
</nav>
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
Olympus CRM v0.9.1
Olympus CRM v0.1
</div>
</aside>
);

View file

@ -18,8 +18,6 @@ type Props = {
description: string;
confirmLabel?: string;
pending?: boolean;
pendingLabel?: string;
confirmDisabled?: boolean;
children?: ReactNode;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
@ -31,8 +29,6 @@ export default function ConfirmDialog({
description,
confirmLabel = "Löschen",
pending = false,
pendingLabel = "Wird gelöscht...",
confirmDisabled = false,
children,
onOpenChange,
onConfirm,
@ -60,9 +56,9 @@ export default function ConfirmDialog({
type="button"
variant="destructive"
onClick={onConfirm}
disabled={pending || confirmDisabled}
disabled={pending}
>
{pending ? pendingLabel : confirmLabel}
{pending ? "Wird gelöscht..." : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import { useToast } from "@/components/common/ToastProvider";
@ -21,7 +21,6 @@ import type {
InventoryCategory,
InventoryItem,
} from "@/types/inventory";
import type { LexwareInvoicePreparation } from "@/types/lexware";
import type {
RepairEstimate,
RepairEstimateItemType,
@ -43,21 +42,6 @@ const statusLabels: Record<string, string> = {
declined: "Abgelehnt",
expired: "Abgelaufen",
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 {
@ -178,8 +162,6 @@ type Props = {
canUpdate: boolean;
canDelete: boolean;
canSend: boolean;
canRevoke: boolean;
canLexwareExport: boolean;
};
export default function RepairEstimatesSection({
@ -190,8 +172,6 @@ export default function RepairEstimatesSection({
canUpdate,
canDelete,
canSend,
canRevoke,
canLexwareExport,
}: Props) {
const { showToast } = useToast();
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
@ -204,11 +184,6 @@ export default function RepairEstimatesSection({
const [pendingId, setPendingId] = useState<number | 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[]>([]);
@ -413,68 +388,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() {
if (!deleteTarget) return;
setPendingId(deleteTarget.id);
@ -539,16 +452,6 @@ export default function RepairEstimatesSection({
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
</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">
{estimate.items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
@ -573,8 +476,6 @@ export default function RepairEstimatesSection({
{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>}
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(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>}
</div>
</article>
@ -708,115 +609,6 @@ export default function RepairEstimatesSection({
</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?"
@ -829,18 +621,6 @@ export default function RepairEstimatesSection({
{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
open={Boolean(deleteTarget)}
title="Kostenvoranschlag löschen?"
@ -864,37 +644,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 }) {
return (
<label className="block">

View file

@ -23,7 +23,6 @@ export const config = {
"/roles/:path*",
"/customers/:path*",
"/repairs/:path*",
"/backups/:path*",
"/settings/:path*",
],
};

View file

@ -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;
}

View file

@ -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;
}

View file

@ -23,7 +23,7 @@ export type RepairDocumentType =
| "shipping"
| "other";
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 interface Repair {
@ -216,14 +216,6 @@ export interface RepairEstimate {
approved_at: string | null;
declined_at: 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_at: string;
updated_at: string;

150
scripts/backup.sh Normal file → Executable file
View file

@ -1,139 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
cd "$(dirname "$0")/.."
BACKUP_DIR="${BACKUP_DIR:-./backups}"
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)"
FILENAME="olympus-backup-${TIMESTAMP}.zip"
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
TARGET_DIR="${BACKUP_DIR}/${TIMESTAMP}"
export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME
mkdir -p "${TARGET_DIR}"
python3 - <<'PY'
from pathlib import Path
import os
import shutil
echo "==> Creating backup in ${TARGET_DIR}"
source = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
target = Path(os.environ["WORK_DIR"]).resolve() / "storage"
source.mkdir(parents=True, exist_ok=True)
target.mkdir(parents=True, exist_ok=True)
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
echo "==> Creating PostgreSQL dump from container ${POSTGRES_CONTAINER}"
docker exec "${POSTGRES_CONTAINER}" pg_dump -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" > "${TARGET_DIR}/postgres.sql"
elif command -v pg_dump >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
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 source_path == backup_dir or backup_dir in source_path.parents:
continue
relative = source_path.relative_to(source)
destination = target / relative
if source_path.is_dir():
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
if [[ -d "${STORAGE_HOST_PATH}" ]]; then
echo "==> Archiving storage directory"
tar -czf "${TARGET_DIR}/storage.tar.gz" -C "${STORAGE_HOST_PATH}" .
else
echo "WARN: Storage directory ${STORAGE_HOST_PATH} not found; storage backup skipped."
fi
PG_DUMP_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_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}"
echo "INFO: .env is not copied automatically. Store production secrets separately and securely."
echo "==> Backup completed"

161
scripts/restore.sh Normal file → Executable file
View file

@ -1,151 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
cd "$(dirname "$0")/.."
BACKUP_SOURCE="${1:-}"
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
echo "Usage: scripts/restore.sh <backup-zip>" >&2
if [[ -z "${BACKUP_SOURCE}" || ! -d "${BACKUP_SOURCE}" ]]; then
echo "Usage: scripts/restore.sh <backup-directory>" >&2
exit 1
fi
if [[ -z "$DATABASE_URL" ]]; then
echo "DATABASE_URL ist erforderlich." >&2
exit 1
fi
echo "This restore can overwrite database and storage state."
echo "Backup source: ${BACKUP_SOURCE}"
echo "Storage target: ${STORAGE_HOST_PATH}"
read -r -p "Type RESTORE to continue: " confirmation
if ! command -v pg_restore >/dev/null 2>&1; then
echo "pg_restore ist nicht verfuegbar." >&2
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."
if [[ "${confirmation}" != "RESTORE" ]]; then
echo "Restore cancelled"
exit 0
fi
if command -v docker >/dev/null 2>&1; then
read -r -p "Olympus-Dienste jetzt per docker compose stoppen? [y/N] " stop_reply
if [[ "$stop_reply" =~ ^[Yy]$ ]]; then
docker compose stop athena hermes
STOPPED_SERVICES=1
if [[ -f "${BACKUP_SOURCE}/postgres.sql" ]]; then
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
echo "==> Restoring PostgreSQL dump into container ${POSTGRES_CONTAINER}"
docker exec -i "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" < "${BACKUP_SOURCE}/postgres.sql"
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
else
echo "WARN: postgres.sql not found; database restore skipped."
fi
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.restore.XXXXXX")"
trap 'rm -rf "$WORK_DIR"; if [[ "$STOPPED_SERVICES" -eq 1 ]]; then docker compose start hermes athena; fi' EXIT
if [[ -f "${BACKUP_SOURCE}/storage.tar.gz" ]]; then
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'
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}"
echo "==> Restore completed"