"""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 delete as sa_delete, 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 # ── Status Transition Map ──────────────────────────────────────────── # Keys: current status → list of valid next statuses VALID_TRANSITIONS: dict[str, list[str]] = { "New": ["Logged", "Cancelled"], "Logged": ["Triage", "Closed", "Cancelled"], "Triage": ["Assigned", "Escalated", "Cancelled"], "Assigned": ["Accepted", "Triage", "Cancelled"], "Accepted": ["Travelling", "Triage", "Cancelled"], "Travelling": ["On Site", "Triage", "Cancelled"], "On Site": ["In Progress", "Triage", "Cancelled"], "In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"], "Waiting Parts": ["In Progress", "Escalated", "Cancelled"], "Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"], "Completed": ["On-Field Verification", "In Progress", "Cancelled"], "On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"], "Wahab Review": ["Closed", "On-Field Verification", "Cancelled"], "Closed": ["Reopened"], "Reopened": ["Triage", "Logged", "Cancelled"], # Terminal: cancelled tickets cannot resume work. "Cancelled": [], } 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. Uses the highest existing suffix + 1 (not a row count) so that deleting tickets never re-issues an already-used number. """ year = datetime.now(timezone.utc).year prefix = f"PAV-{year}-" result = await db.execute( select(func.max(Ticket.ticket_number)).where(Ticket.ticket_number.like(f"{prefix}%")) ) max_number = result.scalar() if max_number: try: next_seq = int(max_number.rsplit("-", 1)[1]) + 1 except (ValueError, IndexError): next_seq = 1 else: next_seq = 1 return f"{prefix}{next_seq: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") or data.get("customer_name"), phone=data.get("phone"), 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, building_filter: str | None = None, unit_id: int | 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 building_filter: query = query.join(Ticket.unit).where(Unit.building == building_filter) count_query = count_query.join(Ticket.unit).where(Unit.building == building_filter) if unit_id: query = query.where(Ticket.unit_id == unit_id) count_query = count_query.where(Ticket.unit_id == unit_id) 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(), Ticket.id.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician)) 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") old_status = ticket.status status_changed = new_status is not None and old_status != new_status if new_status is not None: 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 += 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 # Assigning a technician advances pre-Assigned tickets to Assigned with a # timeline entry; tickets already past Assigned keep their current status. advanced_to_assigned = False if "assigned_to" in data and data["assigned_to"] != ticket.assigned_to: if ticket.status in {"New", "Logged", "Triage"}: await _log_status_change( db, ticket.id, from_status=ticket.status, to_status="Assigned", note=data.get("note") if not status_changed else None, user_id=user.id if user else None, ) ticket.status = "Assigned" advanced_to_assigned = True # Handle standalone note (no status change) note_only = data.get("note") if note_only and not status_changed and not advanced_to_assigned: await _log_status_change( db, ticket.id, from_status=ticket.status, to_status=ticket.status, note=note_only, user_id=user.id if user else None, ) # Update other fields for field in ("unit_id", "category_id", "priority", "reporter", "phone", "reported_via", "description", "assigned_to", "eta", "cost", "parts_used"): if field in data: setattr(ticket, field, data[field]) # Recompute SLA if priority changed if data.get("priority"): ticket.sla_deadline = compute_sla_deadline(data["priority"]) await db.flush() await db.refresh(ticket) return ticket async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket: """Delete a ticket and all dependent rows (timeline, photos, escalations). Returns the deleted ticket so the caller can remove orphaned photo files. """ ticket = await _get_ticket_or_404(db, ticket_id) # Delete escalations, timeline, and photo rows first (FK children). await db.execute(sa_delete(Escalation).where(Escalation.ticket_id == ticket_id)) await db.execute(sa_delete(TicketTimeline).where(TicketTimeline.ticket_id == ticket_id)) await db.execute(sa_delete(TicketPhoto).where(TicketPhoto.ticket_id == ticket_id)) await db.delete(ticket) await db.flush() return ticket