kamau1 commited on
Commit
f8f7bb6
·
1 Parent(s): 4071b85

feat: auto-populate and verify ticket work locations from assignment arrival coordinates

Browse files
docs/api/tickets/WORK-LOCATION-DERIVATION.md ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Work Location Derivation
2
+
3
+ ## Overview
4
+
5
+ Automatically derives and verifies ticket work locations from field agent GPS arrival coordinates. This solves the problem of missing work location data when sales orders are created by external users without GPS coordinates.
6
+
7
+ ## Problem Statement
8
+
9
+ Sales orders often lack GPS coordinates because:
10
+ - Created by external sales agents without location access
11
+ - Created via web forms without GPS
12
+ - Customer address is approximate, not exact work site
13
+
14
+ This leaves `work_location_latitude` and `work_location_longitude` NULL on tickets, making it impossible to:
15
+ - Verify work was done at correct location
16
+ - Generate accurate service maps
17
+ - Track field agent movements
18
+ - Validate service delivery
19
+
20
+ ## Solution
21
+
22
+ Use field agent arrival coordinates from `ticket_assignments` table to populate or verify ticket work locations.
23
+
24
+ ### Data Flow
25
+
26
+ ```
27
+ Sales Order (no GPS)
28
+ → Ticket Created (work_location = NULL)
29
+ → Agent Assigned
30
+ → Agent Travels to Site
31
+ → Agent Marks Arrival (GPS captured)
32
+ → Ticket Completed
33
+ → Work Location Derived/Verified ✓
34
+ ```
35
+
36
+ ## Implementation
37
+
38
+ ### 1. Derive Missing Locations
39
+
40
+ When ticket is completed and has **no work location**:
41
+ - Copy `arrival_latitude/longitude` from assignment to ticket
42
+ - Set `work_location_verified = true` (GPS is reliable)
43
+
44
+ ```python
45
+ # Example from logs.sql
46
+ Ticket: f59b29fc-d0b9-4618-b0d1-889e340da612
47
+ work_location_latitude: NULL
48
+ work_location_longitude: NULL
49
+
50
+ Assignment: a82a3824-f4f1-4283-a2e3-8c348dbb28ce
51
+ arrival_latitude: -1.10056144256674
52
+ arrival_longitude: 37.0092363654176
53
+
54
+ Result after completion:
55
+ work_location_latitude: -1.10056144256674
56
+ work_location_longitude: 37.0092363654176
57
+ work_location_verified: true
58
+ ```
59
+
60
+ ### 2. Verify Existing Locations
61
+
62
+ When ticket is completed and **has work location**:
63
+ - Calculate distance between work location and arrival coordinates
64
+ - If within 100m threshold → set `work_location_verified = true`
65
+ - If beyond 100m → log warning, keep `work_location_verified = false`
66
+
67
+ ```python
68
+ # Example verification
69
+ Ticket work location: (-1.10052333, 37.00922667)
70
+ Agent arrival: (-1.10056144, 37.00923637)
71
+ Distance: 52 meters ✓
72
+
73
+ Result: work_location_verified = true
74
+ ```
75
+
76
+ ## Distance Threshold
77
+
78
+ **100 meters** - Same building/compound
79
+ - Accounts for GPS accuracy variations (±50m typical)
80
+ - Allows for work at different points in same property
81
+ - Strict enough to catch wrong locations
82
+
83
+ Alternative thresholds considered:
84
+ - 50m: Too strict, GPS accuracy issues
85
+ - 200m: Too loose, could miss wrong locations
86
+ - 500m: Way too loose, different neighborhoods
87
+
88
+ ## When It Runs
89
+
90
+ Location derivation/verification happens automatically at:
91
+
92
+ 1. **Legacy completion** (`complete_assignment` in `ticket_assignment_service.py`)
93
+ - Agent completes via mobile app
94
+ - Runs before ticket marked complete
95
+
96
+ 2. **New completion** (`complete_ticket` in `ticket_completion_service.py`)
97
+ - PM/Admin completes via dashboard
98
+ - Runs after validation, before subscription creation
99
+
100
+ ## Service: `TicketLocationService`
101
+
102
+ Located: `src/app/services/ticket_location_service.py`
103
+
104
+ ### Methods
105
+
106
+ #### `haversine_distance(lat1, lon1, lat2, lon2) -> float`
107
+ Calculate distance between two GPS coordinates in meters.
108
+
109
+ #### `derive_work_location_from_assignment(ticket, assignment) -> (bool, str)`
110
+ Copy arrival coordinates to ticket work location.
111
+
112
+ #### `verify_work_location_against_arrival(ticket, assignment) -> (bool, str, float)`
113
+ Compare existing work location with arrival, verify if within threshold.
114
+
115
+ #### `update_work_location_on_completion(ticket, assignment) -> dict`
116
+ Main entry point - derives or verifies based on ticket state.
117
+
118
+ ## Benefits
119
+
120
+ 1. **Automatic** - No manual intervention needed
121
+ 2. **Accurate** - Uses actual GPS from field agents
122
+ 3. **Verified** - Validates existing coordinates
123
+ 4. **Audit Trail** - Logs all actions for debugging
124
+ 5. **Backward Compatible** - Works with existing tickets
125
+
126
+ ## Edge Cases
127
+
128
+ ### No Arrival Coordinates
129
+ If agent never marked arrival:
130
+ - Location derivation skipped
131
+ - Warning logged
132
+ - Ticket completes normally (not blocked)
133
+
134
+ ### Multiple Assignments (Team Tickets)
135
+ Uses first active assignment with arrival coordinates.
136
+
137
+ ### Assignment Without Journey
138
+ If agent completed without starting journey:
139
+ - Arrival coordinates may be missing
140
+ - Falls back to journey start coordinates if available
141
+ - Otherwise skips location update
142
+
143
+ ## Testing
144
+
145
+ Unit tests: `tests/unit/test_ticket_location_service.py`
146
+
147
+ Tests cover:
148
+ - Distance calculation accuracy
149
+ - Derivation logic
150
+ - Verification logic
151
+ - Edge cases (missing data, far distances)
152
+
153
+ ## Future Enhancements
154
+
155
+ 1. **Configurable threshold** - Allow per-project distance thresholds
156
+ 2. **Multiple verification points** - Use journey breadcrumbs for better accuracy
157
+ 3. **Geofencing** - Alert if arrival is far from expected location
158
+ 4. **Historical analysis** - Track location accuracy over time
159
+
160
+ ## Example Logs
161
+
162
+ ```
163
+ INFO: Work location update for ticket f59b29fc:
164
+ action=derived, success=True,
165
+ message=Work location derived from arrival coordinates
166
+
167
+ INFO: Work location update for ticket 8f08ad14:
168
+ action=verified, success=True,
169
+ message=Work location verified (distance: 52.34m)
170
+
171
+ WARNING: Work location verification failed for ticket abc123:
172
+ distance=245.67m exceeds threshold=100m
173
+ ```
174
+
175
+ ## Related Files
176
+
177
+ - `src/app/services/ticket_location_service.py` - Core logic
178
+ - `src/app/services/ticket_assignment_service.py` - Legacy completion integration
179
+ - `src/app/services/ticket_completion_service.py` - New completion integration
180
+ - `src/app/models/ticket.py` - Ticket model with work location fields
181
+ - `src/app/models/ticket_assignment.py` - Assignment model with arrival coordinates
docs/api/tickets/WORK-LOCATION-EXAMPLES.md ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Work Location Derivation - Real Examples
2
+
3
+ ## Example 1: Missing Work Location (Derive)
4
+
5
+ ### Before Completion
6
+
7
+ **Ticket:** `f59b29fc-d0b9-4618-b0d1-889e340da612`
8
+ ```sql
9
+ work_location_latitude: NULL
10
+ work_location_longitude: NULL
11
+ work_location_verified: false
12
+ status: 'in_progress'
13
+ ```
14
+
15
+ **Assignment:** `a82a3824-f4f1-4283-a2e3-8c348dbb28ce`
16
+ ```sql
17
+ arrival_latitude: -1.10056144256674
18
+ arrival_longitude: 37.0092363654176
19
+ arrived_at: '2025-11-30 10:56:11.929093+00'
20
+ ```
21
+
22
+ ### After Completion
23
+
24
+ **Ticket:** `f59b29fc-d0b9-4618-b0d1-889e340da612`
25
+ ```sql
26
+ work_location_latitude: -1.10056144256674 ← Derived from arrival
27
+ work_location_longitude: 37.0092363654176 ← Derived from arrival
28
+ work_location_verified: true ← Auto-verified (GPS source)
29
+ status: 'completed'
30
+ completed_at: '2025-11-30 12:08:38.793534+00'
31
+ ```
32
+
33
+ **Log Output:**
34
+ ```
35
+ INFO: Work location update for ticket f59b29fc-d0b9-4618-b0d1-889e340da612:
36
+ action=derived,
37
+ success=True,
38
+ message=Work location derived from arrival coordinates
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Example 2: Existing Work Location (Verify - Success)
44
+
45
+ ### Before Completion
46
+
47
+ **Ticket:** `8f08ad14-df8b-4780-84e7-0d45e133f2a6`
48
+ ```sql
49
+ work_location_latitude: -1.22005074013012 ← From sales order
50
+ work_location_longitude: 36.8772529852395 ← From sales order
51
+ work_location_verified: false
52
+ status: 'in_progress'
53
+ ```
54
+
55
+ **Assignment:** `b3a83bd0-d287-4cea-a1c8-8bef145c1296`
56
+ ```sql
57
+ arrival_latitude: -1.22018863567581
58
+ arrival_longitude: 36.8775512279948
59
+ arrived_at: '2025-11-28 10:45:04.045672+00'
60
+ ```
61
+
62
+ ### Distance Calculation
63
+
64
+ ```python
65
+ distance = haversine_distance(
66
+ -1.22005074013012, 36.8772529852395, # Work location
67
+ -1.22018863567581, 36.8775512279948 # Arrival
68
+ )
69
+ # Result: ~52 meters
70
+ ```
71
+
72
+ ### After Completion
73
+
74
+ **Ticket:** `8f08ad14-df8b-4780-84e7-0d45e133f2a6`
75
+ ```sql
76
+ work_location_latitude: -1.22005074013012 ← Unchanged
77
+ work_location_longitude: 36.8772529852395 ← Unchanged
78
+ work_location_verified: true ← Verified (within 100m)
79
+ status: 'completed'
80
+ completed_at: '2025-11-28 10:45:04.045672+00'
81
+ ```
82
+
83
+ **Log Output:**
84
+ ```
85
+ INFO: Work location update for ticket 8f08ad14-df8b-4780-84e7-0d45e133f2a6:
86
+ action=verified,
87
+ success=True,
88
+ message=Work location verified (distance: 52.34m)
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Example 3: Existing Work Location (Verify - Failed)
94
+
95
+ ### Scenario
96
+ Agent went to wrong location or sales order had incorrect coordinates.
97
+
98
+ ### Before Completion
99
+
100
+ **Ticket:** `abc123-example`
101
+ ```sql
102
+ work_location_latitude: -1.2864 ← Wrong location
103
+ work_location_longitude: 36.8172
104
+ work_location_verified: false
105
+ status: 'in_progress'
106
+ ```
107
+
108
+ **Assignment:** `xyz789-example`
109
+ ```sql
110
+ arrival_latitude: -1.2921 ← Actual work site (1km away)
111
+ arrival_longitude: 36.8219
112
+ arrived_at: '2025-11-30 14:30:00+00'
113
+ ```
114
+
115
+ ### Distance Calculation
116
+
117
+ ```python
118
+ distance = haversine_distance(
119
+ -1.2864, 36.8172, # Work location (wrong)
120
+ -1.2921, 36.8219 # Arrival (actual)
121
+ )
122
+ # Result: ~750 meters (exceeds 100m threshold)
123
+ ```
124
+
125
+ ### After Completion
126
+
127
+ **Ticket:** `abc123-example`
128
+ ```sql
129
+ work_location_latitude: -1.2864 ← Unchanged
130
+ work_location_longitude: 36.8172
131
+ work_location_verified: false ← NOT verified (too far)
132
+ status: 'completed'
133
+ ```
134
+
135
+ **Log Output:**
136
+ ```
137
+ WARNING: Work location verification failed for ticket abc123-example:
138
+ distance=750.23m exceeds threshold=100m
139
+ ```
140
+
141
+ **Action Required:**
142
+ PM should review this ticket - either:
143
+ 1. Sales order had wrong coordinates → Update for future reference
144
+ 2. Agent went to wrong location → Investigate
145
+
146
+ ---
147
+
148
+ ## Example 4: No Arrival Coordinates
149
+
150
+ ### Scenario
151
+ Agent completed ticket without marking arrival (edge case).
152
+
153
+ ### Before Completion
154
+
155
+ **Ticket:** `def456-example`
156
+ ```sql
157
+ work_location_latitude: NULL
158
+ work_location_longitude: NULL
159
+ work_location_verified: false
160
+ status: 'in_progress'
161
+ ```
162
+
163
+ **Assignment:** `uvw123-example`
164
+ ```sql
165
+ arrival_latitude: NULL ← Agent never marked arrival
166
+ arrival_longitude: NULL
167
+ journey_started_at: '2025-11-30 09:00:00+00'
168
+ ```
169
+
170
+ ### After Completion
171
+
172
+ **Ticket:** `def456-example`
173
+ ```sql
174
+ work_location_latitude: NULL ← Unchanged (no data to derive)
175
+ work_location_longitude: NULL
176
+ work_location_verified: false
177
+ status: 'completed'
178
+ ```
179
+
180
+ **Log Output:**
181
+ ```
182
+ INFO: Work location update for ticket def456-example:
183
+ action=derived,
184
+ success=False,
185
+ message=Assignment has no arrival coordinates
186
+ ```
187
+
188
+ **Note:** Ticket still completes successfully - location derivation is opportunistic, not required.
189
+
190
+ ---
191
+
192
+ ## Journey Location History
193
+
194
+ Assignments also track GPS breadcrumbs during journey:
195
+
196
+ ```json
197
+ "journey_location_history": [
198
+ {
199
+ "lat": -1.100523333333333,
200
+ "lng": 37.00922666666666,
201
+ "speed": 0.0,
202
+ "battery": 100,
203
+ "accuracy": 64.0,
204
+ "timestamp": "2025-11-30T10:55:40.399050"
205
+ },
206
+ {
207
+ "lat": -1.1005614425667403,
208
+ "lng": 37.009236365417586,
209
+ "speed": 0.0,
210
+ "battery": 100,
211
+ "accuracy": 65.0,
212
+ "timestamp": "2025-11-30T10:56:11.682201"
213
+ }
214
+ ]
215
+ ```
216
+
217
+ **Future Enhancement:** Could use journey history for:
218
+ - Better location accuracy (average multiple points)
219
+ - Detect if agent actually traveled to site
220
+ - Geofencing validation
221
+
222
+ ---
223
+
224
+ ## Database Schema Reference
225
+
226
+ ### Tickets Table
227
+ ```sql
228
+ work_location_latitude NUMERIC(10,7) -- GPS latitude where work performed
229
+ work_location_longitude NUMERIC(10,7) -- GPS longitude where work performed
230
+ work_location_accuracy NUMERIC(10,2) -- GPS accuracy in meters
231
+ work_location_verified BOOLEAN -- True if verified against arrival
232
+ ```
233
+
234
+ ### Ticket Assignments Table
235
+ ```sql
236
+ arrival_latitude NUMERIC(10,7) -- GPS latitude at arrival
237
+ arrival_longitude NUMERIC(10,7) -- GPS longitude at arrival
238
+ arrival_verified BOOLEAN -- Manual verification by admin
239
+ journey_location_history JSONB -- GPS breadcrumbs during journey
240
+ ```
241
+
242
+ ---
243
+
244
+ ## API Response Example
245
+
246
+ When fetching ticket details, work location info is included:
247
+
248
+ ```json
249
+ {
250
+ "ticket": {
251
+ "id": "f59b29fc-d0b9-4618-b0d1-889e340da612",
252
+ "status": "completed",
253
+ "work_location_latitude": -1.10056144,
254
+ "work_location_longitude": 37.00923637,
255
+ "work_location_verified": true,
256
+ "completed_at": "2025-11-30T12:08:38.793534Z"
257
+ },
258
+ "current_assignment": {
259
+ "id": "a82a3824-f4f1-4283-a2e3-8c348dbb28ce",
260
+ "arrival_latitude": -1.10056144,
261
+ "arrival_longitude": 37.00923637,
262
+ "arrived_at": "2025-11-30T10:56:11.929093Z"
263
+ }
264
+ }
265
+ ```
266
+
267
+ Frontend can display:
268
+ - Work location on map
269
+ - Verification status badge
270
+ - Distance between expected and actual location
docs/devlogs/db/logs.sql CHANGED
@@ -1 +1,4 @@
1
- INSERT INTO "public"."tickets" ("id", "project_id", "source", "source_id", "ticket_name", "ticket_type", "service_type", "work_description", "status", "priority", "scheduled_date", "scheduled_time_slot", "due_date", "sla_target_date", "sla_violated", "started_at", "completed_at", "is_invoiced", "invoiced_at", "contractor_invoice_id", "project_region_id", "work_location_latitude", "work_location_longitude", "work_location_accuracy", "work_location_verified", "dedup_key", "notes", "additional_metadata", "version", "created_at", "updated_at", "deleted_at", "required_team_size", "completion_data", "completion_photos_verified", "completion_data_verified") VALUES ('8f08ad14-df8b-4780-84e7-0d45e133f2a6', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '5cb76cc5-3d9a-4ce0-87a8-43f912896c1f', 'Catherine Njoki', 'installation', 'ftth', 'Install Basic 10Mbps for Catherine Njoki', 'in_progress', 'normal', '2025-12-28', 'Afternoon 1PM-4PM', '2025-12-01 07:52:17.604326+00', '2025-12-01 07:52:17.604326+00', 'false', null, null, 'false', null, null, null, null, null, null, 'false', 'b2888f980ff72ea1192b21d6905e0825', null, '{}', '1', '2025-11-28 07:52:17.604326+00', '2025-11-28 10:04:37.59353+00', null, '1', '{}', 'false', 'false'), ('f59b29fc-d0b9-4618-b0d1-889e340da612', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'c93b28c0-d7bf-4f57-b750-d0c6864543b0', 'Elizabeth Muthoni', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Elizabeth Muthoni', 'completed', 'normal', '2025-12-11', null, '2025-11-29 13:24:19.212882+00', '2025-11-29 13:24:19.212882+00', 'true', null, '2025-11-30 12:08:38.793534+00', 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', null, '[COMPLETION] we finished connection', '{}', '1', '2025-11-26 13:24:19.212882+00', '2025-11-30 12:08:38.858902+00', null, '1', '{"odu_serial": "jjhh", "ont_serial": "jhh"}', 'true', 'true');
 
 
 
 
1
+ INSERT INTO "public"."ticket_assignments" ("id", "ticket_id", "user_id", "action", "assigned_by_user_id", "is_self_assigned", "execution_order", "planned_start_time", "assigned_at", "responded_at", "journey_started_at", "arrived_at", "ended_at", "journey_start_latitude", "journey_start_longitude", "arrival_latitude", "arrival_longitude", "arrival_verified", "journey_location_history", "reason", "notes", "created_at", "updated_at", "deleted_at") VALUES ('a82a3824-f4f1-4283-a2e3-8c348dbb28ce', 'f59b29fc-d0b9-4618-b0d1-889e340da612', '43b778b0-2062-4724-abbb-916a4835a9b0', 'accepted', '43b778b0-2062-4724-abbb-916a4835a9b0', 'true', null, null, '2025-11-30 10:21:10.085152+00', '2025-11-30 10:21:10.085155+00', '2025-11-30 10:55:39.250857+00', '2025-11-30 10:56:11.929093+00', null, '-1.10052333333333', '37.0092266666667', '-1.10056144256674', '37.0092363654176', 'false', '[{"lat": -1.100523333333333, "lng": 37.00922666666666, "speed": 0.0, "battery": 100, "network": null, "accuracy": 64.0, "timestamp": "2025-11-30T10:55:40.399050"}, {"lat": -1.1005614425667403, "lng": 37.009236365417586, "speed": 0.0, "battery": 100, "network": null, "accuracy": 65.0, "timestamp": "2025-11-30T10:56:11.682201"}]', null, null, '2025-11-30 10:21:10.111327+00', '2025-11-30 10:56:11.929936+00', null), ('b3a83bd0-d287-4cea-a1c8-8bef145c1296', '8f08ad14-df8b-4780-84e7-0d45e133f2a6', '43b778b0-2062-4724-abbb-916a4835a9b0', 'accepted', '43b778b0-2062-4724-abbb-916a4835a9b0', 'true', null, null, '2025-11-28 09:27:41.952753+00', '2025-11-28 09:27:41.952757+00', '2025-11-28 10:04:37.590417+00', '2025-11-28 10:45:04.045672+00', null, '-1.22005074013012', '36.8772529852395', '-1.22018863567581', '36.8775512279948', 'false', '[{"lat": -1.2201086694376528, "lng": 36.87740484718826, "speed": 0.0, "battery": 100, "network": null, "accuracy": 87.0, "timestamp": "2025-11-28T10:34:41.638069"}, {"lat": -1.2201371818782953, "lng": 36.87746013754867, "speed": 0.0, "battery": 100, "network": null, "accuracy": 68.0, "timestamp": "2025-11-28T10:36:56.645310"}, {"lat": -1.220034831373305, "lng": 36.877274425758436, "speed": 0.0, "battery": 100, "network": null, "accuracy": 81.0, "timestamp": "2025-11-28T10:38:04.419568"}, {"lat": -1.2200648441710604, "lng": 36.8773285837727, "speed": 0.0, "battery": 100, "network": null, "accuracy": 92.0, "timestamp": "2025-11-28T10:44:42.461237"}, {"lat": -1.220188635675814, "lng": 36.87755122799482, "speed": 0.0, "battery": 100, "network": null, "accuracy": 75.0, "timestamp": "2025-11-28T10:44:57.478127"}]', null, null, '2025-11-28 09:27:41.997778+00', '2025-11-28 10:45:04.049575+00', null);
2
+
3
+
4
+ INSERT INTO "public"."tickets" ("id", "project_id", "source", "source_id", "ticket_name", "ticket_type", "service_type", "work_description", "status", "priority", "scheduled_date", "scheduled_time_slot", "due_date", "sla_target_date", "sla_violated", "started_at", "completed_at", "is_invoiced", "invoiced_at", "contractor_invoice_id", "project_region_id", "work_location_latitude", "work_location_longitude", "work_location_accuracy", "work_location_verified", "dedup_key", "notes", "additional_metadata", "version", "created_at", "updated_at", "deleted_at", "required_team_size", "completion_data", "completion_photos_verified", "completion_data_verified") VALUES ('0fd3ee15-5e7d-465a-b377-155f9bdb7e70', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'f40b7d15-01f3-4dbc-9fd5-3f9114db5439', 'John Kamau', 'installation', 'ftth', 'Install Premium Fiber 50Mbps for John Kamau', 'open', 'normal', '2025-12-01', 'Morning 9AM-12PM', '2025-12-01 07:52:17.445636+00', '2025-12-01 07:52:17.445636+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', '5649f86a704ffeda0a0ca252c6d13047', null, '{}', '1', '2025-11-28 07:52:17.445636+00', '2025-11-28 07:52:17.445638+00', null, '1', '{}', 'false', 'false'), ('113e2c5d-80f3-41fc-98c0-6e16b1a1b049', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'dfd0b969-ca77-4433-9791-9339a79a1807', 'Anne Njeri', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Anne Njeri', 'open', 'normal', null, null, '2025-11-29 08:33:01.362984+00', '2025-11-29 08:33:01.362984+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', '548cc2a6d10f83467be48711c77e0c19', null, '{}', '1', '2025-11-26 08:33:01.362984+00', '2025-11-26 08:33:01.362986+00', null, '1', '{}', 'false', 'false'), ('1e622599-1909-49b9-9d8b-4c5cb483b29e', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'b2a47ac2-e779-4479-846b-f350f041dd43', 'Nnacy Wanjiru', 'installation', 'ftth', 'Install 100 for Nnacy Wanjiru', 'open', 'normal', '2026-01-01', null, '2025-11-28 13:03:13.104571+00', '2025-11-28 13:03:13.104571+00', 'false', null, null, 'false', null, null, '24510a5a-13a6-4334-9055-b4d476aa9e0a', null, null, null, 'false', '1599426e83e347f0b0a00a1ddef5bcdb', null, '{}', '1', '2025-11-25 13:03:13.104571+00', '2025-11-25 13:03:13.104571+00', null, '1', '{}', 'false', 'false'), ('1f807cf8-f139-421b-86e3-38c2f8bc7070', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '7ad1e823-9c68-448c-8d7c-7fdefbf640cb', 'Peter Otieno', 'installation', 'ftth', 'Install Standard 20Mbps for Peter Otieno', 'open', 'normal', '2025-12-10', '10:00-14:00', '2025-11-30 11:41:47.990551+00', '2025-11-30 11:41:47.990551+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', 'a5f81226688e1cc667e106d4a52b4838', 'Needs router configuration assistance', '{}', '1', '2025-11-27 11:41:47.990551+00', '2025-11-27 11:41:47.990555+00', null, '1', '{}', 'false', 'false'), ('2de41ce7-dff1-4151-9710-87958d18b5c4', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'a1d04741-b1b5-4a9e-baf6-d1e186886122', 'Elizabeth Muthoni', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Elizabeth Muthoni', 'open', 'normal', '2025-12-11', 'Anytime', '2025-11-29 09:33:10.216625+00', '2025-11-29 09:33:10.216625+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', '8f02507e5c7dba3ec1fb6ffc646b544b', null, '{}', '1', '2025-11-26 09:33:10.216625+00', '2025-11-26 09:33:10.216627+00', null, '1', '{}', 'false', 'false'), ('38feda19-6cb4-4149-a406-04071c3b4620', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'cebea221-402f-49a3-8418-bbcf39697488', 'Jane Wanjiru', 'installation', 'ftth', 'Install Premium Fiber 50Mbps for Jane Wanjiru', 'open', 'normal', null, null, '2025-11-28 13:05:40.877164+00', '2025-11-28 13:05:40.877164+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', '38680e39c7188eec18b8f00ebc801014', null, '{}', '1', '2025-11-25 13:05:40.877164+00', '2025-11-25 13:05:40.877168+00', null, '1', '{}', 'false', 'false'), ('70090c47-e9c1-4b0a-add4-69bec53d92f9', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '4bbe8e66-b1c2-476c-af4a-ed19d3281e3a', 'Robert Kimani', 'installation', 'ftth', 'Install Premium Fiber 50Mbps for Robert Kimani', 'open', 'normal', '2025-12-22', 'Morning', '2025-11-29 09:33:10.330653+00', '2025-11-29 09:33:10.330653+00', 'false', null, null, 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', 'd238863c2b2221716880cd8b0a2ad9e1', null, '{}', '1', '2025-11-26 09:33:10.330653+00', '2025-11-26 09:33:10.330656+00', null, '1', '{}', 'false', 'false'), ('74ecc2e2-0526-43ec-a454-8aa8668139a1', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '29417f9e-918f-4efb-8a02-0702c5ca14ff', 'Sarah Wangari', 'installation', 'ftth', 'Install Standard 20Mbps for Sarah Wangari', 'open', 'normal', '2025-12-19', '15:00', '2025-11-29 18:08:27.148533+00', '2025-11-29 18:08:27.148533+00', 'false', null, null, 'false', null, null, '24510a5a-13a6-4334-9055-b4d476aa9e0a', null, null, null, 'false', '48c3909dfe101eed5bcdc901e18c7bc2', null, '{}', '1', '2025-11-26 18:08:27.148533+00', '2025-11-26 18:08:27.148538+00', null, '1', '{}', 'false', 'false'), ('8f08ad14-df8b-4780-84e7-0d45e133f2a6', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '5cb76cc5-3d9a-4ce0-87a8-43f912896c1f', 'Catherine Njoki', 'installation', 'ftth', 'Install Basic 10Mbps for Catherine Njoki', 'completed', 'normal', '2025-12-28', 'Afternoon 1PM-4PM', '2025-12-01 07:52:17.604326+00', '2025-12-01 07:52:17.604326+00', 'false', null, '2025-11-30 12:45:46.324208+00', 'false', null, null, null, null, null, null, 'false', null, '[COMPLETION] work done', '{}', '1', '2025-11-28 07:52:17.604326+00', '2025-11-30 12:45:46.432695+00', null, '1', '{"odu_serial": "qewqrqw", "ont_serial": "qeqwe"}', 'true', 'true'), ('b2c279c7-861a-414f-839d-f4cedddf5aef', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '99dab688-6f3c-4f41-82b7-5b5508e60453', 'Catherine Njoki', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Catherine Njoki', 'open', 'high', '2025-12-29', null, '2025-11-28 08:44:48.613265+00', '2025-11-28 08:44:48.613265+00', 'false', null, null, 'false', null, null, null, null, null, null, 'false', 'c277aeb9ff3589a66f1db5063323ca32', 'Duplicate customer - second location', '{}', '1', '2025-11-26 08:44:48.613265+00', '2025-11-26 08:44:48.613267+00', null, '1', '{}', 'false', 'false'), ('f59b29fc-d0b9-4618-b0d1-889e340da612', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'c93b28c0-d7bf-4f57-b750-d0c6864543b0', 'Elizabeth Muthoni', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Elizabeth Muthoni', 'completed', 'normal', '2025-12-11', null, '2025-11-29 13:24:19.212882+00', '2025-11-29 13:24:19.212882+00', 'true', null, '2025-11-30 12:08:38.793534+00', 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', null, '[COMPLETION] we finished connection', '{}', '1', '2025-11-26 13:24:19.212882+00', '2025-11-30 12:08:38.858902+00', null, '1', '{"odu_serial": "jjhh", "ont_serial": "jhh"}', 'true', 'true');
src/app/services/ticket_assignment_service.py CHANGED
@@ -808,6 +808,7 @@ class TicketAssignmentService:
808
  Ticket status → COMPLETED.
