feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 00:57:02 +02:00
parent 6e7e75f864
commit 4436fe5f73
25 changed files with 1802 additions and 9 deletions

View file

@ -172,6 +172,14 @@ def action_title(action: str) -> str:
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"repair_estimates.create": "Kostenvoranschlag erstellt",
"repair_estimates.update": "Kostenvoranschlag geändert",
"repair_estimates.send": "Kostenvoranschlag gesendet",
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View file

@ -0,0 +1,357 @@
from datetime import UTC, datetime
from decimal import Decimal
from html import escape
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
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.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairStatusUpdate
from app.schemas.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateItemResponse,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateItemPayload,
RepairEstimateUpdate,
calculate_item_total,
calculate_tax,
)
from app.services.audit_service import write_audit_log
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.system_settings_service import SystemSettingsService
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _estimate_label(estimate: RepairEstimate) -> str:
return f"{estimate.estimate_number} · {estimate.title}"
def _money(cents: int, currency: str = "EUR") -> str:
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
return {
"id": estimate.id,
"repair_id": estimate.repair_id,
"estimate_number": estimate.estimate_number,
"status": estimate.status,
"title": estimate.title,
"currency": estimate.currency,
"valid_until": estimate.valid_until,
"sent_at": estimate.sent_at,
"approved_at": estimate.approved_at,
"declined_at": estimate.declined_at,
"created_by_user_id": estimate.created_by_user_id,
}
class RepairEstimateService:
@staticmethod
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
estimate = RepairEstimate(
repair_id=repair.id,
estimate_number=estimate_number,
status="draft",
created_by_user_id=actor.id,
)
RepairEstimateService._apply_payload(estimate, payload)
try:
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
except IntegrityError:
db.rollback()
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.create",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
after_data=_audit_estimate_data(estimate),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return estimate
@staticmethod
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
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)
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)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.update",
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},
)
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")
before_data = _audit_estimate_data(estimate)
label = _estimate_label(estimate)
RepairEstimateRepository.delete(db, estimate)
write_audit_log(
db,
action="repair_estimates.delete",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
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")
link = RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
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)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_mail",
channel="email",
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
template="repair_estimate",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
write_audit_log(
db,
action="repair_estimates.send",
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, "mail_sent": mail_sent},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
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")
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()
db.refresh(estimate)
write_audit_log(
db,
action="repair_estimates.cancel",
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},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
if estimate is None:
return None
return PublicEstimateResponse(
estimate_number=estimate.estimate_number,
status=estimate.status,
title=estimate.title,
customer_message=estimate.customer_message,
subtotal_cents=estimate.subtotal_cents,
tax_cents=estimate.tax_cents,
total_cents=estimate.total_cents,
currency=estimate.currency,
valid_until=estimate.valid_until,
items=[
PublicEstimateItemResponse(
item_type=item.item_type,
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price_cents=item.unit_price_cents,
total_cents=item.total_cents,
)
for item in estimate.items
],
)
@staticmethod
def customer_decision(
db: Session,
repair: Repair,
estimate: RepairEstimate,
decision: str,
payload: PublicEstimateDecisionRequest,
*,
request: Request | None,
) -> RepairEstimate:
if estimate.status != "sent":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
now = datetime.now(UTC)
estimate.customer_response_message = payload.message
if decision == "approve":
estimate.status = "approved"
estimate.approved_at = now
event_type = "approved"
repair_status = "approved"
note = "Kunde hat den Kostenvoranschlag freigegeben"
audit_action = "repair_estimates.approve"
elif decision == "decline":
estimate.status = "declined"
estimate.declined_at = now
event_type = "declined"
repair_status = "waiting_for_customer"
note = "Kunde hat den Kostenvoranschlag abgelehnt"
audit_action = "repair_estimates.decline"
else:
event_type = "question"
repair_status = "waiting_for_customer"
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
audit_action = "repair_estimates.question"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action=audit_action,
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
request=request,
metadata={"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:
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)
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
estimate.subtotal_cents = subtotal
estimate.tax_cents = tax_cents
estimate.total_cents = subtotal + tax_cents
estimate.items = items
@staticmethod
def _build_items(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)
subtotal += total
items.append(
RepairEstimateItem(
position=index,
item_type=payload.item_type,
title=payload.title,
description=payload.description,
quantity=payload.quantity,
unit=payload.unit,
unit_price_cents=payload.unit_price_cents,
total_cents=total,
)
)
return items, subtotal
@staticmethod
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n"
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _estimate_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>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
<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>"""

View file

@ -11,6 +11,7 @@ from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.schemas.repair import (
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
@ -131,16 +132,12 @@ class RepairPublicLinkService:
@staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id)
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
from app.services.repair_estimate_service import RepairEstimateService
response = RepairPublicStatusResponse(
repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
@ -155,6 +152,18 @@ class RepairPublicLinkService:
for item in history
],
updated_at=repair.updated_at,
estimate=RepairEstimateService.public_response(estimate),
)
RepairRepository.mark_public_link_used(db, public_link)
return response
@staticmethod
def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
return public_link