From 663bdc41b14509f63ee40e89a1c68a4d9f9085a4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 11:54:23 +0200 Subject: [PATCH 1/7] feat(estimates): integrate inventory items --- ARCHITECTURE.md | 16 +- README-DEV.md | 14 +- ROADMAP.md | 13 +- ..._extend_repair_estimate_items_inventory.py | 44 ++++ backend/hermes/app/api/dashboard.py | 3 + backend/hermes/app/api/inventory.py | 12 ++ backend/hermes/app/models/repair_estimate.py | 6 + .../app/repositories/inventory_repository.py | 42 ++++ backend/hermes/app/schemas/repair_estimate.py | 19 +- backend/hermes/app/services/audit_service.py | 5 + .../hermes/app/services/inventory_service.py | 91 ++++++++ .../app/services/repair_estimate_service.py | 201 ++++++++++++++++-- .../app/api/inventory/items/search/route.ts | 7 + .../repairs/RepairEstimatesSection.tsx | 178 +++++++++++++++- frontend/athena/types/repair.ts | 6 + 15 files changed, 629 insertions(+), 28 deletions(-) create mode 100644 backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py create mode 100644 frontend/athena/app/api/inventory/items/search/route.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8134159..58f6fea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -221,7 +221,7 @@ Tabellen: ## Lager / Ersatzteile -Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet. +Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen. Datenfluss: @@ -238,6 +238,9 @@ Kernregeln: - `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert. - Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation. - Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht. +- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft. +- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird. +- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert. Tabellen: @@ -271,7 +274,16 @@ RBAC-Permissions: Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten. -Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. +Reservierungslogik: + +- Entwurf: keine Reservierung. +- Senden: verknuepfte Lagerartikel werden reserviert. +- Freigabe: Reservierung bleibt bestehen. +- Ablehnung: Reservierung wird freigegeben. +- Storno: Reservierung wird freigegeben. +- Loeschung eines gesendeten KV: Reservierung wird freigegeben. + +Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. Hermes-Endpunkte: diff --git a/README-DEV.md b/README-DEV.md index 70c613f..3e515cd 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -228,6 +228,7 @@ SECRET_KEY=local-check uv run alembic upgrade head Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen: - `/api/inventory/items` +- `/api/inventory/items/search` - `/api/inventory/items/[id]` - `/api/inventory/items/[id]/stock/adjust` - `/api/inventory/items/[id]/stock/reserve` @@ -251,10 +252,19 @@ Bestandslogik: - Artikel mit Bewegungen werden deaktiviert statt hart geloescht. - Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`. +KV-Integration ab v0.8.8: + +- Lagerartikel koennen im KV-Dialog gesucht und als Position uebernommen werden. +- Hermes uebernimmt Verkaufspreis, Einheit, Artikelname, SKU und Hersteller serverseitig. +- `repair_estimate_items` speichert Snapshot-Felder, damit spaetere Lageraenderungen alte KVs nicht veraendern. +- Nur gesendete KVs reservieren Bestand. +- Ablehnung, Storno und Loeschung eines gesendeten KV geben Reservierungen frei. +- Freigegebene KVs behalten die Reservierung. +- Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, aber noch nicht aktiv. + Folgefeatures: -- Integration in Kostenvoranschlaege folgt in v0.8.8. -- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv. +- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv. Vorbereitete Website-/Portal-Routen fuer spaeter: diff --git a/ROADMAP.md b/ROADMAP.md index 3b10ef2..e92efb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,12 +162,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8 - Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion -## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant +## v0.8.8 - Lagerintegration in Kostenvoranschläge - Lagerartikel als KV-Position ueber Suche auswaehlen - Verkaufspreis aus `selling_price_cents` uebernehmen -- Reservierungen aus KV-/Reparaturprozess vorbereiten -- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten +- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen +- Reservierung beim Senden eines KV +- Freigabe behaelt Reservierung +- Ablehnung, Storno und Loeschung geben Reservierungen frei +- Bestandsbewegungen mit `reference_type=repair_estimate` +- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand +- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung +- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv +- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures ## v0.8.9 - Kundenportal, geplant diff --git a/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py new file mode 100644 index 0000000..27dac33 --- /dev/null +++ b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py @@ -0,0 +1,44 @@ +"""extend repair estimate items inventory + +Revision ID: f4a9c2d7e118 +Revises: ef8a2d5c9017 +Create Date: 2026-07-05 13:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f4a9c2d7e118" +down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True)) + op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False) + op.create_foreign_key( + "fk_repair_estimate_items_inventory_item_id", + "repair_estimate_items", + "inventory_items", + ["inventory_item_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey") + op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items") + op.drop_column("repair_estimate_items", "inventory_snapshot_part_number") + op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer") + op.drop_column("repair_estimate_items", "inventory_snapshot_sku") + op.drop_column("repair_estimate_items", "inventory_snapshot_name") + op.drop_column("repair_estimate_items", "inventory_item_id") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index c3201b4..a63f72d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -89,6 +89,9 @@ def get_dashboard_summary( MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)), MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)), + MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)), + MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)), + MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)), MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))), ] diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py index b09f534..8fd3466 100644 --- a/backend/hermes/app/api/inventory.py +++ b/backend/hermes/app/api/inventory.py @@ -81,6 +81,18 @@ def list_items( return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset) +@router.get("/items/search", response_model=list[InventoryItemResponse]) +def search_items( + q: str | None = None, + category: int | None = None, + manufacturer: str | None = None, + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit) + + @router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) def create_item( payload: InventoryItemCreate, diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 81ec866..696a060 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -52,6 +52,11 @@ class RepairEstimateItem(Base): id: Mapped[int] = mapped_column(primary_key=True) estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True) + inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True) + inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="") + inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="") + inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True) + inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True) position: Mapped[int] = mapped_column(Integer) item_type: Mapped[str] = mapped_column(String(40), index=True) title: Mapped[str] = mapped_column(String(255)) @@ -64,6 +69,7 @@ class RepairEstimateItem(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) estimate: Mapped[RepairEstimate] = relationship(back_populates="items") + inventory_item = relationship("InventoryItem", lazy="joined") class RepairEstimateEvent(Base): diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py index ff2fe8e..19c2db2 100644 --- a/backend/hermes/app/repositories/inventory_repository.py +++ b/backend/hermes/app/repositories/inventory_repository.py @@ -69,6 +69,32 @@ class InventoryRepository: ) return items, total + @staticmethod + def search_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + manufacturer: str | None = None, + limit: int = 20, + ) -> list[InventoryItem]: + query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True)) + if q: + pattern = f"%{q.strip()}%" + query = query.where(or_( + InventoryItem.sku.ilike(pattern), + InventoryItem.name.ilike(pattern), + InventoryItem.manufacturer.ilike(pattern), + InventoryItem.manufacturer_part_number.ilike(pattern), + InventoryItem.supplier_part_number.ilike(pattern), + InventoryItem.compatible_devices.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if manufacturer: + query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%")) + return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit))) + @staticmethod def get_item(db: Session, item_id: int) -> InventoryItem | None: return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id)) @@ -176,6 +202,22 @@ class InventoryRepository: .where(InventoryItem.purchase_price_cents.is_not(None)) ) or 0 + @staticmethod + def count_reserved_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_reserved > 0) + ) or 0 + + @staticmethod + def reserved_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.selling_price_cents.is_not(None)) + ) or 0 + @staticmethod def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]: return list( diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 3410a04..75b942d 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -18,7 +18,9 @@ def normalize_text(value: object) -> str: class RepairEstimateItemPayload(BaseModel): item_type: EstimateItemType = "other" - title: str = Field(min_length=1, max_length=255) + inventory_item_id: int | None = None + inventory_price_overridden: bool = False + title: str = Field(default="", max_length=255) description: str | None = None quantity: Decimal = Field(gt=Decimal("0")) unit: str = Field(default="Stk.", max_length=40) @@ -38,6 +40,12 @@ class RepairEstimateItemPayload(BaseModel): return value.strip().replace(",", ".") return value + @model_validator(mode="after") + def validate_inventory_or_title(self): + if self.inventory_item_id is None and not self.title.strip(): + raise ValueError("Titel ist erforderlich") + return self + class RepairEstimatePayload(BaseModel): title: str = Field(min_length=1, max_length=255) @@ -85,6 +93,11 @@ class RepairEstimateUpdate(RepairEstimatePayload): class RepairEstimateItemResponse(BaseModel): id: int estimate_id: int + inventory_item_id: int | None + inventory_snapshot_name: str + inventory_snapshot_sku: str + inventory_snapshot_manufacturer: str | None + inventory_snapshot_part_number: str | None position: int item_type: EstimateItemType title: str @@ -139,6 +152,10 @@ class RepairEstimateResponse(BaseModel): class PublicEstimateItemResponse(BaseModel): item_type: EstimateItemType + inventory_snapshot_name: str + inventory_snapshot_sku: str + inventory_snapshot_manufacturer: str | None + inventory_snapshot_part_number: str | None title: str description: str | None quantity: Decimal diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d46ef66..d986106 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -188,6 +188,11 @@ def action_title(action: str) -> str: "inventory.stock.reserve": "Bestand reserviert", "inventory.stock.release": "Reservierung aufgehoben", "inventory.stock.consume": "Bestand verbraucht", + "inventory.estimate.reserve": "KV reserviert Lagerbestand", + "inventory.estimate.release": "KV-Reservierung aufgehoben", + "inventory.estimate.consume": "Reservierung verbraucht", + "inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben", + "inventory.stock.low": "Lagerbestand knapp", "inventory.categories.create": "Lagerkategorie erstellt", "inventory.categories.update": "Lagerkategorie geändert", "inventory.categories.delete": "Lagerkategorie gelöscht", diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py index 4960557..01b082c 100644 --- a/backend/hermes/app/services/inventory_service.py +++ b/backend/hermes/app/services/inventory_service.py @@ -1,5 +1,6 @@ import re import unicodedata +from decimal import Decimal from typing import TypeVar from fastapi import HTTPException, status @@ -8,6 +9,7 @@ from sqlalchemy.orm import Session from starlette.requests import Request from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.repair_estimate import RepairEstimate from app.models.user import User from app.repositories.inventory_repository import InventoryRepository from app.schemas.inventory import ( @@ -194,6 +196,83 @@ class InventoryService: InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id) return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request) + @staticmethod + def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None or not item.is_active: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv") + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_available: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}", + ) + item.quantity_reserved += quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="reservation", + quantity=quantity, + reason="estimate_reserved", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + item.quantity_reserved = max(0, item.quantity_reserved - quantity) + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="release", + quantity=quantity, + reason="estimate_released", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_reserved or quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus") + item.quantity_reserved -= quantity + item.quantity_on_hand -= quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="consumption", + quantity=quantity, + reason="estimate_consumed", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + @staticmethod def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description) @@ -260,6 +339,18 @@ class InventoryService: if payload.quantity <= 0: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein") + @staticmethod + def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int: + if quantity != quantity.to_integral_value(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Lagerposition {position} muss eine ganze Menge verwenden", + ) + normalized = int(quantity) + if normalized <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0") + return normalized + @staticmethod def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None: InventoryService._recalculate_available(item) diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py index e933587..b9b4ee8 100644 --- a/backend/hermes/app/services/repair_estimate_service.py +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -10,6 +10,7 @@ from starlette.requests import Request from app.models.repair import Repair from app.models.repair_estimate import RepairEstimate, RepairEstimateItem from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.repair_repository import RepairRepository from app.schemas.repair import RepairStatusUpdate @@ -24,6 +25,7 @@ from app.schemas.repair_estimate import ( calculate_tax, ) from app.services.audit_service import write_audit_log +from app.services.inventory_service import InventoryService from app.services.repair_public_link_service import RepairPublicLinkService from app.services.system_settings_service import SystemSettingsService @@ -53,6 +55,7 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict: "approved_at": estimate.approved_at, "declined_at": estimate.declined_at, "created_by_user_id": estimate.created_by_user_id, + "inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None], } @@ -66,7 +69,8 @@ class RepairEstimateService: status="draft", created_by_user_id=actor.id, ) - RepairEstimateService._apply_payload(estimate, payload) + price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) + RepairEstimateService._apply_payload(db, estimate, payload) try: db.add(estimate) db.flush() @@ -92,6 +96,7 @@ class RepairEstimateService: after_data=_audit_estimate_data(estimate), metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request) return estimate @staticmethod @@ -99,7 +104,24 @@ class RepairEstimateService: if estimate.status not in {"draft", "sent"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden") before_data = _audit_estimate_data(estimate) - RepairEstimateService._apply_payload(estimate, payload) + price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) + was_sent = estimate.status == "sent" + if was_sent: + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) + RepairEstimateService._apply_payload(db, estimate, payload) + if was_sent: + InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) + write_audit_log( + db, + action="inventory.estimate.reserve", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False) db.commit() db.refresh(estimate) @@ -116,14 +138,27 @@ class RepairEstimateService: after_data=_audit_estimate_data(updated), metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request) return updated @staticmethod def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None: - if estimate.status not in {"draft", "cancelled"}: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden") + if estimate.status not in {"draft", "sent", "cancelled"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden") before_data = _audit_estimate_data(estimate) label = _estimate_label(estimate) + if estimate.status == "sent": + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=label, + actor=actor, + request=request, + metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number}, + ) RepairEstimateRepository.delete(db, estimate) write_audit_log( db, @@ -145,6 +180,10 @@ class RepairEstimateService: if not repair.customer_email: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt") + was_sent = estimate.status == "sent" + if not was_sent: + InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) + link = RepairPublicLinkService.create_with_audit( db, repair, @@ -155,6 +194,18 @@ class RepairEstimateService: public_status_url = link.public_status_path estimate = RepairEstimateRepository.mark_sent(db, estimate) RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id) + if not was_sent: + write_audit_log( + db, + action="inventory.estimate.reserve", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) smtp_config = SystemSettingsService.get_smtp_runtime_config(db) mail_sent = False if smtp_config.is_configured: @@ -200,6 +251,9 @@ class RepairEstimateService: def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate: 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: + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) estimate.status = "cancelled" RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False) db.commit() @@ -214,6 +268,17 @@ class RepairEstimateService: request=request, metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + if was_sent: + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number}, + ) return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate @staticmethod @@ -233,6 +298,10 @@ class RepairEstimateService: items=[ PublicEstimateItemResponse( item_type=item.item_type, + inventory_snapshot_name=item.inventory_snapshot_name, + inventory_snapshot_sku=item.inventory_snapshot_sku, + inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer, + inventory_snapshot_part_number=item.inventory_snapshot_part_number, title=item.title, description=item.description, quantity=item.quantity, @@ -272,6 +341,7 @@ class RepairEstimateService: repair_status = "waiting_for_customer" note = "Kunde hat den Kostenvoranschlag abgelehnt" audit_action = "repair_estimates.decline" + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None) else: event_type = "question" repair_status = "waiting_for_customer" @@ -291,17 +361,27 @@ class RepairEstimateService: request=request, metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"}, ) + if decision == "decline": + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + request=request, + metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"}, + ) return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate @staticmethod - def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None: + def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None: estimate.title = payload.title estimate.customer_message = payload.customer_message estimate.internal_note = payload.internal_note estimate.tax_rate_percent = payload.tax_rate_percent estimate.currency = payload.currency estimate.valid_until = payload.valid_until - items, subtotal = RepairEstimateService._build_items(payload.items) + items, subtotal = RepairEstimateService._build_items(db, payload.items) tax_cents = calculate_tax(subtotal, payload.tax_rate_percent) estimate.subtotal_cents = subtotal estimate.tax_cents = tax_cents @@ -309,26 +389,121 @@ class RepairEstimateService: estimate.items = items @staticmethod - def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]: + def _build_items( + db: Session, + payload_items: list[RepairEstimateItemPayload], + ) -> tuple[list[RepairEstimateItem], int]: items: list[RepairEstimateItem] = [] subtotal = 0 for index, payload in enumerate(payload_items, start=1): - total = calculate_item_total(payload.quantity, payload.unit_price_cents) + item_type = payload.item_type + title = payload.title + description = payload.description + unit = payload.unit + unit_price_cents = payload.unit_price_cents + inventory_item_id = payload.inventory_item_id + inventory_snapshot_name = "" + inventory_snapshot_sku = "" + inventory_snapshot_manufacturer = None + inventory_snapshot_part_number = None + if inventory_item_id is not None: + inventory_item = InventoryRepository.get_item(db, inventory_item_id) + if inventory_item is None or not inventory_item.is_active: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden") + item_type = "part" + title = inventory_item.name + unit = inventory_item.unit + inventory_snapshot_name = inventory_item.name + inventory_snapshot_sku = inventory_item.sku + inventory_snapshot_manufacturer = inventory_item.manufacturer + inventory_snapshot_part_number = inventory_item.manufacturer_part_number + if not payload.inventory_price_overridden: + unit_price_cents = inventory_item.selling_price_cents or 0 + total = calculate_item_total(payload.quantity, unit_price_cents) subtotal += total items.append( RepairEstimateItem( + inventory_item_id=inventory_item_id, + inventory_snapshot_name=inventory_snapshot_name, + inventory_snapshot_sku=inventory_snapshot_sku, + inventory_snapshot_manufacturer=inventory_snapshot_manufacturer, + inventory_snapshot_part_number=inventory_snapshot_part_number, position=index, - item_type=payload.item_type, - title=payload.title, - description=payload.description, + item_type=item_type, + title=title, + description=description, quantity=payload.quantity, - unit=payload.unit, - unit_price_cents=payload.unit_price_cents, + unit=unit, + unit_price_cents=unit_price_cents, total_cents=total, ) ) return items, subtotal + @staticmethod + def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]: + overrides: list[dict] = [] + for payload in payload_items: + if payload.inventory_item_id is None or not payload.inventory_price_overridden: + continue + inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id) + if inventory_item is None: + continue + default_price = inventory_item.selling_price_cents or 0 + if payload.unit_price_cents != default_price: + overrides.append({ + "inventory_item_id": inventory_item.id, + "label": f"{inventory_item.sku} · {inventory_item.name}", + "default_price_cents": default_price, + "override_price_cents": payload.unit_price_cents, + }) + return overrides + + @staticmethod + def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None: + for override in overrides: + write_audit_log( + db, + action="inventory.estimate.price_override", + entity_type="inventory_items", + entity_id=override["inventory_item_id"], + entity_label=override["label"], + actor=actor, + request=request, + metadata={ + "estimate_id": estimate.id, + "estimate_number": estimate.estimate_number, + "default_price_cents": override["default_price_cents"], + "override_price_cents": override["override_price_cents"], + }, + ) + + @staticmethod + def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None: + seen_item_ids: set[int] = set() + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids: + continue + seen_item_ids.add(estimate_item.inventory_item_id) + inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level: + continue + write_audit_log( + db, + action="inventory.stock.low", + entity_type="inventory_items", + entity_id=inventory_item.id, + entity_label=f"{inventory_item.sku} · {inventory_item.name}", + actor=actor, + request=request, + metadata={ + "estimate_id": estimate.id, + "estimate_number": estimate.estimate_number, + "quantity_available": inventory_item.quantity_available, + "reorder_level": inventory_item.reorder_level, + }, + ) + @staticmethod def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str: return ( diff --git a/frontend/athena/app/api/inventory/items/search/route.ts b/frontend/athena/app/api/inventory/items/search/route.ts new file mode 100644 index 0000000..7ce787d --- /dev/null +++ b/frontend/athena/app/api/inventory/items/search/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`); +} diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index b924bfa..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 { FileCheck2, Plus, Send, Trash2, 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"; @@ -17,6 +17,10 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; +import type { + InventoryCategory, + InventoryItem, +} from "@/types/inventory"; import type { RepairEstimate, RepairEstimateItemType, @@ -89,6 +93,12 @@ function dateTime(value: string | null) { type EstimateFormItem = { item_type: RepairEstimateItemType; + inventory_item_id: number | null; + inventory_snapshot_name: string; + inventory_snapshot_sku: string; + inventory_snapshot_manufacturer: string | null; + inventory_snapshot_part_number: string | null; + inventory_price_overridden: boolean; title: string; description: string | null; quantity: string; @@ -118,6 +128,12 @@ function centsToEuroInput(cents: number): string { function emptyItem(): EstimateFormItem { return { item_type: "labor", + inventory_item_id: null, + inventory_snapshot_name: "", + inventory_snapshot_sku: "", + inventory_snapshot_manufacturer: null, + inventory_snapshot_part_number: null, + inventory_price_overridden: false, title: "", description: "", quantity: "1.00", @@ -167,6 +183,14 @@ export default function RepairEstimatesSection({ const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); + const [inventoryItems, setInventoryItems] = useState([]); + const [inventoryCategories, setInventoryCategories] = useState([]); + const [inventorySearch, setInventorySearch] = useState(""); + const [inventoryCategory, setInventoryCategory] = useState("all"); + const [inventoryManufacturer, setInventoryManufacturer] = useState(""); + const [inventoryLoading, setInventoryLoading] = useState(false); + const [inventoryError, setInventoryError] = useState(""); const loadEstimates = useCallback(async () => { if (!canRead) { @@ -191,6 +215,38 @@ export default function RepairEstimatesSection({ }); }, [loadEstimates]); + const searchInventoryItems = useCallback(async () => { + setInventoryLoading(true); + setInventoryError(""); + try { + const params = new URLSearchParams(); + params.set("limit", "20"); + if (inventorySearch.trim()) params.set("q", inventorySearch.trim()); + if (inventoryCategory !== "all") params.set("category", inventoryCategory); + if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim()); + const [itemsResponse, categoriesResponse] = await Promise.all([ + api.get(`/inventory/items/search?${params.toString()}`), + api.get("/inventory/categories"), + ]); + setInventoryItems(itemsResponse.data); + setInventoryCategories(categoriesResponse.data); + } catch (err) { + setInventoryError(getErrorMessage(err)); + } finally { + setInventoryLoading(false); + } + }, [inventoryCategory, inventoryManufacturer, inventorySearch]); + + useEffect(() => { + if (!inventoryDialogOpen) { + return; + } + const id = window.setTimeout(() => { + void searchInventoryItems(); + }, 200); + return () => window.clearTimeout(id); + }, [inventoryDialogOpen, searchInventoryItems]); + const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => { const quantity = Number(item.quantity.replace(",", ".")) || 0; const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0; @@ -215,6 +271,12 @@ export default function RepairEstimatesSection({ valid_until: estimate.valid_until, items: estimate.items.map((item) => ({ item_type: item.item_type, + inventory_item_id: item.inventory_item_id ?? null, + inventory_snapshot_name: item.inventory_snapshot_name, + inventory_snapshot_sku: item.inventory_snapshot_sku, + inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer, + inventory_snapshot_part_number: item.inventory_snapshot_part_number, + inventory_price_overridden: false, title: item.title, description: item.description ?? "", quantity: String(item.quantity), @@ -232,6 +294,25 @@ export default function RepairEstimatesSection({ })); } + function addInventoryItem(item: InventoryItem) { + const formItem: EstimateFormItem = { + item_type: "part", + inventory_item_id: item.id, + inventory_snapshot_name: item.name, + inventory_snapshot_sku: item.sku, + inventory_snapshot_manufacturer: item.manufacturer, + inventory_snapshot_part_number: item.manufacturer_part_number, + inventory_price_overridden: false, + title: item.name, + description: item.description ?? "", + quantity: "1", + unit: item.unit, + unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0), + }; + setPayload((current) => ({ ...current, items: [...current.items, formItem] })); + setInventoryDialogOpen(false); + } + async function saveEstimate() { setSaving(true); try { @@ -254,6 +335,8 @@ export default function RepairEstimatesSection({ valid_until: payload.valid_until || null, items: payload.items.map((item) => ({ item_type: item.item_type, + inventory_item_id: item.inventory_item_id, + inventory_price_overridden: item.inventory_price_overridden, title: item.title.trim(), description: item.description?.trim() || null, quantity: item.quantity.replace(",", "."), @@ -371,7 +454,15 @@ export default function RepairEstimatesSection({

