"""Ticket business logic — CRUD, status transitions, SLA enforcement.""" from __future__ import annotations from datetime import datetime, timezone from typing import Any from fastapi import HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline from app.models.unit import Unit from app.models.user import User from app.services.sla import compute_sla_deadline, get_sla_status # ── Status Transition Map ──────────────────────────────────────────── # Keys: current status → list of valid next statuses VALID_TRANSITIONS: dict[str, list[str]] = { "New": ["Logged"], "Logged": ["Triage", "Closed"], "Triage": ["Assigned", "Escalated"], "Assigned": ["Accepted", "Triage"], "Accepted": ["Travelling", "Triage"], "Travelling": ["On Site", "Triage"], "On Site": ["In Progress", "Triage"], "In Progress": ["Waiting Parts", "Escalated", "Completed"], "Waiting Parts": ["In Progress", "Escalated"], "Escalated": ["Triage", "In Progress", "Completed", "Closed"], "Completed": ["On-Field Verification", "In Progress"], "On-Field Verification": ["Wahab Review", "Completed", "Closed"], "Wahab Review": ["Closed", "On-Field Verification"], "Closed": ["Reopened"], "Reopened": ["Triage", "Logged"], } REOPEN_WINDOW_DAYS = 7 SLA_ACK_USER = "Ama" # ── Helpers ────────────────────────────────────────────────────────── async def _generate_ticket_number(db: AsyncSession) -> str: """Generate the next ticket number in PAV-YYYY-NNNNN format.""" year = datetime.now(timezone.utc).year prefix = f"PAV-{year}-" # Count existing tickets this year result = await db.execute( select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%")) ) count = result.scalar() or 0 return f"{prefix}{count + 1:05d}" async def _log_status_change( db: AsyncSession, ticket_id: int, from_status: str | None, to_status: str | None, note: str | None = None, user_id: int | None = None, ) -> TicketTimeline: """Create a timeline entry for a status change.""" entry = TicketTimeline( ticket_id=ticket_id, from_status=from_status, to_status=to_status, note=note, user_id=user_id, ) db.add(entry) await db.flush() return entry async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket: """Fetch a ticket by ID or raise 404.""" result = await db.execute( select(Ticket) .options( selectinload(Ticket.timeline), selectinload(Ticket.photos), selectinload(Ticket.assigned_technician), selectinload(Ticket.unit), selectinload(Ticket.category), ) .where(Ticket.id == ticket_id) ) ticket = result.scalar_one_or_none() if ticket is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found") return ticket # ── CRUD ───────────────────────────────────────────────────────────── async def create_ticket( db: AsyncSession, data: dict[str, Any], user: User | None = None, ) -> Ticket: """Create a new ticket with auto-numbering and SLA deadline.""" ticket_number = await _generate_ticket_number(db) priority = data.get("priority") sla_deadline = compute_sla_deadline(priority) if priority else None ticket = Ticket( ticket_number=ticket_number, status="New", unit_id=data.get("unit_id"), category_id=data.get("category_id"), priority=priority, reporter=data.get("reporter"), reported_via=data.get("reported_via"), description=data.get("description"), assigned_to=data.get("assigned_to"), sla_deadline=sla_deadline, ) db.add(ticket) await db.flush() await db.refresh(ticket) # Log initial creation await _log_status_change( db, ticket.id, from_status=None, to_status="New", note="Ticket created", user_id=user.id if user else None, ) # Auto-advance New → Logged (creation is also the logging step) ticket.status = "Logged" await _log_status_change( db, ticket.id, from_status="New", to_status="Logged", note="Ticket logged and numbered", user_id=user.id if user else None, ) await db.flush() await db.refresh(ticket) return ticket async def get_ticket(db: AsyncSession, ticket_id: int) -> Ticket: """Get a single ticket with full details.""" return await _get_ticket_or_404(db, ticket_id) async def list_tickets( db: AsyncSession, *, status_filter: str | None = None, priority_filter: str | None = None, property_filter: str | None = None, category_id: int | None = None, assigned_to: int | None = None, date_from: datetime | None = None, date_to: datetime | None = None, page: int = 1, page_size: int = 50, ) -> tuple[list[Ticket], int]: """List tickets with optional filters. Returns (tickets, total_count).""" query = select(Ticket) count_query = select(func.count(Ticket.id)) if status_filter: query = query.where(Ticket.status == status_filter) count_query = count_query.where(Ticket.status == status_filter) if priority_filter: query = query.where(Ticket.priority == priority_filter) count_query = count_query.where(Ticket.priority == priority_filter) if property_filter: # Join unit to filter by property query = query.join(Ticket.unit).where(Unit.property == property_filter) count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter) if category_id: query = query.where(Ticket.category_id == category_id) count_query = count_query.where(Ticket.category_id == category_id) if assigned_to: query = query.where(Ticket.assigned_to == assigned_to) count_query = count_query.where(Ticket.assigned_to == assigned_to) if date_from: query = query.where(Ticket.created_at >= date_from) count_query = count_query.where(Ticket.created_at >= date_from) if date_to: query = query.where(Ticket.created_at <= date_to) count_query = count_query.where(Ticket.created_at <= date_to) # Count total total_result = await db.execute(count_query) total = total_result.scalar() or 0 # Paginate offset = (page - 1) * page_size query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size) result = await db.execute(query) tickets = list(result.scalars().all()) return tickets, total async def update_ticket( db: AsyncSession, ticket_id: int, data: dict[str, Any], user: User | None = None, ) -> Ticket: """Update a ticket. Status changes are validated and logged.""" ticket = await _get_ticket_or_404(db, ticket_id) # Handle status transitions separately new_status = data.get("status") if new_status is not None: old_status = ticket.status if old_status != new_status: valid_targets = VALID_TRANSITIONS.get(old_status, []) if new_status not in valid_targets: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status transition: {old_status} → {new_status}. " f"Valid targets: {valid_targets}", ) # Handle special logic for reopening if new_status == "Reopened": if old_status != "Closed": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Can only reopen a Closed ticket", ) if ticket.closed_at: closed_at = ticket.closed_at if closed_at.tzinfo is None: closed_at = closed_at.replace(tzinfo=timezone.utc) days_since_close = (datetime.now(timezone.utc) - closed_at).days if days_since_close > REOPEN_WINDOW_DAYS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot reopen: ticket closed {days_since_close} days ago " f"(max {REOPEN_WINDOW_DAYS} days)", ) # Auto-escalate to Ama ama_result = await db.execute( select(User).where(User.full_name == SLA_ACK_USER) ) ama_user = ama_result.scalar_one_or_none() ticket.reopen_count = (ticket.reopen_count or 0) + 1 ticket.closed_at = None if ama_user: escalation = Escalation( ticket_id=ticket.id, escalated_to=ama_user.id, reason=f"Auto-escalation: ticket reopened (reopen #{ticket.reopen_count})", ) db.add(escalation) # Handle closing if new_status == "Closed": ticket.closed_at = datetime.now(timezone.utc) # Log the transition await _log_status_change( db, ticket.id, from_status=old_status, to_status=new_status, note=data.get("note"), user_id=user.id if user else None, ) ticket.status = new_status # Recompute SLA if priority changed if data.get("priority") and data["priority"] != ticket.priority: ticket.sla_deadline = compute_sla_deadline(data["priority"]) # Update other fields for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", "description", "assigned_to", "eta", "cost", "parts_used"): if field in data: setattr(ticket, field, data[field]) # Recompute SLA if priority changed and status didn't change if "priority" in data and not new_status: ticket.sla_deadline = compute_sla_deadline(data["priority"]) await db.flush() await db.refresh(ticket) return ticket