Spaces:
Sleeping
Sleeping
feat: auto-populate and verify ticket work locations from assignment arrival coordinates
Browse files- docs/api/tickets/WORK-LOCATION-DERIVATION.md +181 -0
- docs/api/tickets/WORK-LOCATION-EXAMPLES.md +270 -0
- docs/devlogs/db/logs.sql +4 -1
- src/app/services/ticket_assignment_service.py +12 -0
- src/app/services/ticket_completion_service.py +21 -0
- src/app/services/ticket_location_service.py +194 -0
- tests/unit/test_ticket_location_service.py +228 -0
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"."
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|