Olympus/backend/hermes/app/services/repair_notification_service.py
2026-07-04 23:54:20 +02:00

297 lines
13 KiB
Python

from dataclasses import dataclass
from datetime import UTC, datetime
from html import escape
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair, RepairNotificationEvent
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
from app.services.audit_service import write_audit_log
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
from app.services.system_settings_service import SystemSettingsService
@dataclass(frozen=True)
class RepairNotificationTemplate:
event_type: str
status: RepairStatus | None
subject: str
text: str
html: str | None = None
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
RepairNotificationTemplate(
event_type="repair_created",
status="new",
subject="Reparatur {repair_number} wurde erfasst",
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="device_accepted",
status="accepted",
subject="Gerät zu Reparatur {repair_number} angenommen",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="diagnosis_started",
status="diagnosis",
subject="Diagnose für Reparatur {repair_number} läuft",
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="estimate_created",
status="estimate",
subject="Kostenvoranschlag zu Reparatur {repair_number}",
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="waiting_for_customer",
status="waiting_for_customer",
subject="Freigabe für Reparatur {repair_number} erforderlich",
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="repair_started",
status="repair",
subject="Reparatur {repair_number} läuft",
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="final_test",
status="final_test",
subject="Endprüfung für Reparatur {repair_number}",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="ready_for_pickup",
status="ready_for_pickup",
subject="Reparatur {repair_number} ist abholbereit",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="shipped",
status="shipped",
subject="Reparatur {repair_number} wurde versendet",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="completed",
status="completed",
subject="Reparatur {repair_number} abgeschlossen",
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
),
)
def _device_label(repair: Repair) -> str:
return f"{repair.device_manufacturer} {repair.device_model}".strip()
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"der Status Ihrer Reparatur wurde aktualisiert.\n\n"
f"Reparaturnummer:\n{repair.repair_number}\n\n"
f"Gerät:\n{_device_label(repair)}\n\n"
f"Neuer Status:\n{status_label}\n\n"
f"Status online ansehen:\n{public_status_url}\n\n"
"Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n"
"Funktechnik Schubert"
)
def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de">
<body style="margin:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#172033;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid #dce5ef;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#082a60;color:#ffffff;padding:24px 28px;">
<div style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Olympus CRM</div>
<div style="font-size:24px;font-weight:800;margin-top:6px;">Funktechnik Schubert</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 18px;font-size:16px;">Hallo {escape(repair.customer_name)},</p>
<p style="margin:0 0 22px;font-size:16px;line-height:1.55;">der Status Ihrer Reparatur wurde aktualisiert.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 24px;">
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;">Reparaturnummer</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;">{escape(repair.repair_number)}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Gerät</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(_device_label(repair))}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Neuer Status</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(status_label)}</td>
</tr>
</table>
<p style="margin:0 0 24px;">
<a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#ffffff;text-decoration:none;font-weight:800;border-radius:8px;padding:13px 18px;">Reparaturstatus ansehen</a>
</p>
<p style="margin:0;color:#657184;font-size:14px;line-height:1.5;">Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f8fafc;color:#657184;font-size:13px;">Funktechnik Schubert</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
class RepairNotificationService:
@staticmethod
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
placeholders = {
"repair_number": repair.repair_number,
"customer_name": repair.customer_name,
"device": _device_label(repair),
"status_label": STATUS_LABELS.get(repair.status, repair.status),
"public_status_url": public_status_url or "wird später bereitgestellt",
"company_name": "Olympus CRM",
}
return [
RepairNotificationTemplateResponse(
event_type=template.event_type,
status=template.status,
subject=template.subject.format(**placeholders),
text=template.text.format(**placeholders),
html=template.html.format(**placeholders) if template.html else None,
)
for template in TEMPLATES
]
@staticmethod
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
return RepairNotificationOverviewResponse(
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
events=RepairRepository.list_notification_events(db, repair.id),
)
@staticmethod
def send_status_mail(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
manual: bool = False,
) -> RepairNotificationEvent:
recipient = repair.customer_email.strip()
subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert"
status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status)
template = "repair_status_update"
if not recipient:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient="",
subject=subject,
template=template,
status="skipped",
success=False,
error_message="Keine Kunden-E-Mail hinterlegt",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient")
return event
link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create"
public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action)
public_status_url = public_link.public_status_path
text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
if not smtp_config.is_configured:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="skipped",
success=False,
error_message="SMTP nicht konfiguriert",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured")
return event
try:
SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html)
except Exception:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="failed",
success=False,
error_message="Statusmail konnte nicht versendet werden",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed")
return event
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="sent",
success=True,
sent_at=datetime.now(UTC),
)
write_audit_log(
db,
action="repairs.status_mail.sent",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"notification_event_id": event.id},
)
return event
@staticmethod
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"reason": reason},
)