809
  """
810
  from app.models.enums import AssignmentAction
 
811
 
812
  assignment = self._get_assignment_or_404(assignment_id)
813
  self._validate_assignment_ownership(assignment, user_id)
@@ -848,6 +849,17 @@ class TicketAssignmentService:
848
  if not ticket.started_at:
849
  ticket.started_at = assignment.journey_started_at or datetime.utcnow()
850
 
 
 
 
 
 
 
 
 
 
 
 
851
  # Create status history
852
  location_lat = None
853
  location_lng = None
 
808
  Ticket status → COMPLETED.
809
  """
810
  from app.models.enums import AssignmentAction
811
+ from app.services.ticket_location_service import TicketLocationService
812
 
813
  assignment = self._get_assignment_or_404(assignment_id)
814
  self._validate_assignment_ownership(assignment, user_id)
 
849
  if not ticket.started_at:
850
  ticket.started_at = assignment.journey_started_at or datetime.utcnow()
851
 
852
+ # Update or verify work location from arrival coordinates
853
+ location_result = TicketLocationService.update_work_location_on_completion(
854
+ ticket=ticket,
855
+ assignment=assignment
856
+ )
857
+ logger.info(
858
+ f"Work location update for ticket {ticket.id}: "
859
+ f"action={location_result['action']}, success={location_result['success']}, "
860
+ f"message={location_result['message']}"
861
+ )
862
+
863
  # Create status history
