diff --git a/.env.example b/.env.example index 155c54e..afd6786 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,6 @@ STORAGE_PROVIDER=local STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage -# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume. # Legacy-Fallback fuer bestehende Knowledge-Installationen. KNOWLEDGE_STORAGE_PATH=/data/knowledge @@ -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= diff --git a/.gitignore b/.gitignore index 5bd760f..b36a9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,6 @@ out/ # Runtime storage # =========================== storage/ -/backups/ -*.dump # =========================== # Coverage diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3bced00..58f6fea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,58 +166,6 @@ Hermes Athena ist die einzige API-Oberflaeche fuer den Browser. -## Lexware Office Integration - -Ab v0.8.9 besitzt Olympus eine Lexware-Office-Foundation. - -Verantwortlichkeiten: - -- Olympus bleibt Werkstatt-ERP fuer Kunden, Reparaturen, Lager und Kostenvoranschlaege. -- Lexware Office bleibt fuehrend fuer Buchhaltung, Rechnungen, Steuer, DATEV und EÜR. -- Eine automatische Rechnungserstellung findet in v0.8.9 noch nicht statt. - -Komponenten: - -- Hermes speichert Lexware-Konfiguration in `system_settings`. -- Der API-Key ist ein Secret und wird nie an Athena zurueckgegeben; Athena sieht nur `api_key_is_set`. -- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`. -- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`. -- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden. -- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt. - -Datenfluss: - -```text -Browser -> Athena /api/lexware/... -> Hermes -> Lexware Office -Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreSQL -``` - -Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert. - -Buchhaltungsworkflow: - -- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt. -- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen. -- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet. -- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet. - -Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren. - -Neue zentrale Endpunkte: - -- `GET /lexware/settings` -- `PUT /lexware/settings` -- `POST /lexware/test-connection` -- `POST /repairs/{repair_id}/estimates/{estimate_id}/lexware/prepare-invoice` - -RBAC: - -- `lexware.read` -- `lexware.manage` -- `lexware.export` - -Techniker erhalten keine Lexware-Rechte. Management und Administratoren koennen die Integration nutzen. - ## Dashboard Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`. @@ -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 diff --git a/README-DEV.md b/README-DEV.md index d309d74..3e515cd 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -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 ` 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`. diff --git a/ROADMAP.md b/ROADMAP.md index 7c0efb0..e92efb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index cfaeb3b..6f7a6d0 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -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= diff --git a/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py deleted file mode 100644 index 58c82c7..0000000 --- a/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py +++ /dev/null @@ -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)) diff --git a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py deleted file mode 100644 index bf98008..0000000 --- a/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py +++ /dev/null @@ -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") diff --git a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py deleted file mode 100644 index 0c7936b..0000000 --- a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py +++ /dev/null @@ -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") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index 5704b10..3912a67 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -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."): diff --git a/backend/hermes/app/api/backups.py b/backend/hermes/app/api/backups.py deleted file mode 100644 index 75e131e..0000000 --- a/backend/hermes/app/api/backups.py +++ /dev/null @@ -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") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 02d2752..a63f72d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -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( diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py deleted file mode 100644 index 03b20c0..0000000 --- a/backend/hermes/app/api/lexware.py +++ /dev/null @@ -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) diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py index 260754c..56ab0fb 100644 --- a/backend/hermes/app/api/repair_estimates.py +++ b/backend/hermes/app/api/repair_estimates.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from starlette.requests import Request -from app.core.rbac import require_any_permission, require_permission +from app.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 @@ -117,26 +117,13 @@ def cancel_estimate( estimate_id: int, request: Request, db: Session = Depends(get_db), - current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])), + current_user: User = Depends(require_permission("repair_estimates.update")), ): repair = get_repair_or_404(db, repair_id) estimate = get_estimate_or_404(db, repair_id, estimate_id) return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request) -@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse) -def revoke_estimate( - repair_id: int, - estimate_id: int, - request: Request, - db: Session = Depends(get_db), - current_user: User = Depends(require_permission("repair_estimates.revoke")), -): - repair = get_repair_or_404(db, repair_id) - estimate = get_estimate_or_404(db, repair_id, estimate_id) - return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request) - - @router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse]) def list_estimate_events( repair_id: int, diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 2a3d97f..9590c60 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -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", diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index c860b24..dcc29f4 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -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(): diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 04c84ca..3b7c410 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -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__) diff --git a/backend/hermes/app/models/inventory.py b/backend/hermes/app/models/inventory.py index ecfdfc9..9428b9e 100644 --- a/backend/hermes/app/models/inventory.py +++ b/backend/hermes/app/models/inventory.py @@ -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: diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py deleted file mode 100644 index febb874..0000000 --- a/backend/hermes/app/models/lexware.py +++ /dev/null @@ -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) diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index 6b32497..53ba9e2 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -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: diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 9cdda96..696a060 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -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") diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 6661bff..1191da6 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -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", diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py index db20a7c..7519a94 100644 --- a/backend/hermes/app/repositories/repair_estimate_repository.py +++ b/backend/hermes/app/repositories/repair_estimate_repository.py @@ -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 diff --git a/backend/hermes/app/schemas/backup.py b/backend/hermes/app/schemas/backup.py deleted file mode 100644 index 661d1e9..0000000 --- a/backend/hermes/app/schemas/backup.py +++ /dev/null @@ -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 diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py deleted file mode 100644 index 45cd103..0000000 --- a/backend/hermes/app/schemas/lexware.py +++ /dev/null @@ -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 diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index b1b68e5..75b942d 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -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 diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 14110e0..d986106 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -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) diff --git a/backend/hermes/app/services/backup_service.py b/backend/hermes/app/services/backup_service.py deleted file mode 100644 index b84ffe2..0000000 --- a/backend/hermes/app/services/backup_service.py +++ /dev/null @@ -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) diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py index 49afd72..01b082c 100644 --- a/backend/hermes/app/services/inventory_service.py +++ b/backend/hermes/app/services/inventory_service.py @@ -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}", diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py deleted file mode 100644 index 61ffe38..0000000 --- a/backend/hermes/app/services/lexware_service.py +++ /dev/null @@ -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 {} diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py index f096b84..b9b4ee8 100644 --- a/backend/hermes/app/services/repair_estimate_service.py +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -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: Kostenvoranschlag: {escape(estimate.estimate_number)}
Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}

