fix(estimates): allow approved estimate revocation
This commit is contained in:
parent
31bdc1047d
commit
8d37eb29b3
14 changed files with 295 additions and 8 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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:
|
|||
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
||||
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
|
||||
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
|
||||
</td></tr></table></body></html>"""
|
||||
|
||||
@staticmethod
|
||||
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||
return (
|
||||
f"Hallo {repair.customer_name},\n\n"
|
||||
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
|
||||
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
|
||||
f"Reparatur: {repair.repair_number}\n"
|
||||
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
|
||||
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
|
||||
"Funktechnik Schubert"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _revoked_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
|
||||
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
|
||||
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
|
||||
<tr><td style="padding:28px;">
|
||||
<p>Hallo {escape(repair.customer_name)},</p>
|
||||
<p>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
|
||||
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
|
||||
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</p>
|
||||
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Status ansehen</a></p>
|
||||
</td></tr></table></body></html>"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue