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}

} +
+