Files
denya-onecare/app/services/ticket.py
T
Mumuni (Hermes) 106699ac5e feat: apply Wahab Abdul's 2026-09-28 directives (roster, sub-contractors, penthouses)
Three client decisions for Denya OneCare / Pavilion Accra:

1. Technician roster converges to exactly 5 named techs
   - Samuel Shang, Desmond Afful, Desmond Odekyi, Francis Norgbey, Nicholas Nartey
   - New app/services/roster.py: converge_tech_roster() runs at startup and is
     idempotent; off-roster techs are DEACTIVATED, never deleted, so ticket
     history keeps a valid assignee reference
   - seed.py SEED_USERS_DATA updated; placeholder emails until client confirms

2. Sub-contractors appear in the "Assign to" list alongside technicians
   - New canonical role "Sub-contractor" (ASSIGNEE_POOL_ROLES = Tech + Sub-contractor)
   - New GET /api/auth/assignees endpoint returns active pool members only
   - Server-side _validate_assignee gate in ticket service rejects off-pool
     or deactivated assignees (400/404)
   - "Assign To" dropdown added to the new-ticket form; assigning at creation
     auto-advances Logged -> Assigned
   - base.html isTech() includes Sub-contractor (portal UX, tracked "under tech")

3. Penthouse units selectable when raising a ticket
   - apartment_mapping.json: PH1E-/PH1W-/PH2E-/PH2W- -> clean codes
   - seed_units self-heals legacy malformed codes on existing DBs and sets floors
   - Penthouse units added to the built-in fallback seed

Tests: new tests/test_wahab_directives_20260928.py (8 tests); updated the
stale East unit count in test_categories_and_units.py (60 -> 62 with penthouses).
Full suite green.
2026-09-28 21:42:56 +00:00

564 lines
21 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 delete as sa_delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.roles import ASSIGNEE_POOL_ROLES
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 _validate_assignee(db: AsyncSession, assigned_to: int | None) -> None:
"""Assignment must land on an active member of the pool (Tech roster +
sub-contractors, client directive 2026-09-28). Deactivated accounts —
replaced technicians included — can no longer receive new work."""
if assigned_to is None:
return
user = (
await db.execute(select(User).where(User.id == assigned_to))
).scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Assignee not found")
if not user.active or user.role not in ASSIGNEE_POOL_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{user.full_name} is not in the assignable pool "
f"({'/'.join(ASSIGNEE_POOL_ROLES)}, active only)",
)
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)
await _validate_assignee(db, data.get("assigned_to"))
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,
)
# Assigning at creation advances Logged → Assigned (same rule as PATCH
# /tickets/{id} — client directive 2026-09-28 added the "Assign to"
# picker on the new-ticket form).
if ticket.assigned_to:
ticket.status = "Assigned"
await _log_status_change(
db,
ticket.id,
from_status="Logged",
to_status="Assigned",
note="Assigned at creation",
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)
await _validate_assignee(db, data.get("assigned_to"))
# 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 #<id>"``).
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