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"""
Funktechnik Schubert

Hallo {escape(repair.customer_name)},

zu Ihrer Reparatur {escape(repair.repair_number)} liegt ein Kostenvoranschlag vor.

Gerät: {escape((repair.device_manufacturer + " " + repair.device_model).strip())}
Kostenvoranschlag: {escape(estimate.estimate_number)}
Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}

Kostenvoranschlag ansehen

"""