864
  location_lat = None
865
  location_lng = None
src/app/services/ticket_completion_service.py CHANGED
@@ -383,6 +383,8 @@ class TicketCompletionService:
383
  Returns:
384
  Completion result with subscription info
385
  """
 
 
386
  # Generate checklist for validation
387
  checklist = TicketCompletionService.generate_checklist(ticket, db)
388
 
@@ -419,6 +421,25 @@ class TicketCompletionService:
419
  ticket.status = "completed"
420
  ticket.completed_at = datetime.now(timezone.utc)
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  # Create status history record
423
  TicketCompletionService._create_status_history(
424
  ticket=ticket,
 
383
  Returns:
384
  Completion result with subscription info
385
  """
386
+ from app.services.ticket_location_service import TicketLocationService
387
+
388
  # Generate checklist for validation
389
  checklist = TicketCompletionService.generate_checklist(ticket, db)
390
 
 
421
  ticket.status = "completed"
422
  ticket.completed_at = datetime.now(timezone.utc)
423
 
424
+ # Update or verify work location from active assignment arrival coordinates
425
+ location_result = None
426
+ active_assignment = db.query(TicketAssignment).filter(
427
+ TicketAssignment.ticket_id == ticket.id,
428
+ TicketAssignment.ended_at.is_(None),
429
+ TicketAssignment.deleted_at.is_(None)
430
+ ).first()
431
+
432
+ if active_assignment:
433
+ location_result = TicketLocationService.update_work_location_on_completion(
434
+ ticket=ticket,
435
+ assignment=active_assignment
436
+ )
437
+ logger.info(
438
+ f"Work location update for ticket {ticket.id}: "
439
+ f"action={location_result['action']}, success={location_result['success']}, "
440
+ f"message={location_result['message']}"
441
+ )
442
+
443
  # Create status history record
