Spaces:
Sleeping
Sleeping
feat: location trail for visualization
Browse files- docs/api/projects/field-agent-dashboard.md +143 -0
- docs/api/ticket-assignments/location-trail.md +118 -0
- docs/devlogs/browser/response.json +61 -177
- src/app/api/v1/ticket_assignments.py +50 -0
- src/app/schemas/ticket_assignment.py +28 -0
- src/app/services/dashboard_service.py +94 -0
- src/app/services/ticket_assignment_service.py +82 -0
docs/api/projects/field-agent-dashboard.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Agent Project Dashboard
|
| 2 |
+
|
| 3 |
+
## Endpoint
|
| 4 |
+
|
| 5 |
+
```http
|
| 6 |
+
GET /api/v1/projects/{project_id}/dashboard
|
| 7 |
+
```
|
| 8 |
+
|
| 9 |
+
## Response Structure
|
| 10 |
+
|
| 11 |
+
```json
|
| 12 |
+
{
|
| 13 |
+
"project_info": {
|
| 14 |
+
"id": "uuid",
|
| 15 |
+
"title": "Atomio Fttx",
|
| 16 |
+
"project_type": "customer_service",
|
| 17 |
+
"status": "active"
|
| 18 |
+
},
|
| 19 |
+
"my_stats": {
|
| 20 |
+
"tickets": {
|
| 21 |
+
"total_assigned": 3,
|
| 22 |
+
"pending": 0,
|
| 23 |
+
"in_progress": 2,
|
| 24 |
+
"completed_this_week": 1
|
| 25 |
+
},
|
| 26 |
+
"expenses": {
|
| 27 |
+
"total_amount": 5000.0,
|
| 28 |
+
"pending_amount": 5000.0,
|
| 29 |
+
"pending_count": 1
|
| 30 |
+
},
|
| 31 |
+
"inventory": {
|
| 32 |
+
"items_on_hand": 0
|
| 33 |
+
},
|
| 34 |
+
"notifications": {
|
| 35 |
+
"unread": 0,
|
| 36 |
+
"total": 0
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"work_queue": {
|
| 40 |
+
"pending_assignments": [...],
|
| 41 |
+
"total_pending": 2,
|
| 42 |
+
"high_priority": 0,
|
| 43 |
+
"due_today": 1
|
| 44 |
+
},
|
| 45 |
+
"assignment_history": {
|
| 46 |
+
"assignments": [
|
| 47 |
+
{
|
| 48 |
+
"assignment_id": "uuid",
|
| 49 |
+
"ticket_id": "uuid",
|
| 50 |
+
"ticket_name": "John Kamau",
|
| 51 |
+
"ticket_type": "installation",
|
| 52 |
+
"service_type": "ftth",
|
| 53 |
+
"action": "completed",
|
| 54 |
+
"assigned_at": "2025-11-28T09:00:00Z",
|
| 55 |
+
"ended_at": "2025-11-28T12:30:00Z",
|
| 56 |
+
"travel_time_minutes": 15,
|
| 57 |
+
"work_time_minutes": 45,
|
| 58 |
+
"total_time_minutes": 60,
|
| 59 |
+
"journey_distance_km": 2.3,
|
| 60 |
+
"has_location_trail": true,
|
| 61 |
+
"reason": null
|
| 62 |
+
}
|
| 63 |
+
],
|
| 64 |
+
"total": 5,
|
| 65 |
+
"completed": 4,
|
| 66 |
+
"dropped": 1,
|
| 67 |
+
"rejected": 0,
|
| 68 |
+
"period_days": 7
|
| 69 |
+
},
|
| 70 |
+
"cached_at": "2025-12-01T07:00:00Z",
|
| 71 |
+
"cache_expires_in_seconds": 300
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Assignment History
|
| 76 |
+
|
| 77 |
+
### What It Shows
|
| 78 |
+
|
| 79 |
+
- **Last 7 days** of completed work (configurable)
|
| 80 |
+
- Assignments with `ended_at` set (completed, dropped, rejected)
|
| 81 |
+
- Sorted by most recent first
|
| 82 |
+
|
| 83 |
+
### Assignment Actions
|
| 84 |
+
|
| 85 |
+
- `completed` - Successfully finished
|
| 86 |
+
- `dropped` - Agent couldn't complete (reason provided)
|
| 87 |
+
- `rejected` - Agent rejected assignment
|
| 88 |
+
|
| 89 |
+
### Performance Metrics
|
| 90 |
+
|
| 91 |
+
Each assignment includes:
|
| 92 |
+
- `travel_time_minutes` - Time from acceptance to arrival
|
| 93 |
+
- `work_time_minutes` - Time from arrival to completion
|
| 94 |
+
- `total_time_minutes` - Total assignment duration
|
| 95 |
+
- `journey_distance_km` - Distance traveled (if GPS tracking used)
|
| 96 |
+
- `has_location_trail` - Boolean flag for "View Journey" button
|
| 97 |
+
|
| 98 |
+
### Frontend Usage
|
| 99 |
+
|
| 100 |
+
```typescript
|
| 101 |
+
// Display history section
|
| 102 |
+
const history = dashboard.assignment_history;
|
| 103 |
+
|
| 104 |
+
// Show summary
|
| 105 |
+
showSummary({
|
| 106 |
+
total: history.total,
|
| 107 |
+
completed: history.completed,
|
| 108 |
+
dropped: history.dropped,
|
| 109 |
+
period: `Last ${history.period_days} days`
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
// Render list
|
| 113 |
+
history.assignments.forEach(assignment => {
|
| 114 |
+
renderHistoryCard({
|
| 115 |
+
ticketName: assignment.ticket_name,
|
| 116 |
+
action: assignment.action,
|
| 117 |
+
endedAt: assignment.ended_at,
|
| 118 |
+
duration: `${assignment.total_time_minutes} min`,
|
| 119 |
+
distance: assignment.journey_distance_km ? `${assignment.journey_distance_km} km` : null,
|
| 120 |
+
showJourneyButton: assignment.has_location_trail
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
// "View Journey" button
|
| 125 |
+
if (assignment.has_location_trail) {
|
| 126 |
+
onClickViewJourney(() => {
|
| 127 |
+
navigateToMap(assignment.assignment_id);
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Cache
|
| 133 |
+
|
| 134 |
+
- **TTL**: 5 minutes
|
| 135 |
+
- **Invalidation**: Automatic on data changes
|
| 136 |
+
- **Force refresh**: Add `?force_refresh=true` query param
|
| 137 |
+
|
| 138 |
+
## Notes
|
| 139 |
+
|
| 140 |
+
- History period is 7 days by default
|
| 141 |
+
- Only shows agent's own assignments
|
| 142 |
+
- Includes performance metrics for completed work
|
| 143 |
+
- Location trail available if GPS tracking was used
|
docs/api/ticket-assignments/location-trail.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Location Trail - Frontend Guide
|
| 2 |
+
|
| 3 |
+
## What We Built
|
| 4 |
+
|
| 5 |
+
Agents can now view their journey trail on a map after completing a ticket. The trail shows GPS breadcrumbs collected during travel.
|
| 6 |
+
|
| 7 |
+
## How It Works
|
| 8 |
+
|
| 9 |
+
### 1. Ticket Detail Response
|
| 10 |
+
|
| 11 |
+
Assignment now includes `has_location_trail` flag:
|
| 12 |
+
|
| 13 |
+
```json
|
| 14 |
+
{
|
| 15 |
+
"current_assignment": {
|
| 16 |
+
"id": "assignment-uuid",
|
| 17 |
+
"has_location_trail": true,
|
| 18 |
+
"journey_distance_km": 2.3,
|
| 19 |
+
"travel_time_minutes": 15
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### 2. Show "View Journey" Button
|
| 25 |
+
|
| 26 |
+
```typescript
|
| 27 |
+
if (assignment.has_location_trail) {
|
| 28 |
+
// Show "View Journey" button
|
| 29 |
+
// On click → navigate to map page
|
| 30 |
+
}
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 3. Fetch Location Trail
|
| 34 |
+
|
| 35 |
+
```http
|
| 36 |
+
GET /api/v1/ticket-assignments/assignments/{assignment_id}/location-trail
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Response:**
|
| 40 |
+
```json
|
| 41 |
+
{
|
| 42 |
+
"assignment_id": "uuid",
|
| 43 |
+
"ticket_id": "uuid",
|
| 44 |
+
"user_id": "uuid",
|
| 45 |
+
"user_name": "John Doe",
|
| 46 |
+
"journey_started_at": "2024-03-20T09:00:00Z",
|
| 47 |
+
"arrived_at": "2024-03-20T09:15:00Z",
|
| 48 |
+
"journey_start": {
|
| 49 |
+
"lat": -1.2921,
|
| 50 |
+
"lng": 36.8219
|
| 51 |
+
},
|
| 52 |
+
"arrival_point": {
|
| 53 |
+
"lat": -1.2930,
|
| 54 |
+
"lng": 36.8225
|
| 55 |
+
},
|
| 56 |
+
"trail": [
|
| 57 |
+
{
|
| 58 |
+
"lat": -1.2921,
|
| 59 |
+
"lng": 36.8219,
|
| 60 |
+
"timestamp": "2024-03-20T09:00:00Z",
|
| 61 |
+
"accuracy": 10,
|
| 62 |
+
"speed": 0
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"lat": -1.2925,
|
| 66 |
+
"lng": 36.8222,
|
| 67 |
+
"timestamp": "2024-03-20T09:05:00Z",
|
| 68 |
+
"accuracy": 8,
|
| 69 |
+
"speed": 45
|
| 70 |
+
}
|
| 71 |
+
],
|
| 72 |
+
"total_points": 45,
|
| 73 |
+
"journey_distance_km": 2.3,
|
| 74 |
+
"travel_time_minutes": 15
|
| 75 |
+
}
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Map Rendering
|
| 79 |
+
|
| 80 |
+
```typescript
|
| 81 |
+
// Fetch trail
|
| 82 |
+
const trail = await api.get(`/ticket-assignments/assignments/${assignmentId}/location-trail`);
|
| 83 |
+
|
| 84 |
+
// Render on map
|
| 85 |
+
const map = initMap();
|
| 86 |
+
|
| 87 |
+
// Add start marker (green)
|
| 88 |
+
if (trail.journey_start) {
|
| 89 |
+
addMarker(map, trail.journey_start, 'green', 'Start');
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Add arrival marker (red)
|
| 93 |
+
if (trail.arrival_point) {
|
| 94 |
+
addMarker(map, trail.arrival_point, 'red', 'Arrival');
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Draw route line
|
| 98 |
+
const coordinates = trail.trail.map(point => [point.lat, point.lng]);
|
| 99 |
+
drawPolyline(map, coordinates, 'blue');
|
| 100 |
+
|
| 101 |
+
// Show stats
|
| 102 |
+
showStats({
|
| 103 |
+
distance: `${trail.journey_distance_km} km`,
|
| 104 |
+
time: `${trail.travel_time_minutes} min`,
|
| 105 |
+
points: trail.total_points
|
| 106 |
+
});
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## Authorization
|
| 110 |
+
|
| 111 |
+
- Agents can view their own trails
|
| 112 |
+
- PM/Dispatcher/Admin can view any trail
|
| 113 |
+
|
| 114 |
+
## Notes
|
| 115 |
+
|
| 116 |
+
- Trail only exists if agent used GPS tracking during journey
|
| 117 |
+
- Empty trail = agent didn't update location during travel
|
| 118 |
+
- Trail data is lazy-loaded (not in ticket detail response)
|
docs/devlogs/browser/response.json
CHANGED
|
@@ -1,183 +1,67 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
-
"id": "
|
| 4 |
-
"
|
| 5 |
-
"
|
| 6 |
-
"
|
| 7 |
-
"ticket_name": "John Kamau",
|
| 8 |
-
"ticket_type": "installation",
|
| 9 |
-
"service_type": "ftth",
|
| 10 |
-
"work_description": "Install Premium Fiber 50Mbps for John Kamau",
|
| 11 |
-
"status": "completed",
|
| 12 |
-
"priority": "normal",
|
| 13 |
-
"scheduled_date": "2025-12-01",
|
| 14 |
-
"scheduled_time_slot": "Morning 9AM-12PM",
|
| 15 |
-
"due_date": "2025-12-01T07:52:17.445636Z",
|
| 16 |
-
"sla_target_date": "2025-12-01T07:52:17.445636Z",
|
| 17 |
-
"sla_violated": false,
|
| 18 |
-
"started_at": "2025-12-01T05:52:03.358706Z",
|
| 19 |
-
"completed_at": "2025-12-01T05:54:37.178415Z",
|
| 20 |
-
"is_invoiced": false,
|
| 21 |
-
"invoiced_at": null,
|
| 22 |
-
"project_region_id": "4cd27765-5720-4cc0-872e-bf0da3cd1898",
|
| 23 |
-
"work_location_latitude": "-1.2200285",
|
| 24 |
-
"work_location_longitude": "36.8770248",
|
| 25 |
-
"work_location_verified": true,
|
| 26 |
-
"notes": "[COMPLETION] Hhhdg",
|
| 27 |
-
"version": 1,
|
| 28 |
-
"created_at": "2025-11-28T07:52:17.445636Z",
|
| 29 |
-
"updated_at": "2025-12-01T05:54:37.233167Z",
|
| 30 |
-
"project_title": null,
|
| 31 |
-
"region_name": null,
|
| 32 |
-
"customer_name": null,
|
| 33 |
-
"is_open": false,
|
| 34 |
-
"is_assigned": false,
|
| 35 |
-
"is_in_progress": false,
|
| 36 |
-
"is_completed": true,
|
| 37 |
-
"is_cancelled": false,
|
| 38 |
-
"is_active": false,
|
| 39 |
-
"is_overdue": false,
|
| 40 |
-
"has_region": true,
|
| 41 |
-
"has_schedule": true,
|
| 42 |
-
"can_be_assigned": false,
|
| 43 |
-
"can_be_started": false,
|
| 44 |
-
"can_be_completed": false,
|
| 45 |
-
"can_be_cancelled": false
|
| 46 |
},
|
| 47 |
-
"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
"arrived": true,
|
| 54 |
-
"ended": true,
|
| 55 |
-
"can_drop": false
|
| 56 |
-
},
|
| 57 |
-
"team_info": {
|
| 58 |
-
"required_size": 1,
|
| 59 |
-
"assigned_size": 0,
|
| 60 |
-
"is_full": false,
|
| 61 |
-
"assigned_agents": []
|
| 62 |
-
},
|
| 63 |
-
"message": "Ticket TicketStatus.COMPLETED",
|
| 64 |
-
"source_data": {
|
| 65 |
-
"type": "sales_order",
|
| 66 |
-
"id": "f40b7d15-01f3-4dbc-9fd5-3f9114db5439",
|
| 67 |
-
"order_number": "ORD-2025-001",
|
| 68 |
-
"customer_preferred_package": "Premium Fiber 50Mbps",
|
| 69 |
-
"package_price": 5000.0,
|
| 70 |
-
"installation_address": "123 Kilimani Road, Apt 4B",
|
| 71 |
-
"installation_latitude": -1.286389,
|
| 72 |
-
"installation_longitude": 36.817223,
|
| 73 |
-
"preferred_visit_date": "2025-12-01",
|
| 74 |
-
"status": "processed"
|
| 75 |
-
},
|
| 76 |
-
"customer": {
|
| 77 |
-
"id": "5daadd72-7e1c-454b-8a7a-50e50a28e7ba",
|
| 78 |
-
"name": "John Kamau",
|
| 79 |
-
"phone": "+254712345678",
|
| 80 |
-
"email": "john.kamau@email.com",
|
| 81 |
-
"address": null,
|
| 82 |
-
"location_latitude": null,
|
| 83 |
-
"location_longitude": null
|
| 84 |
-
},
|
| 85 |
-
"expenses": [],
|
| 86 |
-
"images": [
|
| 87 |
-
{
|
| 88 |
-
"id": "286bc35f-0154-46a7-b9c5-411bc7e4cf3e",
|
| 89 |
-
"image_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764568434/ticket_0fd3ee15_ticket_photo_airtel_network_airtel_network_20251201_055353_176456838428265210116379744783.jpg",
|
| 90 |
-
"image_type": "completion",
|
| 91 |
-
"description": "[Airtel network] Completion photo for ticket",
|
| 92 |
-
"captured_at": "2025-12-01T05:53:54.723185+00:00",
|
| 93 |
-
"uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 94 |
-
"created_at": "2025-12-01T05:53:54.723853+00:00"
|
| 95 |
},
|
| 96 |
-
{
|
| 97 |
-
"
|
| 98 |
-
"
|
| 99 |
-
"
|
| 100 |
-
"description": "[Speedtest] Completion photo for ticket",
|
| 101 |
-
"captured_at": "2025-12-01T05:53:53.881645+00:00",
|
| 102 |
-
"uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 103 |
-
"created_at": "2025-12-01T05:53:53.882301+00:00"
|
| 104 |
},
|
| 105 |
-
{
|
| 106 |
-
"
|
| 107 |
-
"image_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764568432/ticket_0fd3ee15_ticket_photo_odu_outdoor_image_odu_outdoor_image_20251201_055352_176456841641438038017979239093.jpg",
|
| 108 |
-
"image_type": "completion",
|
| 109 |
-
"description": "[ODU outdoor image] Completion photo for ticket",
|
| 110 |
-
"captured_at": "2025-12-01T05:53:53.064280+00:00",
|
| 111 |
-
"uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 112 |
-
"created_at": "2025-12-01T05:53:53.096941+00:00"
|
| 113 |
-
}
|
| 114 |
-
],
|
| 115 |
-
"comments": [],
|
| 116 |
-
"assignments": [
|
| 117 |
-
{
|
| 118 |
-
"id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
|
| 119 |
-
"user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 120 |
-
"user_name": "Viyisa Sasa",
|
| 121 |
-
"action": "completed",
|
| 122 |
-
"status": "CLOSED",
|
| 123 |
-
"assigned_at": "2025-12-01T05:50:51.219042+00:00",
|
| 124 |
-
"responded_at": "2025-12-01T05:50:51.219044+00:00",
|
| 125 |
-
"journey_started_at": "2025-12-01T05:52:03.358665+00:00",
|
| 126 |
-
"arrived_at": "2025-12-01T05:52:35.847093+00:00",
|
| 127 |
-
"ended_at": "2025-12-01T05:54:37.183538+00:00"
|
| 128 |
-
}
|
| 129 |
-
],
|
| 130 |
-
"status_history": [
|
| 131 |
-
{
|
| 132 |
-
"id": "e8de766c-14f9-4425-8c67-a77fa671b16f",
|
| 133 |
-
"old_status": "in_progress",
|
| 134 |
-
"new_status": "completed",
|
| 135 |
-
"changed_at": "2025-12-01T05:54:37.189824+00:00",
|
| 136 |
-
"changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 137 |
-
"changed_by_user_name": "Viyisa Sasa",
|
| 138 |
-
"assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
|
| 139 |
-
"change_reason": "Ticket completed with all requirements satisfied",
|
| 140 |
-
"notes": "Hhhdg",
|
| 141 |
-
"location_latitude": null,
|
| 142 |
-
"location_longitude": null,
|
| 143 |
-
"location_accuracy": null,
|
| 144 |
-
"location_verified": false,
|
| 145 |
-
"communication_method": "app",
|
| 146 |
-
"additional_metadata": {}
|
| 147 |
-
},
|
| 148 |
-
{
|
| 149 |
-
"id": "48ef8f9d-731d-4e74-b2ed-136f3bd1d026",
|
| 150 |
-
"old_status": "assigned",
|
| 151 |
-
"new_status": "in_progress",
|
| 152 |
-
"changed_at": "2025-12-01T05:52:03.358718+00:00",
|
| 153 |
-
"changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 154 |
-
"changed_by_user_name": "Viyisa Sasa",
|
| 155 |
-
"assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
|
| 156 |
-
"change_reason": "Agent started journey to site",
|
| 157 |
-
"notes": null,
|
| 158 |
-
"location_latitude": -1.2200188,
|
| 159 |
-
"location_longitude": 36.8770193,
|
| 160 |
-
"location_accuracy": null,
|
| 161 |
-
"location_verified": false,
|
| 162 |
-
"communication_method": "app",
|
| 163 |
-
"additional_metadata": {}
|
| 164 |
},
|
| 165 |
-
{
|
| 166 |
-
"
|
| 167 |
-
"
|
| 168 |
-
"new_status": "assigned",
|
| 169 |
-
"changed_at": "2025-12-01T05:50:51.241567+00:00",
|
| 170 |
-
"changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
|
| 171 |
-
"changed_by_user_name": "Viyisa Sasa",
|
| 172 |
-
"assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
|
| 173 |
-
"change_reason": "Self-assigned by agent",
|
| 174 |
-
"notes": null,
|
| 175 |
-
"location_latitude": null,
|
| 176 |
-
"location_longitude": null,
|
| 177 |
-
"location_accuracy": null,
|
| 178 |
-
"location_verified": false,
|
| 179 |
-
"communication_method": "app",
|
| 180 |
-
"additional_metadata": {}
|
| 181 |
}
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
{
|
| 2 |
+
"project_info": {
|
| 3 |
+
"id": "0ade6bd1-e492-4e25-b681-59f42058d29a",
|
| 4 |
+
"title": "Atomio Fttx",
|
| 5 |
+
"project_type": "customer_service",
|
| 6 |
+
"status": "active"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
},
|
| 8 |
+
"my_stats": {
|
| 9 |
+
"tickets": {
|
| 10 |
+
"total_assigned": 3,
|
| 11 |
+
"pending": 0,
|
| 12 |
+
"in_progress": 2,
|
| 13 |
+
"completed_this_week": 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
+
"expenses": {
|
| 16 |
+
"total_amount": 5000.0,
|
| 17 |
+
"pending_amount": 5000.0,
|
| 18 |
+
"pending_count": 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
},
|
| 20 |
+
"inventory": {
|
| 21 |
+
"items_on_hand": 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
+
"notifications": {
|
| 24 |
+
"unread": 0,
|
| 25 |
+
"total": 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
+
},
|
| 28 |
+
"work_queue": {
|
| 29 |
+
"pending_assignments": [
|
| 30 |
+
{
|
| 31 |
+
"assignment_id": "b3a83bd0-d287-4cea-a1c8-8bef145c1296",
|
| 32 |
+
"ticket_id": "8f08ad14-df8b-4780-84e7-0d45e133f2a6",
|
| 33 |
+
"ticket_name": "Catherine Njoki",
|
| 34 |
+
"ticket_type": "installation",
|
| 35 |
+
"service_type": "ftth",
|
| 36 |
+
"work_description": "Install Basic 10Mbps for Catherine Njoki",
|
| 37 |
+
"status": "ON_SITE",
|
| 38 |
+
"priority": "normal",
|
| 39 |
+
"due_date": "2025-12-01T07:52:17.604326+00:00",
|
| 40 |
+
"scheduled_date": "2025-12-28",
|
| 41 |
+
"scheduled_time_slot": "Afternoon 1PM-4PM",
|
| 42 |
+
"execution_order": null,
|
| 43 |
+
"assigned_at": "2025-11-28T09:27:41.952753+00:00"
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"assignment_id": "a82a3824-f4f1-4283-a2e3-8c348dbb28ce",
|
| 47 |
+
"ticket_id": "f59b29fc-d0b9-4618-b0d1-889e340da612",
|
| 48 |
+
"ticket_name": "Elizabeth Muthoni",
|
| 49 |
+
"ticket_type": "installation",
|
| 50 |
+
"service_type": "ftth",
|
| 51 |
+
"work_description": "Install Premium Fiber 100Mbps for Elizabeth Muthoni",
|
| 52 |
+
"status": "ON_SITE",
|
| 53 |
+
"priority": "normal",
|
| 54 |
+
"due_date": "2025-11-29T13:24:19.212882+00:00",
|
| 55 |
+
"scheduled_date": "2025-12-11",
|
| 56 |
+
"scheduled_time_slot": null,
|
| 57 |
+
"execution_order": null,
|
| 58 |
+
"assigned_at": "2025-11-30T10:21:10.085152+00:00"
|
| 59 |
+
}
|
| 60 |
+
],
|
| 61 |
+
"total_pending": 2,
|
| 62 |
+
"high_priority": 0,
|
| 63 |
+
"due_today": 1
|
| 64 |
+
},
|
| 65 |
+
"cached_at": "2025-12-01T07:00:02.307362Z",
|
| 66 |
+
"cache_expires_in_seconds": 300
|
| 67 |
+
}
|
src/app/api/v1/ticket_assignments.py
CHANGED
|
@@ -654,6 +654,56 @@ def get_assignment(
|
|
| 654 |
return service.get_assignment(assignment_id)
|
| 655 |
|
| 656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
@router.get(
|
| 658 |
"/tickets/{ticket_id}/assignments",
|
| 659 |
summary="Get Ticket Assignments",
|
|
|
|
| 654 |
return service.get_assignment(assignment_id)
|
| 655 |
|
| 656 |
|
| 657 |
+
@router.get(
|
| 658 |
+
"/assignments/{assignment_id}/location-trail",
|
| 659 |
+
summary="Get Location Trail for Map",
|
| 660 |
+
description="""
|
| 661 |
+
Get GPS location trail for map visualization.
|
| 662 |
+
|
| 663 |
+
**Returns:**
|
| 664 |
+
- Journey start point
|
| 665 |
+
- Arrival point
|
| 666 |
+
- GPS breadcrumb trail (array of coordinates with timestamps)
|
| 667 |
+
- Journey stats (distance, travel time)
|
| 668 |
+
|
| 669 |
+
**Authorization:**
|
| 670 |
+
- Agent can view their own trail
|
| 671 |
+
- PM/Dispatcher/Admin can view any trail
|
| 672 |
+
|
| 673 |
+
**Use Case:**
|
| 674 |
+
- Frontend shows "View Journey" button if `has_location_trail: true`
|
| 675 |
+
- Button opens map page
|
| 676 |
+
- Map page calls this endpoint to get coordinates
|
| 677 |
+
- Renders route on map with start/end markers
|
| 678 |
+
|
| 679 |
+
**Example Response:**
|
| 680 |
+
```json
|
| 681 |
+
{
|
| 682 |
+
"assignment_id": "uuid",
|
| 683 |
+
"journey_start": {"lat": -1.2921, "lng": 36.8219},
|
| 684 |
+
"arrival_point": {"lat": -1.2930, "lng": 36.8225},
|
| 685 |
+
"trail": [
|
| 686 |
+
{"lat": -1.2921, "lng": 36.8219, "timestamp": "2024-03-20T09:00:00Z"},
|
| 687 |
+
{"lat": -1.2925, "lng": 36.8222, "timestamp": "2024-03-20T09:05:00Z"}
|
| 688 |
+
],
|
| 689 |
+
"total_points": 45,
|
| 690 |
+
"journey_distance_km": 2.3,
|
| 691 |
+
"travel_time_minutes": 15
|
| 692 |
+
}
|
| 693 |
+
```
|
| 694 |
+
""",
|
| 695 |
+
tags=["Ticket Assignments - Queries"]
|
| 696 |
+
)
|
| 697 |
+
def get_location_trail(
|
| 698 |
+
assignment_id: UUID,
|
| 699 |
+
db: Session = Depends(get_db),
|
| 700 |
+
current_user: User = Depends(get_current_user)
|
| 701 |
+
):
|
| 702 |
+
from app.schemas.ticket_assignment import LocationTrailResponse
|
| 703 |
+
service = TicketAssignmentService(db)
|
| 704 |
+
return service.get_location_trail(assignment_id, current_user)
|
| 705 |
+
|
| 706 |
+
|
| 707 |
@router.get(
|
| 708 |
"/tickets/{ticket_id}/assignments",
|
| 709 |
summary="Get Ticket Assignments",
|
src/app/schemas/ticket_assignment.py
CHANGED
|
@@ -295,6 +295,7 @@ class TicketAssignmentResponse(BaseModel):
|
|
| 295 |
arrival_longitude: Optional[Decimal] = None
|
| 296 |
arrival_verified: bool = False
|
| 297 |
journey_location_history: List[Dict[str, Any]] = []
|
|
|
|
| 298 |
|
| 299 |
# Computed
|
| 300 |
status: str # PENDING, ACCEPTED, IN_TRANSIT, ON_SITE, CLOSED
|
|
@@ -335,6 +336,33 @@ class TicketAssignmentBriefResponse(BaseModel):
|
|
| 335 |
from_attributes = True
|
| 336 |
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
class TicketWithAssignmentsResponse(BaseModel):
|
| 339 |
"""Ticket with assignment info"""
|
| 340 |
id: UUID
|
|
|
|
| 295 |
arrival_longitude: Optional[Decimal] = None
|
| 296 |
arrival_verified: bool = False
|
| 297 |
journey_location_history: List[Dict[str, Any]] = []
|
| 298 |
+
has_location_trail: bool = False # True if journey_location_history has data
|
| 299 |
|
| 300 |
# Computed
|
| 301 |
status: str # PENDING, ACCEPTED, IN_TRANSIT, ON_SITE, CLOSED
|
|
|
|
| 336 |
from_attributes = True
|
| 337 |
|
| 338 |
|
| 339 |
+
class LocationTrailResponse(BaseModel):
|
| 340 |
+
"""GPS location trail for map visualization"""
|
| 341 |
+
assignment_id: UUID
|
| 342 |
+
ticket_id: UUID
|
| 343 |
+
user_id: UUID
|
| 344 |
+
user_name: str
|
| 345 |
+
|
| 346 |
+
# Journey timeline
|
| 347 |
+
journey_started_at: Optional[datetime] = None
|
| 348 |
+
arrived_at: Optional[datetime] = None
|
| 349 |
+
|
| 350 |
+
# Start and end points
|
| 351 |
+
journey_start: Optional[Dict[str, Any]] = None # {"lat": float, "lng": float}
|
| 352 |
+
arrival_point: Optional[Dict[str, Any]] = None # {"lat": float, "lng": float}
|
| 353 |
+
|
| 354 |
+
# GPS breadcrumb trail
|
| 355 |
+
trail: List[LocationBreadcrumb] = []
|
| 356 |
+
|
| 357 |
+
# Stats
|
| 358 |
+
total_points: int = 0
|
| 359 |
+
journey_distance_km: Optional[float] = None
|
| 360 |
+
travel_time_minutes: Optional[int] = None
|
| 361 |
+
|
| 362 |
+
class Config:
|
| 363 |
+
from_attributes = True
|
| 364 |
+
|
| 365 |
+
|
| 366 |
class TicketWithAssignmentsResponse(BaseModel):
|
| 367 |
"""Ticket with assignment info"""
|
| 368 |
id: UUID
|
src/app/services/dashboard_service.py
CHANGED
|
@@ -125,6 +125,7 @@ class DashboardService:
|
|
| 125 |
"notifications": DashboardService._get_notification_stats(db, current_user)
|
| 126 |
},
|
| 127 |
"work_queue": DashboardService._get_project_work_queue(db, project_id, current_user, limit=50),
|
|
|
|
| 128 |
"cached_at": datetime.utcnow().isoformat() + "Z",
|
| 129 |
"cache_expires_in_seconds": 300
|
| 130 |
}
|
|
@@ -856,6 +857,99 @@ class DashboardService:
|
|
| 856 |
"due_today": 0
|
| 857 |
}
|
| 858 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
@staticmethod
|
| 860 |
def _get_inventory_stats(db: Session, project_id: str) -> Dict:
|
| 861 |
"""Get inventory stats for project"""
|
|
|
|
| 125 |
"notifications": DashboardService._get_notification_stats(db, current_user)
|
| 126 |
},
|
| 127 |
"work_queue": DashboardService._get_project_work_queue(db, project_id, current_user, limit=50),
|
| 128 |
+
"assignment_history": DashboardService._get_assignment_history(db, project_id, current_user, days=7),
|
| 129 |
"cached_at": datetime.utcnow().isoformat() + "Z",
|
| 130 |
"cache_expires_in_seconds": 300
|
| 131 |
}
|
|
|
|
| 857 |
"due_today": 0
|
| 858 |
}
|
| 859 |
|
| 860 |
+
@staticmethod
|
| 861 |
+
def _get_assignment_history(db: Session, project_id: str, current_user: User, days: int = 7) -> Dict:
|
| 862 |
+
"""
|
| 863 |
+
Get field agent's completed assignment history for specific project.
|
| 864 |
+
|
| 865 |
+
Returns assignments that have ended (completed, dropped, rejected) within the last N days.
|
| 866 |
+
Default is 7 days (weekly history).
|
| 867 |
+
|
| 868 |
+
Args:
|
| 869 |
+
db: Database session
|
| 870 |
+
project_id: Project ID
|
| 871 |
+
current_user: Current user (field agent)
|
| 872 |
+
days: Number of days to look back (default: 7)
|
| 873 |
+
"""
|
| 874 |
+
try:
|
| 875 |
+
from app.models.ticket_assignment import TicketAssignment
|
| 876 |
+
|
| 877 |
+
# Calculate date threshold
|
| 878 |
+
date_threshold = datetime.utcnow() - timedelta(days=days)
|
| 879 |
+
|
| 880 |
+
# Query completed assignments (ended_at is set)
|
| 881 |
+
assignments = db.query(TicketAssignment).options(
|
| 882 |
+
joinedload(TicketAssignment.ticket)
|
| 883 |
+
).join(
|
| 884 |
+
Ticket
|
| 885 |
+
).filter(
|
| 886 |
+
Ticket.project_id == project_id,
|
| 887 |
+
TicketAssignment.user_id == current_user.id,
|
| 888 |
+
TicketAssignment.ended_at.isnot(None), # Assignment ended
|
| 889 |
+
TicketAssignment.ended_at >= date_threshold, # Within last N days
|
| 890 |
+
TicketAssignment.deleted_at.is_(None)
|
| 891 |
+
).order_by(
|
| 892 |
+
TicketAssignment.ended_at.desc() # Most recent first
|
| 893 |
+
).all()
|
| 894 |
+
|
| 895 |
+
# Build history items
|
| 896 |
+
history_items = []
|
| 897 |
+
completed_count = 0
|
| 898 |
+
dropped_count = 0
|
| 899 |
+
rejected_count = 0
|
| 900 |
+
|
| 901 |
+
for assignment in assignments:
|
| 902 |
+
ticket = assignment.ticket
|
| 903 |
+
|
| 904 |
+
# Count by action
|
| 905 |
+
if assignment.action == "completed":
|
| 906 |
+
completed_count += 1
|
| 907 |
+
elif assignment.action == "dropped":
|
| 908 |
+
dropped_count += 1
|
| 909 |
+
elif assignment.action == "rejected":
|
| 910 |
+
rejected_count += 1
|
| 911 |
+
|
| 912 |
+
item = {
|
| 913 |
+
"assignment_id": str(assignment.id),
|
| 914 |
+
"ticket_id": str(ticket.id) if ticket else None,
|
| 915 |
+
"ticket_name": ticket.ticket_name if ticket else "Unknown Ticket",
|
| 916 |
+
"ticket_type": ticket.ticket_type if ticket else None,
|
| 917 |
+
"service_type": ticket.service_type if ticket else None,
|
| 918 |
+
"action": assignment.action, # completed, dropped, rejected
|
| 919 |
+
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
| 920 |
+
"ended_at": assignment.ended_at.isoformat() if assignment.ended_at else None,
|
| 921 |
+
"travel_time_minutes": assignment.travel_time_minutes,
|
| 922 |
+
"work_time_minutes": assignment.work_time_minutes,
|
| 923 |
+
"total_time_minutes": assignment.total_time_minutes,
|
| 924 |
+
"journey_distance_km": assignment.journey_distance_km,
|
| 925 |
+
"has_location_trail": (
|
| 926 |
+
isinstance(assignment.journey_location_history, list) and
|
| 927 |
+
len(assignment.journey_location_history) > 0
|
| 928 |
+
),
|
| 929 |
+
"reason": assignment.reason # For dropped/rejected
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
history_items.append(item)
|
| 933 |
+
|
| 934 |
+
return {
|
| 935 |
+
"assignments": history_items,
|
| 936 |
+
"total": len(assignments),
|
| 937 |
+
"completed": completed_count,
|
| 938 |
+
"dropped": dropped_count,
|
| 939 |
+
"rejected": rejected_count,
|
| 940 |
+
"period_days": days
|
| 941 |
+
}
|
| 942 |
+
except Exception as e:
|
| 943 |
+
logger.error(f"Error getting assignment history: {e}")
|
| 944 |
+
return {
|
| 945 |
+
"assignments": [],
|
| 946 |
+
"total": 0,
|
| 947 |
+
"completed": 0,
|
| 948 |
+
"dropped": 0,
|
| 949 |
+
"rejected": 0,
|
| 950 |
+
"period_days": days
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
@staticmethod
|
| 954 |
def _get_inventory_stats(db: Session, project_id: str) -> Dict:
|
| 955 |
"""Get inventory stats for project"""
|
src/app/services/ticket_assignment_service.py
CHANGED
|
@@ -976,6 +976,81 @@ class TicketAssignmentService:
|
|
| 976 |
assignment = self._get_assignment_or_404(assignment_id)
|
| 977 |
return self._to_response(assignment)
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
def get_ticket_assignments(self, ticket_id: UUID) -> dict:
|
| 980 |
"""Get all assignments for ticket (current + past)"""
|
| 981 |
assignments = self.db.query(TicketAssignment).options(
|
|
@@ -1242,6 +1317,12 @@ class TicketAssignmentService:
|
|
| 1242 |
phone=assignment.assigned_by.phone
|
| 1243 |
)
|
| 1244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
return TicketAssignmentResponse(
|
| 1246 |
id=assignment.id,
|
| 1247 |
ticket_id=assignment.ticket_id,
|
|
@@ -1262,6 +1343,7 @@ class TicketAssignmentService:
|
|
| 1262 |
arrival_longitude=assignment.arrival_longitude,
|
| 1263 |
arrival_verified=assignment.arrival_verified,
|
| 1264 |
journey_location_history=assignment.journey_location_history,
|
|
|
|
| 1265 |
status=assignment.status,
|
| 1266 |
is_active=assignment.is_active,
|
| 1267 |
travel_time_minutes=assignment.travel_time_minutes,
|
|
|
|
| 976 |
assignment = self._get_assignment_or_404(assignment_id)
|
| 977 |
return self._to_response(assignment)
|
| 978 |
|
| 979 |
+
def get_location_trail(self, assignment_id: UUID, current_user: User):
|
| 980 |
+
"""
|
| 981 |
+
Get GPS location trail for map visualization.
|
| 982 |
+
|
| 983 |
+
Authorization:
|
| 984 |
+
- Agent can view their own trail
|
| 985 |
+
- PM/Dispatcher/Admin can view any trail
|
| 986 |
+
"""
|
| 987 |
+
from app.schemas.ticket_assignment import LocationTrailResponse, LocationBreadcrumb
|
| 988 |
+
|
| 989 |
+
assignment = self.db.query(TicketAssignment).options(
|
| 990 |
+
joinedload(TicketAssignment.user)
|
| 991 |
+
).filter(
|
| 992 |
+
TicketAssignment.id == assignment_id,
|
| 993 |
+
TicketAssignment.deleted_at.is_(None)
|
| 994 |
+
).first()
|
| 995 |
+
|
| 996 |
+
if not assignment:
|
| 997 |
+
raise HTTPException(
|
| 998 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 999 |
+
detail="Assignment not found"
|
| 1000 |
+
)
|
| 1001 |
+
|
| 1002 |
+
# Authorization check
|
| 1003 |
+
is_own_assignment = assignment.user_id == current_user.id
|
| 1004 |
+
is_authorized_role = current_user.role in [
|
| 1005 |
+
AppRole.PLATFORM_ADMIN,
|
| 1006 |
+
AppRole.PROJECT_MANAGER,
|
| 1007 |
+
AppRole.DISPATCHER
|
| 1008 |
+
]
|
| 1009 |
+
|
| 1010 |
+
if not (is_own_assignment or is_authorized_role):
|
| 1011 |
+
raise HTTPException(
|
| 1012 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 1013 |
+
detail="Not authorized to view this location trail"
|
| 1014 |
+
)
|
| 1015 |
+
|
| 1016 |
+
# Build journey start point
|
| 1017 |
+
journey_start = None
|
| 1018 |
+
if assignment.journey_start_latitude and assignment.journey_start_longitude:
|
| 1019 |
+
journey_start = {
|
| 1020 |
+
"lat": float(assignment.journey_start_latitude),
|
| 1021 |
+
"lng": float(assignment.journey_start_longitude)
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
# Build arrival point
|
| 1025 |
+
arrival_point = None
|
| 1026 |
+
if assignment.arrival_latitude and assignment.arrival_longitude:
|
| 1027 |
+
arrival_point = {
|
| 1028 |
+
"lat": float(assignment.arrival_latitude),
|
| 1029 |
+
"lng": float(assignment.arrival_longitude)
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
# Convert trail to LocationBreadcrumb objects
|
| 1033 |
+
trail = []
|
| 1034 |
+
if isinstance(assignment.journey_location_history, list):
|
| 1035 |
+
for point in assignment.journey_location_history:
|
| 1036 |
+
if isinstance(point, dict) and "lat" in point and "lng" in point:
|
| 1037 |
+
trail.append(LocationBreadcrumb(**point))
|
| 1038 |
+
|
| 1039 |
+
return LocationTrailResponse(
|
| 1040 |
+
assignment_id=assignment.id,
|
| 1041 |
+
ticket_id=assignment.ticket_id,
|
| 1042 |
+
user_id=assignment.user_id,
|
| 1043 |
+
user_name=assignment.user.full_name if assignment.user else "Unknown",
|
| 1044 |
+
journey_started_at=assignment.journey_started_at,
|
| 1045 |
+
arrived_at=assignment.arrived_at,
|
| 1046 |
+
journey_start=journey_start,
|
| 1047 |
+
arrival_point=arrival_point,
|
| 1048 |
+
trail=trail,
|
| 1049 |
+
total_points=len(trail),
|
| 1050 |
+
journey_distance_km=assignment.journey_distance_km,
|
| 1051 |
+
travel_time_minutes=assignment.travel_time_minutes
|
| 1052 |
+
)
|
| 1053 |
+
|
| 1054 |
def get_ticket_assignments(self, ticket_id: UUID) -> dict:
|
| 1055 |
"""Get all assignments for ticket (current + past)"""
|
| 1056 |
assignments = self.db.query(TicketAssignment).options(
|
|
|
|
| 1317 |
phone=assignment.assigned_by.phone
|
| 1318 |
)
|
| 1319 |
|
| 1320 |
+
# Check if location trail exists
|
| 1321 |
+
has_location_trail = (
|
| 1322 |
+
isinstance(assignment.journey_location_history, list) and
|
| 1323 |
+
len(assignment.journey_location_history) > 0
|
| 1324 |
+
)
|
| 1325 |
+
|
| 1326 |
return TicketAssignmentResponse(
|
| 1327 |
id=assignment.id,
|
| 1328 |
ticket_id=assignment.ticket_id,
|
|
|
|
| 1343 |
arrival_longitude=assignment.arrival_longitude,
|
| 1344 |
arrival_verified=assignment.arrival_verified,
|
| 1345 |
journey_location_history=assignment.journey_location_history,
|
| 1346 |
+
has_location_trail=has_location_trail,
|
| 1347 |
status=assignment.status,
|
| 1348 |
is_active=assignment.is_active,
|
| 1349 |
travel_time_minutes=assignment.travel_time_minutes,
|