"""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 # Original report date: backdated/backfilled tickets keep their true date; # when omitted the ticket is considered reported right now. The SLA clock # is unchanged — deadlines run from creation time, not the reported date. reported_at = data.get("reported_at") or datetime.now(timezone.utc) 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"), reported_at=reported_at, 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 # ── Technician performance ─────────────────────────────────────────── # Buckets for the technician-performance dashboard (Wahab request). The map is # intentionally not exhaustive: any status missing from it is counted as # "pending" so the named buckets always sum to ``total_assigned`` and no # assigned ticket is silently dropped from the report. _TECH_STATUS_BUCKETS: dict[str, str] = { "Completed": "completed", "Closed": "completed", "Accepted": "in_progress", "Travelling": "in_progress", "On Site": "in_progress", "In Progress": "in_progress", "Waiting Parts": "in_progress", "Escalated": "escalated", "Cancelled": "cancelled", } def _bucket_for_status(status: str) -> str: """Map a ticket status onto its technician-performance bucket.""" return _TECH_STATUS_BUCKETS.get(status, "pending") def _empty_tech_entry(technician_id: int, name: str) -> dict[str, Any]: return { "technician_id": technician_id, "name": name, "total_assigned": 0, "completed": 0, "closed": 0, "in_progress": 0, "escalated": 0, "cancelled": 0, "pending": 0, "open_tickets": 0, "completion_rate": 0.0, "avg_resolution_hours": None, "resolved_with_timestamps": 0, "resolved_without_timestamps": 0, "status_breakdown": {}, "_hours_sum": 0.0, } async def get_technician_performance(db: AsyncSession) -> dict[str, Any]: """Aggregate per-technician workload/outcome stats for the dashboard. Derived entirely from existing ``tickets`` columns (``assigned_to``, ``status``, ``created_at``, ``closed_at``) joined to ``users.full_name`` — no schema change. Technician identity is keyed on the user id so two people sharing a display name stay separate rows, while the reported label is the real name (never ``"Tech #"``). Completion rate is ``completed / total_assigned`` (Completed + Closed count as completed). Resolution time averages ``created_at -> closed_at`` only for rows where ``closed_at`` is set; the number of finished tasks lacking that timestamp is reported separately so an absent average is never mistaken for missing work. """ rows = ( await db.execute( select( Ticket.assigned_to, User.full_name, Ticket.status, Ticket.created_at, Ticket.closed_at, ) .join(User, User.id == Ticket.assigned_to) .where(Ticket.assigned_to.is_not(None)) ) ).all() unassigned_result = await db.execute( select(func.count(Ticket.id)).where(Ticket.assigned_to.is_(None)) ) unassigned_tickets = unassigned_result.scalar() or 0 by_tech: dict[int, dict[str, Any]] = {} for assigned_to, full_name, status, created_at, closed_at in rows: entry = by_tech.get(assigned_to) if entry is None: entry = _empty_tech_entry(assigned_to, full_name) by_tech[assigned_to] = entry entry["total_assigned"] += 1 bucket = _bucket_for_status(status) if bucket == "completed": entry["completed"] += 1 if status == "Closed": entry["closed"] += 1 if closed_at is not None and created_at is not None: hours = (closed_at - created_at).total_seconds() / 3600 entry["_hours_sum"] += hours entry["resolved_with_timestamps"] += 1 else: entry["resolved_without_timestamps"] += 1 else: entry[bucket] += 1 breakdown = entry["status_breakdown"] breakdown[status] = breakdown.get(status, 0) + 1 technicians: list[dict[str, Any]] = [] total_assigned = total_completed = total_closed = 0 total_in_progress = total_escalated = total_cancelled = total_pending = 0 total_hours = 0.0 total_with_timestamps = total_without_timestamps = 0 for entry in by_tech.values(): assigned = entry["total_assigned"] entry["open_tickets"] = assigned - entry["completed"] - entry["cancelled"] entry["completion_rate"] = round(entry["completed"] / assigned * 100, 1) if assigned else 0.0 if entry["resolved_with_timestamps"]: entry["avg_resolution_hours"] = round( entry["_hours_sum"] / entry["resolved_with_timestamps"], 1 ) entry["status_breakdown"] = dict( sorted(entry["status_breakdown"].items(), key=lambda kv: (-kv[1], kv[0])) ) total_assigned += assigned total_completed += entry["completed"] total_closed += entry["closed"] total_in_progress += entry["in_progress"] total_escalated += entry["escalated"] total_cancelled += entry["cancelled"] total_pending += entry["pending"] total_hours += entry["_hours_sum"] total_with_timestamps += entry["resolved_with_timestamps"] total_without_timestamps += entry["resolved_without_timestamps"] del entry["_hours_sum"] technicians.append(entry) # Busiest/most productive first; ties broken by workload then real name. technicians.sort(key=lambda e: (-e["completed"], -e["total_assigned"], e["name"].lower())) totals = { "technicians": len(technicians), "total_assigned": total_assigned, "completed": total_completed, "closed": total_closed, "in_progress": total_in_progress, "escalated": total_escalated, "cancelled": total_cancelled, "pending": total_pending, "open_tickets": total_assigned - total_completed - total_cancelled, "completion_rate": round(total_completed / total_assigned * 100, 1) if total_assigned else 0.0, "avg_resolution_hours": round(total_hours / total_with_timestamps, 1) if total_with_timestamps else None, "resolved_with_timestamps": total_with_timestamps, "resolved_without_timestamps": total_without_timestamps, "unassigned_tickets": unassigned_tickets, "total_tickets": total_assigned + unassigned_tickets, } return { "generated_at": datetime.now(timezone.utc), "technicians": technicians, "totals": totals, } 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