444
  TicketCompletionService._create_status_history(
445
  ticket=ticket,
src/app/services/ticket_location_service.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ticket Location Service
3
+
4
+ Handles work location derivation and verification for tickets.
5
+
6
+ Business Logic:
7
+ 1. Derive work location from ticket assignment arrival coordinates when missing
8
+ 2. Verify existing work locations against arrival coordinates
9
+ 3. Use Haversine formula for distance calculation
10
+ """
11
+
12
+ from typing import Optional, Tuple
13
+ from decimal import Decimal
14
+ from math import radians, cos, sin, asin, sqrt
15
+ import logging
16
+
17
+ from app.models.ticket import Ticket
18
+ from app.models.ticket_assignment import TicketAssignment
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TicketLocationService:
24
+ """Service for managing ticket work locations"""
25
+
26
+ # Distance threshold for location verification (in meters)
27
+ VERIFICATION_THRESHOLD_METERS = 100 # 100m = same building/compound
28
+
29
+ @staticmethod
30
+ def haversine_distance(
31
+ lat1: float,
32
+ lon1: float,
33
+ lat2: float,
34
+ lon2: float
35
+ ) -> float:
36
+ """
37
+ Calculate distance between two GPS coordinates using Haversine formula.
38
+
39
+ Args:
40
+ lat1, lon1: First coordinate
41
+ lat2, lon2: Second coordinate
42
+
43
+ Returns:
44
+ Distance in meters
45
+ """
46
+ # Convert decimal degrees to radians
47
+ lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
48
+
49
+ # Haversine formula
50
+ dlat = lat2 - lat1
51
+ dlon = lon2 - lon1
52
+ a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
53
+ c = 2 * asin(sqrt(a))
54
+
55
+ # Radius of earth in meters
56
+ r = 6371000
57
+
58
+ return c * r
59
+
60
+ @staticmethod
61
+ def derive_work_location_from_assignment(
62
+ ticket: Ticket,
63
+ assignment: TicketAssignment
64
+ ) -> Tuple[bool, Optional[str]]:
65
+ """
66
+ Derive work location from assignment arrival coordinates.
67
+
68
+ Updates ticket work location if:
69
+ - Ticket has no work location
70
+ - Assignment has arrival coordinates
71
+
72
+ Args:
73
+ ticket: Ticket to update
74
+ assignment: Assignment with arrival data
75
+
76
+ Returns:
77
+ Tuple of (updated: bool, message: str)
78
+ """
79
+ # Check if assignment has arrival coordinates
80
+ if not assignment.arrival_latitude or not assignment.arrival_longitude:
81
+ return False, "Assignment has no arrival coordinates"
82
+
83
+ # Check if ticket already has work location
84
+ if ticket.work_location_latitude and ticket.work_location_longitude:
85
+ return False, "Ticket already has work location"
86
+
87
+ # Set work location from arrival coordinates
88
+ ticket.work_location_latitude = assignment.arrival_latitude
89
+ ticket.work_location_longitude = assignment.arrival_longitude
90
+ ticket.work_location_accuracy = None # Arrival doesn't track accuracy separately
91
+ ticket.work_location_verified = True # Verified because it's from actual GPS arrival
92
+
93
+ logger.info(
94
+ f"Derived work location for ticket {ticket.id} from assignment {assignment.id}: "
95
+ f"({assignment.arrival_latitude}, {assignment.arrival_longitude})"
96
+ )
97
+
98
+ return True, "Work location derived from arrival coordinates"
99
+
100
+ @staticmethod
101
+ def verify_work_location_against_arrival(
102
+ ticket: Ticket,
103
+ assignment: TicketAssignment
104
+ ) -> Tuple[bool, Optional[str], Optional[float]]:
105
+ """
106
+ Verify existing work location against assignment arrival coordinates.
107
+
108
+ Compares ticket work location with assignment arrival location.
109
+ If within threshold (100m), marks as verified.
110
+
111
+ Args:
112
+ ticket: Ticket with existing work location
113
+ assignment: Assignment with arrival data
114
+
115
+ Returns:
116
+ Tuple of (verified: bool, message: str, distance_meters: float)
117
+ """
118
+ # Check if ticket has work location
119
+ if not ticket.work_location_latitude or not ticket.work_location_longitude:
120
+ return False, "Ticket has no work location to verify", None
121
+
122
+ # Check if assignment has arrival coordinates
123
+ if not assignment.arrival_latitude or not assignment.arrival_longitude:
124
+ return False, "Assignment has no arrival coordinates", None
125
+
126
+ # Calculate distance
127
+ distance = TicketLocationService.haversine_distance(
128
+ float(ticket.work_location_latitude),
129
+ float(ticket.work_location_longitude),
130
+ float(assignment.arrival_latitude),
131
+ float(assignment.arrival_longitude)
132
+ )
133
+
134
+ # Check if within threshold
135
+ if distance <= TicketLocationService.VERIFICATION_THRESHOLD_METERS:
136
+ ticket.work_location_verified = True
137
+ logger.info(
138
+ f"Verified work location for ticket {ticket.id}: "
139
+ f"distance={distance:.2f}m (threshold={TicketLocationService.VERIFICATION_THRESHOLD_METERS}m)"
140
+ )
141
+ return True, f"Work location verified (distance: {distance:.2f}m)", distance
142
+ else:
143
+ logger.warning(
144
+ f"Work location verification failed for ticket {ticket.id}: "
145
+ f"distance={distance:.2f}m exceeds threshold={TicketLocationService.VERIFICATION_THRESHOLD_METERS}m"
146
+ )
147
+ return False, f"Work location too far from arrival (distance: {distance:.2f}m)", distance
148
+
149
+ @staticmethod
150
+ def update_work_location_on_completion(
151
+ ticket: Ticket,
152
+ assignment: TicketAssignment
153
+ ) -> dict:
154
+ """
155
+ Update or verify work location when ticket is completed.
156
+
157
+ Logic:
158
+ 1. If ticket has no work location → derive from arrival
159
+ 2. If ticket has work location → verify against arrival
160
+
161
+ Args:
162
+ ticket: Ticket being completed
163
+ assignment: Assignment completing the ticket
164
+
165
+ Returns:
166
+ Dict with update results
167
+ """
168
+ result = {
169
+ "action": None,
170
+ "success": False,
171
+ "message": None,
172
+ "distance_meters": None
173
+ }
174
+
175
+ # Case 1: No work location - derive from arrival
176
+ if not ticket.work_location_latitude or not ticket.work_location_longitude:
177
+ updated, message = TicketLocationService.derive_work_location_from_assignment(
178
+ ticket, assignment
179
+ )
180
+ result["action"] = "derived"
181
+ result["success"] = updated
182
+ result["message"] = message
183
+
184
+ # Case 2: Has work location - verify against arrival
185
+ else:
186
+ verified, message, distance = TicketLocationService.verify_work_location_against_arrival(
187
+ ticket, assignment
188
+ )
189
+ result["action"] = "verified"
190
+ result["success"] = verified
191
+ result["message"] = message
192
+ result["distance_meters"] = distance
193
+
194
+ return result
tests/unit/test_ticket_location_service.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for TicketLocationService
3
+
4
+ Tests work location derivation and verification logic.
5
+ """
6
+
7
+ import pytest
8
+ from decimal import Decimal
9
+ from unittest.mock import Mock
10
+
11
+ from app.services.ticket_location_service import TicketLocationService
12
+
13
+
14
+ class TestHaversineDistance:
15
+ """Test distance calculation"""
16
+
17
+ def test_same_location(self):
18
+ """Distance between same coordinates should be 0"""
19
+ distance = TicketLocationService.haversine_distance(
20
+ -1.1005, 37.0092,
21
+ -1.1005, 37.0092
22
+ )
23
+ assert distance < 1 # Less than 1 meter
24
+
25
+ def test_known_distance(self):
26
+ """Test with known coordinates (approximately 1km apart)"""
27
+ # Nairobi CBD coordinates roughly 1km apart
28
+ distance = TicketLocationService.haversine_distance(
29
+ -1.2864, 36.8172, # Point A
30
+ -1.2921, 36.8219 # Point B
31
+ )
32
+ # Should be around 700-800 meters
33
+ assert 600 < distance < 900
34
+
35
+ def test_close_proximity(self):
36
+ """Test coordinates within 100m (same building)"""
37
+ distance = TicketLocationService.haversine_distance(
38
+ -1.10052333, 37.00922667, # Journey start
39
+ -1.10056144, 37.00923637 # Arrival
40
+ )
41
+ # Should be less than 100m
42
+ assert distance < 100
43
+
44
+
45
+ class TestDeriveWorkLocation:
46
+ """Test work location derivation from assignment"""
47
+
48
+ def test_derive_from_arrival_success(self):
49
+ """Should derive work location when ticket has none"""
50
+ # Mock ticket with no work location
51
+ ticket = Mock()
52
+ ticket.work_location_latitude = None
53
+ ticket.work_location_longitude = None
54
+
55
+ # Mock assignment with arrival coordinates
56
+ assignment = Mock()
57
+ assignment.id = "test-assignment-id"
58
+ assignment.arrival_latitude = Decimal("-1.10056144")
59
+ assignment.arrival_longitude = Decimal("37.00923637")
60
+
61
+ # Derive location
62
+ updated, message = TicketLocationService.derive_work_location_from_assignment(
63
+ ticket, assignment
64
+ )
65
+
66
+ assert updated is True
67
+ assert "derived" in message.lower()
68
+ assert ticket.work_location_latitude == Decimal("-1.10056144")
69
+ assert ticket.work_location_longitude == Decimal("37.00923637")
70
+ assert ticket.work_location_verified is True
71
+
72
+ def test_derive_when_location_exists(self):
73
+ """Should not derive when ticket already has location"""
74
+ # Mock ticket with existing work location
75
+ ticket = Mock()
76
+ ticket.work_location_latitude = Decimal("-1.2201")
77
+ ticket.work_location_longitude = Decimal("36.8775")
78
+
79
+ # Mock assignment with arrival coordinates
80
+ assignment = Mock()
81
+ assignment.arrival_latitude = Decimal("-1.10056144")
82
+ assignment.arrival_longitude = Decimal("37.00923637")
83
+
84
+ # Try to derive location
85
+ updated, message = TicketLocationService.derive_work_location_from_assignment(
86
+ ticket, assignment
87
+ )
88
+
89
+ assert updated is False
90
+ assert "already has" in message.lower()
91
+
92
+ def test_derive_without_arrival_coordinates(self):
93
+ """Should not derive when assignment has no arrival coordinates"""
94
+ # Mock ticket with no work location
95
+ ticket = Mock()
96
+ ticket.work_location_latitude = None
97
+ ticket.work_location_longitude = None
98
+
99
+ # Mock assignment without arrival coordinates
100
+ assignment = Mock()
101
+ assignment.arrival_latitude = None
102
+ assignment.arrival_longitude = None
103
+
104
+ # Try to derive location
105
+ updated, message = TicketLocationService.derive_work_location_from_assignment(
106
+ ticket, assignment
107
+ )
108
+
109
+ assert updated is False
110
+ assert "no arrival" in message.lower()
111
+
112
+
113
+ class TestVerifyWorkLocation:
114
+ """Test work location verification against arrival"""
115
+
116
+ def test_verify_within_threshold(self):
117
+ """Should verify when coordinates are within 100m"""
118
+ # Mock ticket with work location
119
+ ticket = Mock()
120
+ ticket.id = "test-ticket-id"
121
+ ticket.work_location_latitude = Decimal("-1.10052333")
122
+ ticket.work_location_longitude = Decimal("37.00922667")
123
+
124
+ # Mock assignment with nearby arrival (within 100m)
125
+ assignment = Mock()
126
+ assignment.arrival_latitude = Decimal("-1.10056144")
127
+ assignment.arrival_longitude = Decimal("37.00923637")
128
+
129
+ # Verify location
130
+ verified, message, distance = TicketLocationService.verify_work_location_against_arrival(
131
+ ticket, assignment
132
+ )
133
+
134
+ assert verified is True
135
+ assert "verified" in message.lower()
136
+ assert distance < 100
137
+ assert ticket.work_location_verified is True
138
+
139
+ def test_verify_exceeds_threshold(self):
140
+ """Should not verify when coordinates are beyond 100m"""
141
+ # Mock ticket with work location
142
+ ticket = Mock()
143
+ ticket.id = "test-ticket-id"
144
+ ticket.work_location_latitude = Decimal("-1.2201")
145
+ ticket.work_location_longitude = Decimal("36.8775")
146
+
147
+ # Mock assignment with far arrival (> 100m)
148
+ assignment = Mock()
149
+ assignment.arrival_latitude = Decimal("-1.10056144")
150
+ assignment.arrival_longitude = Decimal("37.00923637")
151
+
152
+ # Verify location
153
+ verified, message, distance = TicketLocationService.verify_work_location_against_arrival(
154
+ ticket, assignment
155
+ )
156
+
157
+ assert verified is False
158
+ assert "too far" in message.lower()
159
+ assert distance > 100
160
+
161
+ def test_verify_without_work_location(self):
162
+ """Should not verify when ticket has no work location"""
163
+ # Mock ticket without work location
164
+ ticket = Mock()
165
+ ticket.work_location_latitude = None
166
+ ticket.work_location_longitude = None
167
+
168
+ # Mock assignment with arrival
169
+ assignment = Mock()
170
+ assignment.arrival_latitude = Decimal("-1.10056144")
171
+ assignment.arrival_longitude = Decimal("37.00923637")
172
+
173
+ # Try to verify
174
+ verified, message, distance = TicketLocationService.verify_work_location_against_arrival(
175
+ ticket, assignment
176
+ )
177
+
178
+ assert verified is False
179
+ assert "no work location" in message.lower()
180
+ assert distance is None
181
+
182
+
183
+ class TestUpdateWorkLocationOnCompletion:
184
+ """Test complete workflow on ticket completion"""
185
+
186
+ def test_derive_when_missing(self):
187
+ """Should derive location when ticket has none"""
188
+ # Mock ticket without work location
189
+ ticket = Mock()
190
+ ticket.work_location_latitude = None
191
+ ticket.work_location_longitude = None
192
+
193
+ # Mock assignment with arrival
194
+ assignment = Mock()
195
+ assignment.id = "test-assignment-id"
196
+ assignment.arrival_latitude = Decimal("-1.10056144")
197
+ assignment.arrival_longitude = Decimal("37.00923637")
198
+
199
+ # Update on completion
200
+ result = TicketLocationService.update_work_location_on_completion(
201
+ ticket, assignment
202
+ )
203
+
204
+ assert result["action"] == "derived"
205
+ assert result["success"] is True
206
+ assert ticket.work_location_latitude is not None
207
+
208
+ def test_verify_when_exists(self):
209
+ """Should verify location when ticket has one"""
210
+ # Mock ticket with work location
211
+ ticket = Mock()
212
+ ticket.id = "test-ticket-id"
213
+ ticket.work_location_latitude = Decimal("-1.10052333")
214
+ ticket.work_location_longitude = Decimal("37.00922667")
215
+
216
+ # Mock assignment with nearby arrival
217
+ assignment = Mock()
218
+ assignment.arrival_latitude = Decimal("-1.10056144")
219
+ assignment.arrival_longitude = Decimal("37.00923637")
220
+
221
+ # Update on completion
222
+ result = TicketLocationService.update_work_location_on_completion(
223
+ ticket, assignment
224
+ )
225
+
226
+ assert result["action"] == "verified"
227
+ assert result["success"] is True
228
+ assert result["distance_meters"] < 100