357 lines
16 KiB
Python
357 lines
16 KiB
Python
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>"""
|