Kostenvoranschlag ansehen

-""" - - @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""" - - - -
Funktechnik Schubert
-

Hallo {escape(repair.customer_name)},

-

Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.

-

Reparatur: {escape(repair.repair_number)}
-Kostenvoranschlag: {escape(estimate.estimate_number)}

-

Status ansehen

""" diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 4fb2749..a2215df 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -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 diff --git a/backend/hermes/dockerfile b/backend/hermes/dockerfile index 11c1906..71f483b 100644 --- a/backend/hermes/dockerfile +++ b/backend/hermes/dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3c8ddf9..5ad8a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/athena/app/api/backups/[filename]/delete/route.ts b/frontend/athena/app/api/backups/[filename]/delete/route.ts deleted file mode 100644 index 0273b73..0000000 --- a/frontend/athena/app/api/backups/[filename]/delete/route.ts +++ /dev/null @@ -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)}`); -} diff --git a/frontend/athena/app/api/backups/[filename]/download/route.ts b/frontend/athena/app/api/backups/[filename]/download/route.ts deleted file mode 100644 index e5827f9..0000000 --- a/frontend/athena/app/api/backups/[filename]/download/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/api/backups/[filename]/restore/route.ts b/frontend/athena/app/api/backups/[filename]/restore/route.ts deleted file mode 100644 index 3b204b4..0000000 --- a/frontend/athena/app/api/backups/[filename]/restore/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/api/backups/[filename]/validate/route.ts b/frontend/athena/app/api/backups/[filename]/validate/route.ts deleted file mode 100644 index 36037b3..0000000 --- a/frontend/athena/app/api/backups/[filename]/validate/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/api/backups/create/route.ts b/frontend/athena/app/api/backups/create/route.ts deleted file mode 100644 index 3928270..0000000 --- a/frontend/athena/app/api/backups/create/route.ts +++ /dev/null @@ -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"); -} diff --git a/frontend/athena/app/api/backups/route.ts b/frontend/athena/app/api/backups/route.ts deleted file mode 100644 index ccf2978..0000000 --- a/frontend/athena/app/api/backups/route.ts +++ /dev/null @@ -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"); -} diff --git a/frontend/athena/app/api/lexware/settings/route.ts b/frontend/athena/app/api/lexware/settings/route.ts deleted file mode 100644 index 3eb117a..0000000 --- a/frontend/athena/app/api/lexware/settings/route.ts +++ /dev/null @@ -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"); -} diff --git a/frontend/athena/app/api/lexware/test-connection/route.ts b/frontend/athena/app/api/lexware/test-connection/route.ts deleted file mode 100644 index 521df64..0000000 --- a/frontend/athena/app/api/lexware/test-connection/route.ts +++ /dev/null @@ -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"); -} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts deleted file mode 100644 index c95e919..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts deleted file mode 100644 index 9e3f9b7..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts deleted file mode 100644 index 140c741..0000000 --- a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts +++ /dev/null @@ -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`); -} diff --git a/frontend/athena/app/backups/page.tsx b/frontend/athena/app/backups/page.tsx deleted file mode 100644 index 3c70d97..0000000 --- a/frontend/athena/app/backups/page.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - const [validationByFile, setValidationByFile] = useState>({}); - const [validatingFile, setValidatingFile] = useState(""); - const [restoreTarget, setRestoreTarget] = useState(null); - const [restoreConfirm, setRestoreConfirm] = useState(""); - const [restoring, setRestoring] = useState(false); - const [currentUser, setCurrentUser] = useState(null); - - const loadBackups = useCallback(async () => { - setError(""); - try { - const response = await api.get>("/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("/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>("/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>(`/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
Backups werden geladen...
; - } - - if (error || !data) { - return
{error || "Keine Backups verfuegbar"}
; - } - - return ( -
-
-
-

Backups

-

PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.

-
-
- - {canCreate && ( - - )} -
-
- -
- {cards.map((card) => ( - - ))} -
- -
-
-

Verfuegbare Backup-Dateien

-
- {data.items.length === 0 ? ( -
Noch keine Backup-Dateien vorhanden.
- ) : ( -
- {data.items.map((backup) => { - const validation = validationByFile[backup.filename]; - return ( -
-
-
-
-

{backup.filename}

- - {backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"} - -
-
-
-

Erstellt

-

{formatDate(backup.created_at)}

-
-
-

Groesse

-

{formatBytes(backup.size_bytes)}

-
-
-

Version

-

{backup.app_version || "Unbekannt"}

-
-
-

Ersteller

-

{backup.created_by_username || "System"}

-
-
-
-
-

Datenbank

-

{backup.database_name || "Unbekannt"}

-
-
-

Dateien

-

{backup.file_count}

-
-
-

Storage-Pfad

-

{backup.storage_base_path || "-"}

-
-
- {validation && ( -
-
- {validation.valid ? : } -
-

{validation.message}

-

Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}

- {!validation.valid && validation.issues.length > 0 && ( -

{validation.issues.join(" | ")}

- )} - {validation.requires_cli_restore && ( -

Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.

- )} -
-
-
- )} -
-
- {canDownload && ( - - - Download - - )} - {canRestore && ( - <> - - - - )} - {canDelete && ( - - )} -
-
-
- ); - })} -
- )} -
- - { - if (!open) { - setDeleteTarget(null); - } - }} - onConfirm={() => void deleteBackup()} - > - {deleteTarget && ( -
-

{deleteTarget.filename}

-

{formatBytes(deleteTarget.size_bytes)}

-
- )} -
- - { - if (!open) { - setRestoreTarget(null); - setRestoreConfirm(""); - } - }} - onConfirm={() => void restoreBackup()} - > -
-
-
- -

Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.

-
-
-
-

Bestaetigungstext

- setRestoreConfirm(event.target.value)} - placeholder={RESTORE_CONFIRM_TEXT} - /> -
- {restoreTarget && ( -

- Ziel-Backup: {restoreTarget.filename} -

- )} -
-
-
- ); -} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 9c4cec3..78a1394 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -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} /> diff --git a/frontend/athena/app/settings/page.tsx b/frontend/athena/app/settings/page.tsx index f5e0059..70b3494 100644 --- a/frontend/athena/app/settings/page.tsx +++ b/frontend/athena/app/settings/page.tsx @@ -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 = { 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("smtp"); @@ -73,28 +56,22 @@ export default function SettingsPage() { repair_status_base_url: "", source: "missing", }); - const [lexware, setLexware] = useState(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("/system-settings/smtp"), api.get("/system-settings/public-links"), - api.get("/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("/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("/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
Einstellungen werden geladen...
; } @@ -244,7 +171,7 @@ export default function SettingsPage() {

Einstellungen

-

SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten

+

SMTP-Versand und öffentliche Statuslinks verwalten

setActiveTab("smtp")}> @@ -253,9 +180,6 @@ export default function SettingsPage() { setActiveTab("public-links")}> Öffentliche Links - setActiveTab("lexware")}> - Lexware Office -
@@ -363,7 +287,7 @@ export default function SettingsPage() { - ) : activeTab === "public-links" ? ( + ) : (
@@ -395,86 +319,6 @@ export default function SettingsPage() {

- ) : ( -
-
-
-
- -

Lexware Office

-
-

- Quelle: {sourceLabels[lexware.source]} - {lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""} -

-
-
- - -
-
- -
- - - - setLexware((current) => ({ ...current, organization_name: event.target.value }))} /> - - - - setLexware((current) => ({ ...current, api_base_url: event.target.value }))} /> - - - - setNewLexwareApiKey(event.target.value)} - /> - - - - setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))} - /> - - - - setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))} - /> - -
- -
- -

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

-
-
)} ); diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 02d54e2..00ed94e 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -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() {
- Olympus CRM v0.9.1 + Olympus CRM v0.1
); diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx index f2fa6da..a3e1b37 100644 --- a/frontend/athena/components/common/ConfirmDialog.tsx +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -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} diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 58b89f1..94de38e 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -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 = { declined: "Abgelehnt", expired: "Abgelaufen", cancelled: "Storniert", - revoked: "Zurückgenommen", -}; - -const accountingStatusLabels: Record = { - prepared: "Vorbereitet", - transferred: "Übertragen", - booked: "Gebucht", - cancelled: "Storniert", -}; - -const accountingStatusClasses: Record = { - 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([]); @@ -203,12 +183,6 @@ export default function RepairEstimatesSection({ const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [cancelTarget, setCancelTarget] = useState(null); - const [revokeTarget, setRevokeTarget] = useState(null); - const [lexwareResult, setLexwareResult] = useState(null); - const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false); - const [accountingTarget, setAccountingTarget] = useState(null); - const [accountingNote, setAccountingNote] = useState(""); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -398,13 +372,11 @@ export default function RepairEstimatesSection({ } } - async function cancelEstimate() { - if (!cancelTarget) return; - setPendingId(cancelTarget.id); + async function cancelEstimate(estimate: RepairEstimate) { + setPendingId(estimate.id); try { - await api.post(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`); + await api.post(`/repairs/${repairId}/estimates/${estimate.id}/cancel`); await loadEstimates(); - setCancelTarget(null); showToast({ type: "success", title: "Kostenvoranschlag storniert" }); } catch (err) { showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) }); @@ -413,68 +385,6 @@ export default function RepairEstimatesSection({ } } - async function revokeEstimate() { - if (!revokeTarget) return; - setPendingId(revokeTarget.id); - try { - await api.post(`/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(`/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( - `/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 +449,6 @@ export default function RepairEstimatesSection({ - {estimate.accounting_export_status && ( -
- - Buchhaltung - - {estimate.accounting_transferred_at && {dateTime(estimate.accounting_transferred_at)}} - {estimate.accounting_note && {estimate.accounting_note}} -
- )} -
{estimate.items.map((item) => (
@@ -572,9 +472,7 @@ export default function RepairEstimatesSection({
{canUpdate && ["draft", "sent"].includes(estimate.status) && } {canSend && ["draft", "sent"].includes(estimate.status) && } - {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } - {canRevoke && estimate.status === "approved" && } - {canLexwareExport && estimate.status === "approved" && } + {canUpdate && ["draft", "sent"].includes(estimate.status) && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
@@ -708,139 +606,6 @@ export default function RepairEstimatesSection({ - - - - In Buchhaltung übernehmen - Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe. - - {lexwareResult && ( -
-
-
- Exportstatus - - - - - -
-

Rechnungsvorbereitung #{lexwareResult.sync_record_id}

- {lexwareResult.transferred_at &&

Übertragen am {dateTime(lexwareResult.transferred_at)}

} -
- - {lexwareResult.warnings.length > 0 && ( -
-

Warnungen

-
    - {lexwareResult.warnings.map((warning) =>
  • {warning}
  • )} -
-
- )} - -
- - void copyText("Kundendaten", text)} - /> - -
- -
- -
- Position - Steuer - Summe -
- {lexwareResult.line_item_mapping.map((item, index) => ( -
-
-

{item.title}

-

{item.quantity} {item.unit} × {item.unit_price} €

-
- {item.tax_rate} % - {item.total} € -
- ))} - ( - `${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)} - /> - -
- -
- - - -
- -
- - -
-