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.inventory_repository import InventoryRepository 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.inventory_service import InventoryService 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, "inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None], } 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, ) price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) RepairEstimateService._apply_payload(db, 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}, ) RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request) 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) price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) was_sent = estimate.status == "sent" if was_sent: InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) RepairEstimateService._apply_payload(db, estimate, payload) if was_sent: InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) write_audit_log( db, action="inventory.estimate.reserve", 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}, ) RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) 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}, ) RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request) return updated @staticmethod def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None: if estimate.status not in {"draft", "sent", "cancelled"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden") before_data = _audit_estimate_data(estimate) label = _estimate_label(estimate) if estimate.status == "sent": InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) write_audit_log( db, action="inventory.estimate.release", entity_type="repair_estimates", entity_id=estimate.id, entity_label=label, actor=actor, request=request, metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number}, ) 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") was_sent = estimate.status == "sent" if not was_sent: InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) 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) if not was_sent: write_audit_log( db, action="inventory.estimate.reserve", 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}, ) RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) 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") was_sent = estimate.status == "sent" if was_sent: InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) 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}, ) if was_sent: write_audit_log( db, action="inventory.estimate.release", entity_type="repair_estimates", entity_id=estimate.id, entity_label=_estimate_label(estimate), actor=actor, request=request, metadata={"reason": "estimate_cancelled", "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, inventory_snapshot_name=item.inventory_snapshot_name, inventory_snapshot_sku=item.inventory_snapshot_sku, inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer, inventory_snapshot_part_number=item.inventory_snapshot_part_number, 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" InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None) 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"}, ) if decision == "decline": write_audit_log( db, action="inventory.estimate.release", entity_type="repair_estimates", entity_id=estimate.id, entity_label=_estimate_label(estimate), request=request, metadata={"reason": "estimate_declined", "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(db: Session, 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(db, 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( db: Session, payload_items: list[RepairEstimateItemPayload], ) -> tuple[list[RepairEstimateItem], int]: items: list[RepairEstimateItem] = [] subtotal = 0 for index, payload in enumerate(payload_items, start=1): item_type = payload.item_type title = payload.title description = payload.description unit = payload.unit unit_price_cents = payload.unit_price_cents inventory_item_id = payload.inventory_item_id inventory_snapshot_name = "" inventory_snapshot_sku = "" inventory_snapshot_manufacturer = None inventory_snapshot_part_number = None if inventory_item_id is not None: inventory_item = InventoryRepository.get_item(db, inventory_item_id) if inventory_item is None or not inventory_item.is_active: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden") item_type = "part" title = inventory_item.name unit = inventory_item.unit inventory_snapshot_name = inventory_item.name inventory_snapshot_sku = inventory_item.sku inventory_snapshot_manufacturer = inventory_item.manufacturer inventory_snapshot_part_number = inventory_item.manufacturer_part_number if not payload.inventory_price_overridden: unit_price_cents = inventory_item.selling_price_cents or 0 total = calculate_item_total(payload.quantity, unit_price_cents) subtotal += total items.append( RepairEstimateItem( inventory_item_id=inventory_item_id, inventory_snapshot_name=inventory_snapshot_name, inventory_snapshot_sku=inventory_snapshot_sku, inventory_snapshot_manufacturer=inventory_snapshot_manufacturer, inventory_snapshot_part_number=inventory_snapshot_part_number, position=index, item_type=item_type, title=title, description=description, quantity=payload.quantity, unit=unit, unit_price_cents=unit_price_cents, total_cents=total, ) ) return items, subtotal @staticmethod def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]: overrides: list[dict] = [] for payload in payload_items: if payload.inventory_item_id is None or not payload.inventory_price_overridden: continue inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id) if inventory_item is None: continue default_price = inventory_item.selling_price_cents or 0 if payload.unit_price_cents != default_price: overrides.append({ "inventory_item_id": inventory_item.id, "label": f"{inventory_item.sku} · {inventory_item.name}", "default_price_cents": default_price, "override_price_cents": payload.unit_price_cents, }) return overrides @staticmethod def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None: for override in overrides: write_audit_log( db, action="inventory.estimate.price_override", entity_type="inventory_items", entity_id=override["inventory_item_id"], entity_label=override["label"], actor=actor, request=request, metadata={ "estimate_id": estimate.id, "estimate_number": estimate.estimate_number, "default_price_cents": override["default_price_cents"], "override_price_cents": override["override_price_cents"], }, ) @staticmethod def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None: seen_item_ids: set[int] = set() for estimate_item in estimate.items: if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids: continue seen_item_ids.add(estimate_item.inventory_item_id) inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level: continue write_audit_log( db, action="inventory.stock.low", entity_type="inventory_items", entity_id=inventory_item.id, entity_label=f"{inventory_item.sku} · {inventory_item.name}", actor=actor, request=request, metadata={ "estimate_id": estimate.id, "estimate_number": estimate.estimate_number, "quantity_available": inventory_item.quantity_available, "reorder_level": inventory_item.reorder_level, }, ) @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())} |