Wahab customer request (relay #748 v3 next-wave). 3a. Fix 'Tech #N' display: - FM dashboard's "Technician Workload" card was built from `Tech #${t.assigned_to}`; it now reads Ticket.assigned_technician_name (falling back to 'Unassigned', matching /tickets). - Template sweep confirms no other `Tech #` placeholder exists. 3b. Technician performance dashboard: - New bearer-gated GET /api/tickets/tech-performance aggregating existing ticket columns only (no schema change): per-technician total assigned, completed, in progress, escalated, cancelled, pending, open tasks, completion rate, and created_at -> closed_at resolution time with explicit counts for finished tasks lacking a timestamp. Rows key on user id so same-name technicians stay separate; labels are real names. Fleet totals and an unassigned-ticket count are included. - New /dashboard/tech-performance page (FM + CEO nav and links) with sortable table, completion bars and empty states; same-origin vendored assets only. - Bucket map, service, schemas and tests in app/services/ticket.py, app/schemas/ticket.py, tests/test_tech_performance.py. Tests: 111 passed (101 existing + 10 new).
194 lines
6.0 KiB
Python
194 lines
6.0 KiB
Python
"""Pydantic schemas for ticket CRUD, timeline, photos, and SLA."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
# ── Unit ─────────────────────────────────────────────────────────────
|
|
class UnitOut(BaseModel):
|
|
id: int
|
|
property: str
|
|
apartment_code: str
|
|
building: str | None = None
|
|
floor: int | None = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Category ─────────────────────────────────────────────────────────
|
|
class CategoryOut(BaseModel):
|
|
id: int
|
|
type: str
|
|
name: str
|
|
parent_id: int | None = None
|
|
sla_urgency: str | None = None
|
|
show_in_form: bool = True
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class CategoryTreeOut(CategoryOut):
|
|
children: list[CategoryTreeOut] = []
|
|
|
|
|
|
# ── Ticket ───────────────────────────────────────────────────────────
|
|
class TicketCreate(BaseModel):
|
|
unit_id: int | None = None
|
|
category_id: int | None = None
|
|
priority: str | None = None # urgent, high, medium, low
|
|
reporter: str | None = None
|
|
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
|
|
description: str | None = None
|
|
assigned_to: int | None = None
|
|
customer_name: str | None = None
|
|
phone: str | None = None
|
|
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
|
|
|
|
|
|
class TicketUpdate(BaseModel):
|
|
status: str | None = None
|
|
unit_id: int | None = None
|
|
category_id: int | None = None
|
|
priority: str | None = None
|
|
reporter: str | None = None
|
|
phone: str | None = None
|
|
reported_via: str | None = None
|
|
description: str | None = None
|
|
assigned_to: int | None = None
|
|
eta: datetime | None = None
|
|
cost: Decimal | None = None
|
|
parts_used: str | None = None
|
|
note: str | None = None
|
|
|
|
|
|
class TicketTimelineOut(BaseModel):
|
|
id: int
|
|
ticket_id: int
|
|
from_status: str | None = None
|
|
to_status: str | None = None
|
|
note: str | None = None
|
|
user_id: int | None = None
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class TicketPhotoOut(BaseModel):
|
|
id: int
|
|
ticket_id: int
|
|
photo_url: str
|
|
is_before: bool
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class TicketBrief(BaseModel):
|
|
"""Summary view for list endpoints."""
|
|
id: int
|
|
ticket_number: str
|
|
status: str
|
|
priority: str | None = None
|
|
unit_id: int | None = None
|
|
category_id: int | None = None
|
|
assigned_to: int | None = None
|
|
assigned_technician_name: str | None = None
|
|
reporter: str | None = None
|
|
phone: str | None = None
|
|
description: str | None = None
|
|
sla_deadline: datetime | None = None
|
|
reopen_count: int = 0
|
|
reported_at: datetime | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class TicketOut(TicketBrief):
|
|
"""Full ticket detail with relations."""
|
|
closed_at: datetime | None = None
|
|
eta: datetime | None = None
|
|
cost: Decimal | None = None
|
|
parts_used: str | None = None
|
|
customer_rating: int | None = None
|
|
timeline: list[TicketTimelineOut] = []
|
|
photos: list[TicketPhotoOut] = []
|
|
unit: UnitOut | None = None
|
|
category: CategoryOut | None = None
|
|
sla_status: dict | None = None
|
|
|
|
|
|
class TicketListResponse(BaseModel):
|
|
items: list[TicketBrief]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
# ── Technician performance ───────────────────────────────────────────
|
|
class TechnicianPerformanceOut(BaseModel):
|
|
"""Per-technician workload and outcome aggregate (by real name).
|
|
|
|
``open_tickets`` is ``total_assigned - completed - cancelled``;
|
|
``pending`` is the remainder bucket (statuses such as New/Logged/Triage/
|
|
Assigned plus verification stages) and keeps the named buckets summing to
|
|
``total_assigned``. ``avg_resolution_hours`` averages ``created_at ->
|
|
closed_at`` and is ``null`` when no finished task carries a timestamp —
|
|
``resolved_without_timestamps`` then says how many those are.
|
|
"""
|
|
technician_id: int
|
|
name: str
|
|
total_assigned: int
|
|
completed: int
|
|
closed: int
|
|
in_progress: int
|
|
escalated: int
|
|
cancelled: int
|
|
pending: int
|
|
open_tickets: int
|
|
completion_rate: float
|
|
avg_resolution_hours: float | None = None
|
|
resolved_with_timestamps: int
|
|
resolved_without_timestamps: int
|
|
status_breakdown: dict[str, int] = {}
|
|
|
|
|
|
class TechnicianPerformanceTotalsOut(BaseModel):
|
|
"""Fleet-wide roll-up of :class:`TechnicianPerformanceOut`."""
|
|
technicians: int
|
|
total_tickets: int
|
|
total_assigned: int
|
|
completed: int
|
|
closed: int
|
|
in_progress: int
|
|
escalated: int
|
|
cancelled: int
|
|
pending: int
|
|
open_tickets: int
|
|
completion_rate: float
|
|
avg_resolution_hours: float | None = None
|
|
resolved_with_timestamps: int
|
|
resolved_without_timestamps: int
|
|
unassigned_tickets: int
|
|
|
|
|
|
class TechnicianPerformanceReportOut(BaseModel):
|
|
"""Response for ``GET /api/tickets/tech-performance``."""
|
|
generated_at: datetime
|
|
technicians: list[TechnicianPerformanceOut]
|
|
totals: TechnicianPerformanceTotalsOut
|
|
|
|
|
|
# ── SLA ──────────────────────────────────────────────────────────────
|
|
class SLAStatusOut(BaseModel):
|
|
priority: str | None = None
|
|
response_deadline: str | None = None
|
|
resolution_deadline: str | None = None
|
|
response_breached: bool = False
|
|
resolution_breached: bool = False
|
|
current_status: str | None = None
|