Spaces:
Sleeping
Sleeping
File size: 10,213 Bytes
38ac151 cb81253 38ac151 cb81253 38ac151 96d849a 38ac151 95005e1 38ac151 d9b075a 38ac151 456b2e2 38ac151 456b2e2 38ac151 456b2e2 38ac151 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | """
Ticket Assignment Model
Tracks the complete history of ticket assignments including:
- Who is assigned to what ticket
- Timeline of work (assigned β accepted β journey β arrived β completed)
- GPS tracking (journey breadcrumbs + arrival verification)
- Execution planning (agent's queue management)
- Performance metrics (travel time, work time)
Key Principles:
- Each row = one work attempt by one agent on one day
- Reassignments create NEW rows (preserves daily history)
- Multiple active assignments = team work
- State derived from timeline fields (no separate status column)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, Text, Boolean, DateTime, DECIMAL, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
import uuid
class TicketAssignment(BaseModel):
"""
Ticket Assignment - Tracks WHO is doing WHAT work
Timeline States (derived from fields):
- PENDING: assigned_at set, responded_at NULL
- ACCEPTED: responded_at set, journey_started_at NULL
- IN_TRANSIT: journey_started_at set, arrived_at NULL
- ON_SITE: arrived_at set, ended_at NULL
- CLOSED: ended_at set
"""
__tablename__ = "ticket_assignments"
# Primary Key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Foreign Keys
ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
assigned_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Assignment Details
action = Column(Text, nullable=False, index=True) # 'assigned', 'self_assigned', 'accepted', 'rejected', 'dropped', 'reassigned', 'unassigned'
is_self_assigned = Column(Boolean, default=False, nullable=False) # True when agent picks from pool
# Execution Planning (Agent Queue Management)
execution_order = Column(Integer, nullable=True) # Agent's planned sequence (1, 2, 3...)
planned_start_time = Column(DateTime(timezone=True), nullable=True)
# Timeline (defines assignment state)
assigned_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.utcnow())
responded_at = Column(DateTime(timezone=True), nullable=True) # When agent accepted/rejected
journey_started_at = Column(DateTime(timezone=True), nullable=True) # When agent started traveling
arrived_at = Column(DateTime(timezone=True), nullable=True) # When agent arrived at site
ended_at = Column(DateTime(timezone=True), nullable=True) # When assignment closed (dropped/completed)
# Journey Start Location (where agent was when they began journey)
journey_start_latitude = Column(DECIMAL(precision=10, scale=7), nullable=True)
journey_start_longitude = Column(DECIMAL(precision=10, scale=7), nullable=True)
# Arrival Location (GPS verification of arrival at customer site)
arrival_latitude = Column(DECIMAL(precision=10, scale=7), nullable=True)
arrival_longitude = Column(DECIMAL(precision=10, scale=7), nullable=True)
arrival_verified = Column(Boolean, default=False, nullable=False) # Human verified (no auto distance check)
# Location Tracking During Journey (GPS breadcrumb trail)
# Format: [{"lat": -1.2921, "lng": 36.8219, "accuracy": 10, "timestamp": "2024-01-15T09:30:00Z", "speed": 45}]
journey_location_history = Column(JSONB, default=list, nullable=False)
# Reason/Notes
reason = Column(Text, nullable=True) # Why assigned/dropped/rejected
notes = Column(Text, nullable=True)
# Timesheet Sync Tracking
timesheet_synced = Column(Boolean, default=False, nullable=False) # Has this been synced to timesheets?
timesheet_synced_at = Column(DateTime(timezone=True), nullable=True) # When was it synced?
# Relationships
ticket = relationship("Ticket", back_populates="assignments", lazy="select")
user = relationship("User", foreign_keys=[user_id], lazy="select")
assigned_by = relationship("User", foreign_keys=[assigned_by_user_id], lazy="select")
expenses = relationship("TicketExpense", back_populates="assignment", cascade="all, delete-orphan")
# status_changes = relationship("TicketStatusHistory", back_populates="assignment", cascade="all, delete-orphan") # Model doesn't exist yet
def __repr__(self):
return f"<TicketAssignment(id={self.id}, ticket_id={self.ticket_id}, user_id={self.user_id}, action={self.action}, status={self.status})>"
# ============================================
# Computed Properties
# ============================================
@property
def status(self) -> str:
"""
Derive assignment status from timeline fields.
No separate status column needed.
"""
if self.ended_at:
return "CLOSED"
if self.arrived_at:
return "ON_SITE"
if self.journey_started_at:
return "IN_TRANSIT"
if self.responded_at:
return "ACCEPTED"
return "PENDING"
@property
def is_active(self) -> bool:
"""Assignment is active if not ended"""
return self.ended_at is None and self.deleted_at is None
@property
def travel_time_minutes(self) -> Optional[int]:
"""Calculate travel time (responded_at β arrived_at)"""
if self.responded_at and self.arrived_at:
delta = self.arrived_at - self.responded_at
return int(delta.total_seconds() / 60)
return None
@property
def work_time_minutes(self) -> Optional[int]:
"""Calculate work time (arrived_at β ended_at)"""
if self.arrived_at and self.ended_at:
delta = self.ended_at - self.arrived_at
return int(delta.total_seconds() / 60)
return None
@property
def total_time_minutes(self) -> Optional[int]:
"""Calculate total assignment time (assigned_at β ended_at)"""
if self.assigned_at and self.ended_at:
delta = self.ended_at - self.assigned_at
return int(delta.total_seconds() / 60)
return None
@property
def journey_distance_km(self) -> Optional[float]:
"""
Calculate approximate journey distance from GPS breadcrumbs.
Uses centralized Haversine formula from geo utilities.
"""
if not self.journey_location_history or len(self.journey_location_history) < 2:
return None
from app.utils.geo import haversine_distance
total_distance = 0.0
for i in range(len(self.journey_location_history) - 1):
point1 = self.journey_location_history[i]
point2 = self.journey_location_history[i + 1]
if all(k in point1 for k in ["lat", "lng"]) and all(k in point2 for k in ["lat", "lng"]):
try:
distance = haversine_distance(
float(point1["lat"]),
float(point1["lng"]),
float(point2["lat"]),
float(point2["lng"])
)
total_distance += distance
except (ValueError, TypeError):
# Skip invalid coordinates
continue
return round(total_distance, 2)
# ============================================
# Business Logic Methods
# ============================================
def mark_accepted(self, notes: Optional[str] = None):
"""Agent accepts assignment"""
self.responded_at = datetime.utcnow()
if notes:
self.notes = notes
def mark_rejected(self, reason: str):
"""Agent rejects assignment - closes assignment immediately"""
self.responded_at = datetime.utcnow()
self.ended_at = datetime.utcnow()
self.reason = reason
def start_journey(self, latitude: float, longitude: float):
"""Agent starts traveling to site"""
self.journey_started_at = datetime.utcnow()
self.journey_start_latitude = latitude
self.journey_start_longitude = longitude
def add_location_breadcrumb(self, latitude: float, longitude: float, accuracy: Optional[float] = None,
speed: Optional[float] = None, **kwargs):
"""Add GPS location to journey trail"""
if not isinstance(self.journey_location_history, list):
self.journey_location_history = []
breadcrumb = {
"lat": latitude,
"lng": longitude,
"timestamp": datetime.utcnow().isoformat(),
}
if accuracy is not None:
breadcrumb["accuracy"] = accuracy
if speed is not None:
breadcrumb["speed"] = speed
# Add any additional metadata
breadcrumb.update(kwargs)
self.journey_location_history.append(breadcrumb)
def mark_arrived(self, latitude: float, longitude: float):
"""Agent arrives at customer site"""
self.arrived_at = datetime.utcnow()
self.arrival_latitude = latitude
self.arrival_longitude = longitude
# arrival_verified is set manually by human (no auto distance check)
def mark_dropped(self, reason: str):
"""Agent drops ticket (can't complete)"""
self.ended_at = datetime.utcnow()
self.reason = reason
def mark_completed(self):
"""Work completed - assignment ends"""
self.ended_at = datetime.utcnow()
def update_execution_order(self, order: int, planned_start: Optional[datetime] = None):
"""Agent reorders their ticket queue"""
self.execution_order = order
if planned_start:
self.planned_start_time = planned_start
def can_claim_expenses(self) -> bool:
"""
Check if agent can claim expenses for this assignment.
Must have arrived at site.
"""
return self.arrived_at is not None
|