{item.position}. {item.title}

-

{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}

+

+ {item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""} + {itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)} +

+ {item.inventory_item_id && ( +

+ {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name} +

+ )}

{money(item.total_cents, estimate.currency)}

@@ -409,18 +500,32 @@ export default function RepairEstimatesSection({

Positionen

- +
+ + +
{payload.items.map((item, index) => (
+ {item.inventory_item_id && ( +
+
+ Lagerartikel · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} +
+ +
+ )}
- updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}> {Object.entries(itemTypeLabels).map(([value, label]) => )} - updateItem(index, { title: event.target.value })} /> + updateItem(index, { title: event.target.value })} /> updateItem(index, { quantity: event.target.value })} /> - updateItem(index, { unit: event.target.value })} /> - updateItem(index, { unit_price_euros: event.target.value })} /> + updateItem(index, { unit: event.target.value })} /> + updateItem(index, { unit_price_euros: event.target.value })} />
updateItem(index, { description: event.target.value })} /> @@ -442,6 +547,65 @@ export default function RepairEstimatesSection({ + + + + Lagerartikel auswählen + Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft. + + +
+
+ setInventorySearch(event.target.value)} /> + + setInventoryManufacturer(event.target.value)} /> +
+ + {inventoryError ? ( +

{inventoryError}

+ ) : inventoryLoading ? ( +

Lagerartikel werden geladen...

+ ) : inventoryItems.length === 0 ? ( +

Keine aktiven Lagerartikel gefunden.

+ ) : ( +
+ {inventoryItems.map((item) => { + const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level; + return ( + + ); + })} +
+ )} +
+ + + + +
+
+ Date: Sun, 5 Jul 2026 12:10:09 +0200 Subject: [PATCH 2/7] fix(estimates): add cancel confirmation and permissions --- backend/hermes/app/api/repair_estimates.py | 4 ++-- .../repairs/RepairEstimatesSection.tsx | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py index 56ab0fb..1ec60f7 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_permission +from app.core.rbac import require_any_permission, require_permission from app.db.database import get_db from app.models.repair import Repair from app.models.repair_estimate import RepairEstimate @@ -117,7 +117,7 @@ def cancel_estimate( estimate_id: int, request: Request, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("repair_estimates.update")), + current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])), ): repair = get_repair_or_404(db, repair_id) estimate = get_estimate_or_404(db, repair_id, estimate_id) diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 94de38e..d59e88e 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -183,6 +183,7 @@ 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 [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -372,11 +373,13 @@ export default function RepairEstimatesSection({ } } - async function cancelEstimate(estimate: RepairEstimate) { - setPendingId(estimate.id); + async function cancelEstimate() { + if (!cancelTarget) return; + setPendingId(cancelTarget.id); try { - await api.post(`/repairs/${repairId}/estimates/${estimate.id}/cancel`); + await api.post(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`); await loadEstimates(); + setCancelTarget(null); showToast({ type: "success", title: "Kostenvoranschlag storniert" }); } catch (err) { showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) }); @@ -472,7 +475,7 @@ export default function RepairEstimatesSection({
{canUpdate && ["draft", "sent"].includes(estimate.status) && } {canSend && ["draft", "sent"].includes(estimate.status) && } - {canUpdate && ["draft", "sent"].includes(estimate.status) && } + {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
@@ -606,6 +609,18 @@ export default function RepairEstimatesSection({ + !open && setCancelTarget(null)} + onConfirm={() => void cancelEstimate()} + > + {cancelTarget &&

{cancelTarget.estimate_number} · {cancelTarget.title}

} +
+ Date: Sun, 5 Jul 2026 12:26:45 +0200 Subject: [PATCH 3/7] fix(estimates): allow approved estimate revocation --- ...1_add_repair_estimate_revoke_permission.py | 67 +++++++++ backend/hermes/app/api/audit.py | 2 + backend/hermes/app/api/dashboard.py | 1 + backend/hermes/app/api/repair_estimates.py | 13 ++ backend/hermes/app/rbac/defaults.py | 2 + .../repair_estimate_repository.py | 13 +- backend/hermes/app/schemas/repair_estimate.py | 5 +- backend/hermes/app/services/audit_service.py | 1 + .../hermes/app/services/inventory_service.py | 10 +- .../app/services/repair_estimate_service.py | 132 +++++++++++++++++- .../estimates/[estimateId]/revoke/route.ts | 19 +++ frontend/athena/app/repairs/[id]/page.tsx | 2 + .../repairs/RepairEstimatesSection.tsx | 34 ++++- frontend/athena/types/repair.ts | 2 +- 14 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts 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 new file mode 100644 index 0000000..58c82c7 --- /dev/null +++ b/backend/hermes/alembic/versions/a7c3e9d4b821_add_repair_estimate_revoke_permission.py @@ -0,0 +1,67 @@ +"""add repair estimate revoke permission + +Revision ID: a7c3e9d4b821 +Revises: f4a9c2d7e118 +Create Date: 2026-07-05 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "a7c3e9d4b821" +down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +PERMISSION_NAME = "repair_estimates.revoke" + + +def upgrade() -> None: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES ( + :name, + 'Kostenvoranschläge zurücknehmen', + 'Freigegebene Kostenvoranschläge administrativ zurücknehmen', + 'repair_estimates' + ) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=PERMISSION_NAME) + ) + for role_name in ("administrator", "management"): + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=PERMISSION_NAME) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE name = :permission_name + ) + """ + ).bindparams(permission_name=PERMISSION_NAME) + ) + op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME)) diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index 3912a67..d81a4ba 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -58,6 +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("audit_logs."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index a63f72d..2f2c197 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -82,6 +82,7 @@ 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 "inventory.read" in permissions: diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py index 1ec60f7..260754c 100644 --- a/backend/hermes/app/api/repair_estimates.py +++ b/backend/hermes/app/api/repair_estimates.py @@ -124,6 +124,19 @@ def cancel_estimate( return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request) +@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse) +def revoke_estimate( + repair_id: int, + estimate_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.revoke")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request) + + @router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse]) def list_estimate_events( repair_id: int, diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 1191da6..78e0346 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -90,6 +90,7 @@ 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"), @@ -119,6 +120,7 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.create", "repair_estimates.update", "repair_estimates.send", + "repair_estimates.revoke", "inventory.read", "inventory.create", "inventory.update", diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py index 7519a94..db20a7c 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"])) + .where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"])) .order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc()) .limit(1) ) @@ -123,3 +123,14 @@ 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/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 75b942d..1473304 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"] +EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"] EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"] EstimateActorType = Literal["user", "customer", "system"] -EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"] +EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"] def normalize_text(value: object) -> str: @@ -167,6 +167,7 @@ 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 d986106..787cd0b 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -179,6 +179,7 @@ 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", diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py index 01b082c..49afd72 100644 --- a/backend/hermes/app/services/inventory_service.py +++ b/backend/hermes/app/services/inventory_service.py @@ -225,7 +225,13 @@ class InventoryService: ) @staticmethod - def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + def release_estimate_reservation( + db: Session, + estimate: RepairEstimate, + *, + actor_user_id: int | None, + reason: str = "estimate_released", + ) -> None: for estimate_item in estimate.items: if estimate_item.inventory_item_id is None: continue @@ -240,7 +246,7 @@ class InventoryService: item_id=item.id, movement_type="release", quantity=quantity, - reason="estimate_released", + reason=reason, reference_type="repair_estimate", reference_id=estimate.id, note=f"Kostenvoranschlag {estimate.estimate_number}", diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py index b9b4ee8..f096b84 100644 --- a/backend/hermes/app/services/repair_estimate_service.py +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -59,6 +59,17 @@ 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: @@ -249,7 +260,7 @@ class RepairEstimateService: @staticmethod def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate: - if estimate.status in {"approved", "declined", "cancelled"}: + if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden") was_sent = estimate.status == "sent" if was_sent: @@ -281,6 +292,98 @@ 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: @@ -288,6 +391,7 @@ 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, @@ -529,4 +633,30 @@ 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/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts new file mode 100644 index 0000000..140c741 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/revoke/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 78a1394..b251eb4 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -175,6 +175,7 @@ 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"); async function createPublicLink() { if (!repair || !canManagePublicLink) return; @@ -442,6 +443,7 @@ export default function RepairDetailPage({ params }: Params) { canUpdate={canUpdateEstimates} canDelete={canDeleteEstimates} canSend={canSendEstimates} + canRevoke={canRevokeEstimates} /> diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index d59e88e..0a7650d 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, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react"; +import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, Undo2, XCircle } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { useToast } from "@/components/common/ToastProvider"; @@ -42,6 +42,7 @@ const statusLabels: Record = { declined: "Abgelehnt", expired: "Abgelaufen", cancelled: "Storniert", + revoked: "Zurückgenommen", }; function humanizeValidationDetail(detail: unknown): string | null { @@ -162,6 +163,7 @@ type Props = { canUpdate: boolean; canDelete: boolean; canSend: boolean; + canRevoke: boolean; }; export default function RepairEstimatesSection({ @@ -172,6 +174,7 @@ export default function RepairEstimatesSection({ canUpdate, canDelete, canSend, + canRevoke, }: Props) { const { showToast } = useToast(); const [estimates, setEstimates] = useState([]); @@ -184,6 +187,7 @@ export default function RepairEstimatesSection({ const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [cancelTarget, setCancelTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -388,6 +392,21 @@ 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 deleteEstimate() { if (!deleteTarget) return; setPendingId(deleteTarget.id); @@ -476,6 +495,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" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
@@ -621,6 +641,18 @@ export default function RepairEstimatesSection({ {cancelTarget &&

{cancelTarget.estimate_number} · {cancelTarget.title}

} + !open && setRevokeTarget(null)} + onConfirm={() => void revokeEstimate()} + > + {revokeTarget &&

{revokeTarget.estimate_number} · {revokeTarget.title}

} +
+ Date: Sun, 5 Jul 2026 13:10:27 +0200 Subject: [PATCH 4/7] feat(lexware): add integration foundation --- .env.example | 5 + ARCHITECTURE.md | 42 ++ README-DEV.md | 31 ++ ROADMAP.md | 30 +- backend/hermes/.env.example | 5 + .../c9d4e5f6a7b8_add_lexware_foundation.py | 113 ++++++ backend/hermes/app/api/audit.py | 2 + backend/hermes/app/api/lexware.py | 69 ++++ backend/hermes/app/core/config.py | 3 + backend/hermes/app/db/database.py | 1 + backend/hermes/app/main.py | 2 + backend/hermes/app/models/lexware.py | 23 ++ backend/hermes/app/models/repair_estimate.py | 4 + backend/hermes/app/rbac/defaults.py | 7 + backend/hermes/app/schemas/lexware.py | 81 ++++ backend/hermes/app/schemas/repair_estimate.py | 4 + backend/hermes/app/services/audit_service.py | 7 +- .../hermes/app/services/lexware_service.py | 361 ++++++++++++++++++ backend/hermes/docker-compose.yml | 3 + docker-compose.yml | 3 + .../athena/app/api/lexware/settings/route.ts | 18 + .../app/api/lexware/test-connection/route.ts | 14 + .../lexware/prepare-invoice/route.ts | 19 + frontend/athena/app/repairs/[id]/page.tsx | 2 + frontend/athena/app/settings/page.tsx | 166 +++++++- .../repairs/RepairEstimatesSection.tsx | 86 ++++- frontend/athena/types/lexware.ts | 56 +++ frontend/athena/types/repair.ts | 4 + 28 files changed, 1150 insertions(+), 11 deletions(-) create mode 100644 backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py create mode 100644 backend/hermes/app/api/lexware.py create mode 100644 backend/hermes/app/models/lexware.py create mode 100644 backend/hermes/app/schemas/lexware.py create mode 100644 backend/hermes/app/services/lexware_service.py create mode 100644 frontend/athena/app/api/lexware/settings/route.ts create mode 100644 frontend/athena/app/api/lexware/test-connection/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts create mode 100644 frontend/athena/types/lexware.ts diff --git a/.env.example b/.env.example index afd6786..77728fe 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,8 @@ 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/ARCHITECTURE.md b/ARCHITECTURE.md index 58f6fea..a35aa71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,6 +166,48 @@ 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. + +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. + +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`. diff --git a/README-DEV.md b/README-DEV.md index 3e515cd..ebb89d2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -42,10 +42,15 @@ 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 @@ -179,6 +184,32 @@ 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. + Benachrichtigungen: - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. diff --git a/ROADMAP.md b/ROADMAP.md index e92efb2..20bd205 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,13 +176,35 @@ 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 - Kundenportal, geplant +## 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` + +## 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 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden - Keine Vermischung mit internen Olympus-Benutzern -## v0.9.0 - Tickets, geplant +## v0.9.2 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell @@ -190,10 +212,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/Lexoffice, geplant +## v0.10.0 - Integrationen Paperless und Lexware-Ausbau, geplant - Paperless-ngx Connector fuer Wissensdokumente -- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten +- Lexware-Exportpfade fuer Rechnungen und Kundenkontakte ausbauen - 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 6f7a6d0..cfaeb3b 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -31,3 +31,8 @@ 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/c9d4e5f6a7b8_add_lexware_foundation.py b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py new file mode 100644 index 0000000..bf98008 --- /dev/null +++ b/backend/hermes/alembic/versions/c9d4e5f6a7b8_add_lexware_foundation.py @@ -0,0 +1,113 @@ +"""add lexware foundation + +Revision ID: c9d4e5f6a7b8 +Revises: a7c3e9d4b821 +Create Date: 2026-07-05 16:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c9d4e5f6a7b8" +down_revision: Union[str, Sequence[str], None] = "a7c3e9d4b821" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +LEXWARE_PERMISSIONS = [ + ("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"), + ("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"), + ("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"), +] + + +def upgrade() -> None: + op.create_table( + "lexware_sync_records", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entity_type", sa.String(length=80), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=False), + sa.Column("lexware_resource_type", sa.String(length=80), nullable=False), + sa.Column("lexware_resource_id", sa.String(length=120), nullable=True), + sa.Column("status", sa.String(length=40), server_default="pending", nullable=False), + sa.Column("direction", sa.String(length=40), server_default="push", nullable=False), + sa.Column("payload_summary", sa.Text(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_lexware_sync_records_direction"), "lexware_sync_records", ["direction"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_entity_id"), "lexware_sync_records", ["entity_id"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_entity_type"), "lexware_sync_records", ["entity_type"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_lexware_resource_id"), "lexware_sync_records", ["lexware_resource_id"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_lexware_resource_type"), "lexware_sync_records", ["lexware_resource_type"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_status"), "lexware_sync_records", ["status"], unique=False) + + op.add_column("repair_estimates", sa.Column("lexware_invoice_id", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_invoice_number", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_invoice_status", sa.String(length=80), nullable=True)) + op.add_column("repair_estimates", sa.Column("lexware_synced_at", sa.DateTime(timezone=True), nullable=True)) + + for name, display_name, description, module in LEXWARE_PERMISSIONS: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES (:name, :display_name, :description, :module) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + role_permissions = { + "administrator": [item[0] for item in LEXWARE_PERMISSIONS], + "management": [item[0] for item in LEXWARE_PERMISSIONS], + "support": ["lexware.read"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'lexware' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'lexware'") + op.drop_column("repair_estimates", "lexware_synced_at") + op.drop_column("repair_estimates", "lexware_invoice_status") + op.drop_column("repair_estimates", "lexware_invoice_number") + op.drop_column("repair_estimates", "lexware_invoice_id") + op.drop_index(op.f("ix_lexware_sync_records_status"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_type"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_lexware_resource_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_entity_type"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_entity_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_direction"), table_name="lexware_sync_records") + op.drop_table("lexware_sync_records") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index d81a4ba..eb8ed14 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -62,6 +62,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: 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("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py new file mode 100644 index 0000000..fc255f9 --- /dev/null +++ b/backend/hermes/app/api/lexware.py @@ -0,0 +1,69 @@ +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 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) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 9590c60..23c8c71 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -32,6 +32,9 @@ 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 dcc29f4..260eb13 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -28,6 +28,7 @@ import app.models.user 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 3b7c410..ffb31e2 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -15,6 +15,7 @@ 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 @@ -48,6 +49,7 @@ 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/lexware.py b/backend/hermes/app/models/lexware.py new file mode 100644 index 0000000..5f07528 --- /dev/null +++ b/backend/hermes/app/models/lexware.py @@ -0,0 +1,23 @@ +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) + 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) diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 696a060..b98603b 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -27,6 +27,10 @@ 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) 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()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 78e0346..9974a8b 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -99,6 +99,9 @@ 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"), ] ROLE_PERMISSION_NAMES = { @@ -128,6 +131,9 @@ ROLE_PERMISSION_NAMES = { "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata", + "lexware.read", + "lexware.manage", + "lexware.export", }, "sales": { "dashboard.read", @@ -177,6 +183,7 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.read", "repair_estimates.send", "inventory.read", + "lexware.read", }, "warehouse": { "dashboard.read", diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py new file mode 100644 index 0000000..7723042 --- /dev/null +++ b/backend/hermes/app/schemas/lexware.py @@ -0,0 +1,81 @@ +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"] + + +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 + payload_summary: dict + customer_mapping: LexwareCustomerMapping + line_item_mapping: list[LexwareLineItemMapping] + tax_mapping: dict + warnings: list[str] + sync_record_id: int diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 1473304..404c0a5 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -142,6 +142,10 @@ 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 created_by_user_id: int | None created_at: datetime updated_at: datetime diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 787cd0b..9ae8a41 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", "token", "access_token", "secret", "secret_key"} +SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"} def to_audit_data(value: Any, seen: set[int] | None = None) -> Any: @@ -207,6 +207,11 @@ 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", } return labels.get(action, action) diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py new file mode 100644 index 0000000..90c70c2 --- /dev/null +++ b/backend/hermes/app/services/lexware_service.py @@ -0,0 +1,361 @@ +import json +from dataclasses import dataclass +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 ( + 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 = { + "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), + } + record = LexwareSyncRecord( + entity_type="repair_estimate", + entity_id=estimate.id, + lexware_resource_type="invoice", + status="pending" if not warnings else "skipped", + direction="push", + payload_summary=json.dumps(payload_summary, ensure_ascii=True), + error_message="; ".join(warnings) if warnings else None, + ) + db.add(record) + db.commit() + db.refresh(record) + write_audit_log( + db, + action="lexware.invoice.prepare", + 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, + }, + ) + return LexwareInvoicePreparationResponse( + ready_for_export=not warnings, + 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, + ) + + @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/docker-compose.yml b/backend/hermes/docker-compose.yml index a2215df..660b1e5 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -22,6 +22,9 @@ 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/docker-compose.yml b/docker-compose.yml index 5ad8a33..94bf31f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ 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/lexware/settings/route.ts b/frontend/athena/app/api/lexware/settings/route.ts new file mode 100644 index 0000000..3eb117a --- /dev/null +++ b/frontend/athena/app/api/lexware/settings/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/lexware/settings"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/settings"); +} diff --git a/frontend/athena/app/api/lexware/test-connection/route.ts b/frontend/athena/app/api/lexware/test-connection/route.ts new file mode 100644 index 0000000..521df64 --- /dev/null +++ b/frontend/athena/app/api/lexware/test-connection/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/lexware/test-connection"); +} 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 new file mode 100644 index 0000000..9e3f9b7 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index b251eb4..9c4cec3 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -176,6 +176,7 @@ export default function RepairDetailPage({ params }: Params) { 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,6 +445,7 @@ export default function RepairDetailPage({ params }: Params) { 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 70b3494..f5e0059 100644 --- a/frontend/athena/app/settings/page.tsx +++ b/frontend/athena/app/settings/page.tsx @@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; -import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-react"; +import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react"; import { useToast } from "@/components/common/ToastProvider"; import { 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, @@ -16,7 +21,7 @@ import type { SmtpTestResponse, } from "@/types/system-settings"; -type SettingsTab = "smtp" | "public-links"; +type SettingsTab = "smtp" | "public-links" | "lexware"; const sourceLabels: Record = { database: "Admin-Konfiguration", @@ -46,6 +51,18 @@ function emptySmtpSettings(): SmtpSettings { }; } +function emptyLexwareSettings(): LexwareSettings { + return { + enabled: false, + api_base_url: "https://api.lexware.io", + api_key_is_set: false, + organization_name: "", + default_tax_rate: "19.00", + default_payment_terms_days: 14, + source: "missing", + }; +} + export default function SettingsPage() { const { showToast } = useToast(); const [activeTab, setActiveTab] = useState("smtp"); @@ -56,22 +73,28 @@ 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] = await Promise.all([ + const [smtpResponse, publicLinksResponse, lexwareResponse] = 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 { @@ -158,6 +181,56 @@ export default function SettingsPage() { } } + async function saveLexware() { + setSavingLexware(true); + try { + const payload: LexwareSettingsPayload = { + enabled: lexware.enabled, + api_base_url: lexware.api_base_url, + organization_name: lexware.organization_name, + default_tax_rate: lexware.default_tax_rate, + default_payment_terms_days: lexware.default_payment_terms_days, + }; + + if (newLexwareApiKey) { + payload.api_key = newLexwareApiKey; + } + + const response = await api.put("/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...
; } @@ -171,7 +244,7 @@ export default function SettingsPage() {

Einstellungen

-

SMTP-Versand und öffentliche Statuslinks verwalten

+

SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten

setActiveTab("smtp")}> @@ -180,6 +253,9 @@ export default function SettingsPage() { setActiveTab("public-links")}> Öffentliche Links + setActiveTab("lexware")}> + Lexware Office +
@@ -287,7 +363,7 @@ export default function SettingsPage() {
- ) : ( + ) : activeTab === "public-links" ? (
@@ -319,6 +395,86 @@ 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/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 0a7650d..210e415 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, FileCheck2, PackageSearch, Plus, Send, Trash2, Undo2, XCircle } from "lucide-react"; +import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { useToast } from "@/components/common/ToastProvider"; @@ -21,6 +21,7 @@ import type { InventoryCategory, InventoryItem, } from "@/types/inventory"; +import type { LexwareInvoicePreparation } from "@/types/lexware"; import type { RepairEstimate, RepairEstimateItemType, @@ -164,6 +165,7 @@ type Props = { canDelete: boolean; canSend: boolean; canRevoke: boolean; + canLexwareExport: boolean; }; export default function RepairEstimatesSection({ @@ -175,6 +177,7 @@ export default function RepairEstimatesSection({ canDelete, canSend, canRevoke, + canLexwareExport, }: Props) { const { showToast } = useToast(); const [estimates, setEstimates] = useState([]); @@ -188,6 +191,8 @@ export default function RepairEstimatesSection({ 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 [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -407,6 +412,24 @@ export default function RepairEstimatesSection({ } } + 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); + setLexwareDialogOpen(true); + showToast({ + type: response.data.ready_for_export ? "success" : "error", + title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen", + description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.", + }); + } catch (err) { + showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) }); + } finally { + setPendingId(null); + } + } + async function deleteEstimate() { if (!deleteTarget) return; setPendingId(deleteTarget.id); @@ -496,6 +519,7 @@ export default function RepairEstimatesSection({ {canSend && ["draft", "sent"].includes(estimate.status) && } {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canRevoke && estimate.status === "approved" && } + {canLexwareExport && estimate.status === "approved" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && } @@ -629,6 +653,66 @@ export default function RepairEstimatesSection({ + + + + Lexware-Rechnung vorbereiten + Die Rechnung wird noch nicht automatisch in Lexware erstellt. + + {lexwareResult && ( +
+
+

{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}

+

Sync-Record #{lexwareResult.sync_record_id}

+
+ + {lexwareResult.warnings.length > 0 && ( +
+

Warnungen

+
    + {lexwareResult.warnings.map((warning) =>
  • {warning}
  • )} +
+
+ )} + +
+ + + + +
+ +
+
+ Position + Steuer + Summe +
+ {lexwareResult.line_item_mapping.map((item, index) => ( +
+
+

{item.title}

+

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

+
+ {item.tax_rate} % + {item.total} € +
+ ))} +
+ +
+ + + +
+
+ )} + + + +
+
+ ; +} + +export interface LexwareLineItemMapping { + title: string; + description: string | null; + quantity: string; + unit: string; + unit_price: string; + tax_rate: string; + total: string; +} + +export interface LexwareInvoicePreparation { + ready_for_export: boolean; + payload_summary: Record; + customer_mapping: LexwareCustomerMapping; + line_item_mapping: LexwareLineItemMapping[]; + tax_mapping: Record; + warnings: string[]; + sync_record_id: number; +} diff --git a/frontend/athena/types/repair.ts b/frontend/athena/types/repair.ts index 336a5ee..1b67875 100644 --- a/frontend/athena/types/repair.ts +++ b/frontend/athena/types/repair.ts @@ -216,6 +216,10 @@ export interface RepairEstimate { approved_at: string | null; declined_at: string | null; customer_response_message: string | null; + lexware_invoice_id: string | null; + lexware_invoice_number: string | null; + lexware_invoice_status: string | null; + lexware_synced_at: string | null; created_by_user_id: number | null; created_at: string; updated_at: string; From 46eeaa1f2e47c0513e2e33539fd20d1266ffe50d Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 13:41:33 +0200 Subject: [PATCH 5/7] feat(accounting): add invoice preparation workflow --- ARCHITECTURE.md | 10 + README-DEV.md | 9 + ROADMAP.md | 3 + ...e3f4a5b6c7_add_accounting_export_status.py | 53 +++++ backend/hermes/app/api/audit.py | 2 + backend/hermes/app/api/dashboard.py | 29 +++ backend/hermes/app/api/lexware.py | 22 +- backend/hermes/app/models/lexware.py | 4 + backend/hermes/app/models/repair_estimate.py | 4 + backend/hermes/app/schemas/lexware.py | 15 ++ backend/hermes/app/schemas/repair_estimate.py | 4 + backend/hermes/app/services/audit_service.py | 3 + .../hermes/app/services/lexware_service.py | 213 +++++++++++++++--- .../accounting/mark-transferred/route.ts | 19 ++ .../repairs/RepairEstimatesSection.tsx | 165 ++++++++++++-- frontend/athena/types/lexware.ts | 4 + frontend/athena/types/repair.ts | 4 + 17 files changed, 519 insertions(+), 44 deletions(-) create mode 100644 backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a35aa71..62a0499 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -183,6 +183,7 @@ Komponenten: - 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: @@ -193,6 +194,15 @@ Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreS 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` diff --git a/README-DEV.md b/README-DEV.md index ebb89d2..3aee4d1 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -210,6 +210,15 @@ 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 20bd205..040120d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,9 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - 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 diff --git a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py new file mode 100644 index 0000000..0c7936b --- /dev/null +++ b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py @@ -0,0 +1,53 @@ +"""add accounting export status + +Revision ID: d2e3f4a5b6c7 +Revises: c9d4e5f6a7b8 +Create Date: 2026-07-05 17:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d2e3f4a5b6c7" +down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False)) + op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True)) + op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True)) + op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False) + op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False) + + op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_repair_estimates_accounting_transferred_by_user_id", + "repair_estimates", + "users", + ["accounting_transferred_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey") + op.drop_column("repair_estimates", "accounting_transferred_by_user_id") + op.drop_column("repair_estimates", "accounting_transferred_at") + op.drop_column("repair_estimates", "accounting_note") + op.drop_column("repair_estimates", "accounting_export_status") + op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records") + op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records") + op.drop_column("lexware_sync_records", "transferred_by_user_id") + op.drop_column("lexware_sync_records", "transferred_at") + op.drop_column("lexware_sync_records", "accounting_note") + op.drop_column("lexware_sync_records", "export_status") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index eb8ed14..8349be7 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -64,6 +64,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: 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("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2f2c197..7488a6d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -8,6 +8,7 @@ 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 @@ -85,6 +86,34 @@ def get_dashboard_summary( MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)), ]) + if "lexware.read" in permissions: + repairs.extend([ + MetricCard( + label="Vorbereitete Rechnungen", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "prepared") + ) or 0, + ), + MetricCard( + label="An Buchhaltung übergeben", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "transferred") + ) or 0, + ), + MetricCard( + label="Noch nicht übertragen", + value=db.scalar( + select(func.count(LexwareSyncRecord.id)) + .where(LexwareSyncRecord.lexware_resource_type == "invoice") + .where(LexwareSyncRecord.export_status == "prepared") + ) or 0, + ), + ]) + if "inventory.read" in permissions: inventory = [ MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py index fc255f9..03b20c0 100644 --- a/backend/hermes/app/api/lexware.py +++ b/backend/hermes/app/api/lexware.py @@ -9,7 +9,13 @@ 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 LexwareInvoicePreparationResponse, LexwareSettingsResponse, LexwareSettingsUpdate, LexwareTestConnectionResponse +from app.schemas.lexware import ( + AccountingTransferUpdate, + LexwareInvoicePreparationResponse, + LexwareSettingsResponse, + LexwareSettingsUpdate, + LexwareTestConnectionResponse, +) from app.services.lexware_service import LexwareService router = APIRouter(tags=["Lexware"]) @@ -67,3 +73,17 @@ def prepare_lexware_invoice( 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/models/lexware.py b/backend/hermes/app/models/lexware.py index 5f07528..febb874 100644 --- a/backend/hermes/app/models/lexware.py +++ b/backend/hermes/app/models/lexware.py @@ -16,8 +16,12 @@ class LexwareSyncRecord(Base): 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_estimate.py b/backend/hermes/app/models/repair_estimate.py index b98603b..b9d6a80 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -31,6 +31,10 @@ class RepairEstimate(Base): 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()) diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py index 7723042..45cd103 100644 --- a/backend/hermes/app/schemas/lexware.py +++ b/backend/hermes/app/schemas/lexware.py @@ -8,6 +8,7 @@ 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): @@ -73,9 +74,23 @@ class LexwareLineItemMapping(BaseModel): 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 404c0a5..b1b68e5 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -146,6 +146,10 @@ class RepairEstimateResponse(BaseModel): 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 diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 9ae8a41..12518a6 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -212,6 +212,9 @@ def action_title(action: str) -> str: "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", } return labels.get(action, action) diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py index 90c70c2..61ffe38 100644 --- a/backend/hermes/app/services/lexware_service.py +++ b/backend/hermes/app/services/lexware_service.py @@ -1,5 +1,6 @@ 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 @@ -16,6 +17,7 @@ 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, @@ -276,7 +278,160 @@ class LexwareService: ) for item in estimate.items ] - payload_summary = { + 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}.", @@ -289,35 +444,21 @@ class LexwareService: "total": str(_euros(estimate.total_cents)), "line_item_count": len(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", - payload_summary=json.dumps(payload_summary, ensure_ascii=True), - error_message="; ".join(warnings) if warnings else None, - ) - db.add(record) - db.commit() - db.refresh(record) - write_audit_log( - db, - action="lexware.invoice.prepare", - 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, - }, - ) + + @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, @@ -329,6 +470,22 @@ class LexwareService: }, 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 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 new file mode 100644 index 0000000..c95e919 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`); +} diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 210e415..58b89f1 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, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { useToast } from "@/components/common/ToastProvider"; @@ -46,6 +46,20 @@ const statusLabels: Record = { 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 { if (!Array.isArray(detail)) { return null; @@ -193,6 +207,8 @@ export default function RepairEstimatesSection({ 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([]); @@ -417,19 +433,48 @@ export default function RepairEstimatesSection({ 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 ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen", - description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.", + 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: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(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); @@ -494,6 +539,16 @@ 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) => (
@@ -519,7 +574,7 @@ export default function RepairEstimatesSection({ {canSend && ["draft", "sent"].includes(estimate.status) && } {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canRevoke && estimate.status === "approved" && } - {canLexwareExport && estimate.status === "approved" && } + {canLexwareExport && estimate.status === "approved" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
@@ -656,14 +711,22 @@ export default function RepairEstimatesSection({ - Lexware-Rechnung vorbereiten - Die Rechnung wird noch nicht automatisch in Lexware erstellt. + In Buchhaltung übernehmen + Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe. {lexwareResult && (
-
-

{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}

-

Sync-Record #{lexwareResult.sync_record_id}

+
+
+ Exportstatus + + + + + +
+

Rechnungsvorbereitung #{lexwareResult.sync_record_id}

+ {lexwareResult.transferred_at &&

Übertragen am {dateTime(lexwareResult.transferred_at)}

}
{lexwareResult.warnings.length > 0 && ( @@ -675,14 +738,21 @@ export default function RepairEstimatesSection({
)} -
- - - - +
+ + void copyText("Kundendaten", text)} + /> +
+
Position Steuer @@ -698,6 +768,13 @@ export default function RepairEstimatesSection({ {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)} + /> +
@@ -705,6 +782,33 @@ export default function RepairEstimatesSection({
+ +
+ + +
+