From 31bdc1047d838c9a5bccbd9c8dd73c5a43b43049 Mon Sep 17 00:00:00 2001
From: Schubert Ferenc
Date: Sun, 5 Jul 2026 12:10:09 +0200
Subject: [PATCH 1/6] 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 2/6] 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
+