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