- Remove duplicate import - Fix → in create ticket - Fix → in ticket detail - Add note field to TicketUpdate schema and handle note-only updates in backend - Update frontend submitNote() to use PATCH endpoint
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""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, 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"],
|
|
"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 += 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
|
|
|
|
# Handle standalone note (no status change)
|
|
note_only = data.get("note")
|
|
if note_only and (new_status is None or new_status == ticket.status):
|
|
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", "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
|
|
|
|
|