@fleetbase/fleetops-engine 0.6.42 → 0.6.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  2. package/addon/components/map/drawer/geofence-event-listing.js +100 -0
  3. package/addon/components/map/drawer.js +1 -0
  4. package/addon/components/map/leaflet-live-map.js +83 -0
  5. package/addon/components/modals/orchestrator-import.js +2 -78
  6. package/addon/components/service-area/form.hbs +59 -0
  7. package/addon/components/zone/form.hbs +60 -1
  8. package/addon/services/geofence-event-bus.js +223 -0
  9. package/app/components/{modals/set-password.js → map/drawer/geofence-event-listing.js} +1 -1
  10. package/app/services/geofence-event-bus.js +1 -0
  11. package/composer.json +1 -1
  12. package/extension.json +1 -1
  13. package/package.json +2 -2
  14. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  15. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  16. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  17. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  18. package/server/migrations/2026_04_08_000001_add_orchestrator_columns_to_vehicles_table.php +3 -0
  19. package/server/migrations/2026_04_08_000004_add_orchestrator_columns_to_payloads_table.php +3 -0
  20. package/server/migrations/2026_04_14_000001_drop_redundant_capacity_columns_from_payloads_table.php +2 -0
  21. package/server/migrations/2026_04_14_000002_rename_capacity_columns_on_vehicles_table.php +2 -0
  22. package/server/migrations/2026_04_21_081353_add_internal_id_column_to_vehicles_table.php +27 -0
  23. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  24. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  25. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  26. package/server/src/Events/GeofenceDwelled.php +208 -0
  27. package/server/src/Events/GeofenceEntered.php +207 -0
  28. package/server/src/Events/GeofenceExited.php +213 -0
  29. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  30. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  31. package/server/src/Http/Controllers/Api/v1/OrderController.php +8 -3
  32. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  33. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  34. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  35. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  36. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  37. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  38. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  39. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  40. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  41. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  42. package/server/src/Models/GeofenceEventLog.php +155 -0
  43. package/server/src/Models/ServiceArea.php +21 -2
  44. package/server/src/Models/ServiceQuote.php +3 -1
  45. package/server/src/Models/Zone.php +20 -2
  46. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  47. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +2 -2
  48. package/server/src/Providers/EventServiceProvider.php +19 -0
  49. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  50. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  51. package/server/src/routes.php +16 -0
  52. package/addon/components/modals/set-password.hbs +0 -27
@@ -0,0 +1,213 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Events;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Vehicle;
7
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
8
+ use Illuminate\Broadcasting\Channel;
9
+ use Illuminate\Broadcasting\InteractsWithSockets;
10
+ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
11
+ use Illuminate\Foundation\Events\Dispatchable;
12
+ use Illuminate\Queue\SerializesModels;
13
+
14
+ /**
15
+ * GeofenceExited.
16
+ *
17
+ * Fired when a driver's location update causes them to cross out of a
18
+ * geofence boundary (Zone or ServiceArea) that they were previously inside.
19
+ *
20
+ * Listeners:
21
+ * - HandleGeofenceExited (event log)
22
+ * - SendResourceLifecycleWebhook (webhook delivery)
23
+ */
24
+ class GeofenceExited implements ShouldBroadcast
25
+ {
26
+ use Dispatchable;
27
+ use InteractsWithSockets;
28
+ use SerializesModels;
29
+
30
+ public string $modelHumanName = 'Geofence';
31
+ public ?string $modelRecordName = null;
32
+ public string $eventName = 'exited';
33
+ public $userSession;
34
+ public $companySession;
35
+ public $requestMethod;
36
+ public $apiCredential;
37
+ public $apiSecret;
38
+ public $apiKey;
39
+ public $apiEnvironment;
40
+ public $isSandbox;
41
+ public array $data = [];
42
+
43
+ /**
44
+ * The driver who exited the geofence.
45
+ */
46
+ public ?Driver $driver = null;
47
+
48
+ /**
49
+ * The vehicle associated with the geofence event, if any.
50
+ */
51
+ public ?Vehicle $vehicle = null;
52
+
53
+ /**
54
+ * The geofence that was exited (Zone or ServiceArea model instance).
55
+ *
56
+ * @var \Fleetbase\FleetOps\Models\Zone|\Fleetbase\FleetOps\Models\ServiceArea
57
+ */
58
+ public $geofence;
59
+
60
+ /**
61
+ * The type of geofence: 'zone' or 'service_area'.
62
+ */
63
+ public string $geofenceType;
64
+
65
+ /**
66
+ * The driver's location at the time of exit.
67
+ */
68
+ public Point $location;
69
+
70
+ /**
71
+ * The timestamp when the exit was detected.
72
+ */
73
+ public \Carbon\Carbon $timestamp;
74
+
75
+ /**
76
+ * How many minutes the driver was inside the geofence before exiting.
77
+ * Null if the entry time was not recorded.
78
+ */
79
+ public ?int $dwellDurationMinutes;
80
+
81
+ /**
82
+ * The subject that triggered the event: 'driver' or 'vehicle'.
83
+ */
84
+ public string $subjectType;
85
+
86
+ /**
87
+ * Create a new GeofenceExited event.
88
+ *
89
+ * @param mixed $geofence Zone or ServiceArea
90
+ * @param string $geofenceType 'zone' | 'service_area'
91
+ */
92
+ public function __construct(Driver|Vehicle $subject, $geofence, string $geofenceType, Point $location, ?int $dwellDurationMinutes = null)
93
+ {
94
+ if ($subject instanceof Driver) {
95
+ $this->driver = $subject;
96
+ $this->vehicle = $subject->vehicle;
97
+ $this->subjectType = 'driver';
98
+ } else {
99
+ $subject->loadMissing('driver');
100
+ $this->vehicle = $subject;
101
+ $this->driver = $subject->driver;
102
+ $this->subjectType = 'vehicle';
103
+ }
104
+
105
+ $this->geofence = $geofence;
106
+ $this->geofenceType = $geofenceType;
107
+ $this->location = $location;
108
+ $this->timestamp = now();
109
+ $this->dwellDurationMinutes = $dwellDurationMinutes;
110
+ $this->modelRecordName = $geofence->name ?? $geofence->public_id ?? $geofence->uuid ?? null;
111
+ $this->userSession = session('user');
112
+ $this->companySession = session('company', $this->getCompanyUuid());
113
+ $this->requestMethod = request()?->method() ?? 'CLI';
114
+ $this->apiCredential = session('api_credential', 'console');
115
+ $this->apiSecret = session('api_secret', 'internal');
116
+ $this->apiKey = session('api_key');
117
+ $this->apiEnvironment = session('api_environment', 'live');
118
+ $this->isSandbox = session('is_sandbox', false);
119
+ $this->data = $this->getEventData();
120
+ }
121
+
122
+ /**
123
+ * Returns the company UUID for this event.
124
+ */
125
+ public function getCompanyUuid(): string
126
+ {
127
+ return $this->vehicle?->company_uuid ?? $this->driver->company_uuid;
128
+ }
129
+
130
+ public function broadcastOn(): array
131
+ {
132
+ $channels = [new Channel('company.' . $this->getCompanyUuid())];
133
+
134
+ if ($this->driver) {
135
+ $channels[] = new Channel('driver.' . $this->driver->public_id);
136
+ $channels[] = new Channel('driver.' . $this->driver->uuid);
137
+ }
138
+
139
+ if ($this->vehicle) {
140
+ $channels[] = new Channel('vehicle.' . $this->vehicle->public_id);
141
+ $channels[] = new Channel('vehicle.' . $this->vehicle->uuid);
142
+ }
143
+
144
+ return $channels;
145
+ }
146
+
147
+ public function broadcastAs(): string
148
+ {
149
+ return 'geofence.exited';
150
+ }
151
+
152
+ /**
153
+ * Returns the standardised webhook payload for this event.
154
+ */
155
+ public function broadcastWith(): array
156
+ {
157
+ return $this->getEventData();
158
+ }
159
+
160
+ public function getEventData(): array
161
+ {
162
+ $driver = $this->driver;
163
+ $vehicle = $this->vehicle;
164
+ $geofence = $this->geofence;
165
+ $subject = $this->subjectType === 'vehicle' ? $vehicle : $driver;
166
+
167
+ $payload = [
168
+ 'event' => $this->broadcastAs(),
169
+ 'event_type' => 'geofence.exited',
170
+ 'occurred_at' => $this->timestamp->toIso8601String(),
171
+ 'dwell_duration_minutes' => $this->dwellDurationMinutes,
172
+ 'subject' => [
173
+ 'type' => $this->subjectType,
174
+ 'id' => $subject?->public_id ?? null,
175
+ 'uuid' => $subject?->uuid ?? null,
176
+ 'name' => $this->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
177
+ ],
178
+ 'driver' => $driver ? [
179
+ 'id' => $driver->public_id,
180
+ 'uuid' => $driver->uuid,
181
+ 'name' => $driver->name,
182
+ 'phone' => $driver->phone,
183
+ ] : null,
184
+ 'vehicle' => $vehicle ? [
185
+ 'id' => $vehicle->public_id,
186
+ 'uuid' => $vehicle->uuid,
187
+ 'name' => $vehicle->display_name ?? null,
188
+ 'plate' => $vehicle->plate_number ?? null,
189
+ ] : null,
190
+ 'geofence' => [
191
+ 'id' => $geofence->public_id,
192
+ 'uuid' => $geofence->uuid,
193
+ 'name' => $geofence->name,
194
+ 'type' => $this->geofenceType,
195
+ ],
196
+ 'location' => [
197
+ 'latitude' => $this->location->getLat(),
198
+ 'longitude' => $this->location->getLng(),
199
+ ],
200
+ ];
201
+
202
+ $order = $driver?->getCurrentOrder();
203
+ if ($order) {
204
+ $payload['order'] = [
205
+ 'id' => $order->public_id,
206
+ 'uuid' => $order->uuid,
207
+ 'status' => $order->status,
208
+ ];
209
+ }
210
+
211
+ return $payload;
212
+ }
213
+ }
@@ -3,15 +3,20 @@
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Events\DriverLocationChanged;
6
+ use Fleetbase\FleetOps\Events\GeofenceEntered;
7
+ use Fleetbase\FleetOps\Events\GeofenceExited;
6
8
  use Fleetbase\FleetOps\Events\VehicleLocationChanged;
7
9
  use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
8
10
  use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest;
9
11
  use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
10
12
  use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
11
13
  use Fleetbase\FleetOps\Http\Resources\v1\Driver as DriverResource;
14
+ use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell;
12
15
  use Fleetbase\FleetOps\Jobs\SimulateDrivingRoute;
13
16
  use Fleetbase\FleetOps\Models\Driver;
14
17
  use Fleetbase\FleetOps\Models\Order;
18
+ use Fleetbase\FleetOps\Models\Vehicle;
19
+ use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
15
20
  use Fleetbase\FleetOps\Support\OSRM;
16
21
  use Fleetbase\FleetOps\Support\Utils;
17
22
  use Fleetbase\Http\Controllers\Controller;
@@ -374,6 +379,29 @@ class DriverController extends Controller
374
379
 
375
380
  broadcast(new DriverLocationChanged($driver));
376
381
 
382
+ // ----------------------------------------------------------------
383
+ // Geofence intersection detection
384
+ //
385
+ // After broadcasting the location change, run the geofence engine
386
+ // asynchronously. We catch all exceptions so that a geofence error
387
+ // never prevents the location update response from being returned.
388
+ // ----------------------------------------------------------------
389
+ try {
390
+ $newLocation = new Point($latitude, $longitude);
391
+ $geofenceService = app(GeofenceIntersectionService::class);
392
+ $this->processSubjectGeofenceCrossings($driver, $newLocation, 'driver_geofence_states', 'driver_uuid', $geofenceService->detectDriverCrossings($driver, $newLocation));
393
+
394
+ if ($vehicle) {
395
+ $vehicle->loadMissing('driver');
396
+ $this->processSubjectGeofenceCrossings($vehicle, $newLocation, 'vehicle_geofence_states', 'vehicle_uuid', $geofenceService->detectVehicleCrossings($vehicle, $newLocation));
397
+ }
398
+ } catch (\Throwable $geofenceException) {
399
+ // Log the error but never let geofence processing block the response
400
+ if (app()->bound('sentry')) {
401
+ app('sentry')->captureException($geofenceException);
402
+ }
403
+ }
404
+
377
405
  return new DriverResource($driver);
378
406
  }
379
407
 
@@ -917,4 +945,76 @@ class DriverController extends Controller
917
945
 
918
946
  return $phone;
919
947
  }
948
+
949
+ private function processSubjectGeofenceCrossings(Driver|Vehicle $subject, Point $newLocation, string $stateTable, string $subjectColumn, array $crossings): void
950
+ {
951
+ foreach ($crossings as $crossing) {
952
+ $geofence = $crossing['geofence'];
953
+ $geofenceType = $crossing['geofence_type'];
954
+
955
+ if ($crossing['type'] === 'entered') {
956
+ if (!$geofence->trigger_on_entry && empty($geofence->dwell_threshold_minutes)) {
957
+ continue;
958
+ }
959
+
960
+ DB::table($stateTable)->upsert(
961
+ [
962
+ $subjectColumn => $subject->uuid,
963
+ 'geofence_uuid' => $geofence->uuid,
964
+ 'geofence_type' => $geofenceType,
965
+ 'is_inside' => true,
966
+ 'entered_at' => now(),
967
+ 'exited_at' => null,
968
+ 'dwell_job_id' => null,
969
+ 'created_at' => now(),
970
+ 'updated_at' => now(),
971
+ ],
972
+ [$subjectColumn, 'geofence_uuid'],
973
+ ['is_inside', 'entered_at', 'exited_at', 'dwell_job_id', 'updated_at']
974
+ );
975
+
976
+ if ($geofence->trigger_on_entry) {
977
+ event(new GeofenceEntered($subject, $geofence, $geofenceType, $newLocation));
978
+ }
979
+
980
+ if ($geofence->dwell_threshold_minutes > 0) {
981
+ $dwellJob = CheckGeofenceDwell::dispatch(
982
+ $subject->uuid,
983
+ $geofence->uuid,
984
+ $geofenceType,
985
+ $subjectColumn === 'vehicle_uuid' ? 'vehicle' : 'driver'
986
+ )->delay(now()->addMinutes($geofence->dwell_threshold_minutes));
987
+
988
+ DB::table($stateTable)
989
+ ->where($subjectColumn, $subject->uuid)
990
+ ->where('geofence_uuid', $geofence->uuid)
991
+ ->update(['dwell_job_id' => (string) $dwellJob]);
992
+ }
993
+ } elseif ($crossing['type'] === 'exited') {
994
+ $state = DB::table($stateTable)
995
+ ->where($subjectColumn, $subject->uuid)
996
+ ->where('geofence_uuid', $geofence->uuid)
997
+ ->first();
998
+
999
+ $dwellMinutes = null;
1000
+ if ($state && $state->entered_at) {
1001
+ $dwellMinutes = (int) Carbon::parse($state->entered_at)->diffInMinutes(now());
1002
+ }
1003
+
1004
+ DB::table($stateTable)
1005
+ ->where($subjectColumn, $subject->uuid)
1006
+ ->where('geofence_uuid', $geofence->uuid)
1007
+ ->update([
1008
+ 'is_inside' => false,
1009
+ 'exited_at' => now(),
1010
+ 'dwell_job_id' => null,
1011
+ 'updated_at' => now(),
1012
+ ]);
1013
+
1014
+ if ($geofence->trigger_on_exit) {
1015
+ event(new GeofenceExited($subject, $geofence, $geofenceType, $newLocation, $dwellMinutes));
1016
+ }
1017
+ }
1018
+ }
1019
+ }
920
1020
  }
@@ -0,0 +1,263 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
+
5
+ use Fleetbase\FleetOps\Models\GeofenceEventLog;
6
+ use Fleetbase\Http\Controllers\Controller;
7
+ use Illuminate\Http\JsonResponse;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\DB;
10
+
11
+ /**
12
+ * GeofenceController.
13
+ *
14
+ * Exposes read-only API endpoints for querying geofence event data:
15
+ * - Event log (paginated, filterable history of all geofence events)
16
+ * - Real-time inventory (which drivers are currently inside which geofences)
17
+ * - Dwell report (aggregated dwell time statistics per geofence)
18
+ */
19
+ class GeofenceController extends Controller
20
+ {
21
+ /**
22
+ * GET /api/v1/geofences/events.
23
+ *
24
+ * Returns a paginated log of geofence events for the authenticated company.
25
+ *
26
+ * Query parameters:
27
+ * - driver_uuid (string) Filter by driver UUID
28
+ * - geofence_uuid (string) Filter by geofence UUID
29
+ * - event_type (string) Filter by event type: entered | exited | dwelled
30
+ * - from (string) Filter events after this ISO 8601 datetime
31
+ * - to (string) Filter events before this ISO 8601 datetime
32
+ * - per_page (int) Number of results per page (default: 50, max: 200)
33
+ */
34
+ public function events(Request $request): JsonResponse
35
+ {
36
+ $companyUuid = session('company');
37
+
38
+ $query = GeofenceEventLog::where('company_uuid', $companyUuid)
39
+ ->with(['driver.vehicle', 'vehicle', 'order'])
40
+ ->orderBy('occurred_at', 'desc');
41
+
42
+ if ($request->filled('driver_uuid')) {
43
+ $query->where('driver_uuid', $request->input('driver_uuid'));
44
+ }
45
+
46
+ if ($request->filled('geofence_uuid')) {
47
+ $query->where('geofence_uuid', $request->input('geofence_uuid'));
48
+ }
49
+
50
+ if ($request->filled('vehicle_uuid')) {
51
+ $query->where('vehicle_uuid', $request->input('vehicle_uuid'));
52
+ }
53
+
54
+ if ($request->filled('subject_type')) {
55
+ $query->where('subject_type', $request->input('subject_type'));
56
+ }
57
+
58
+ if ($request->filled('event_type')) {
59
+ $query->where('event_type', str_replace('geofence.', '', $request->input('event_type')));
60
+ }
61
+
62
+ if ($request->filled('from')) {
63
+ $query->where('occurred_at', '>=', $request->input('from'));
64
+ }
65
+
66
+ if ($request->filled('to')) {
67
+ $query->where('occurred_at', '<=', $request->input('to'));
68
+ }
69
+
70
+ $perPage = min((int) $request->input('per_page', 50), 200);
71
+
72
+ $events = $query->paginate($perPage);
73
+ $events->getCollection()->transform(fn (GeofenceEventLog $event) => $this->serializeEvent($event));
74
+
75
+ return response()->json($events);
76
+ }
77
+
78
+ /**
79
+ * GET /api/v1/geofences/inventory.
80
+ *
81
+ * Returns a real-time snapshot of which drivers are currently inside
82
+ * which geofences for the authenticated company.
83
+ */
84
+ public function inventory(): JsonResponse
85
+ {
86
+ $companyUuid = session('company');
87
+
88
+ $driverStates = DB::table('driver_geofence_states as dgs')
89
+ ->join('drivers as d', 'd.uuid', '=', 'dgs.driver_uuid')
90
+ ->leftJoin('zones as z', function ($join) {
91
+ $join->on('z.uuid', '=', 'dgs.geofence_uuid')
92
+ ->where('dgs.geofence_type', '=', 'zone');
93
+ })
94
+ ->leftJoin('service_areas as sa', function ($join) {
95
+ $join->on('sa.uuid', '=', 'dgs.geofence_uuid')
96
+ ->where('dgs.geofence_type', '=', 'service_area');
97
+ })
98
+ ->where('d.company_uuid', $companyUuid)
99
+ ->where('dgs.is_inside', true)
100
+ ->whereNull('d.deleted_at')
101
+ ->select([
102
+ DB::raw("'driver' as subject_type"),
103
+ 'd.public_id as subject_id',
104
+ 'd.uuid as subject_uuid',
105
+ 'd.name as subject_name',
106
+ 'dgs.driver_uuid',
107
+ 'd.name as driver_name',
108
+ 'dgs.entered_at',
109
+ 'dgs.geofence_uuid',
110
+ DB::raw('COALESCE(z.name, sa.name) as geofence_name'),
111
+ 'dgs.geofence_type',
112
+ DB::raw('TIMESTAMPDIFF(MINUTE, dgs.entered_at, NOW()) as minutes_inside'),
113
+ ])
114
+ ->get();
115
+
116
+ $vehicleStates = DB::table('vehicle_geofence_states as vgs')
117
+ ->join('vehicles as v', 'v.uuid', '=', 'vgs.vehicle_uuid')
118
+ ->leftJoin('zones as z', function ($join) {
119
+ $join->on('z.uuid', '=', 'vgs.geofence_uuid')
120
+ ->where('vgs.geofence_type', '=', 'zone');
121
+ })
122
+ ->leftJoin('service_areas as sa', function ($join) {
123
+ $join->on('sa.uuid', '=', 'vgs.geofence_uuid')
124
+ ->where('vgs.geofence_type', '=', 'service_area');
125
+ })
126
+ ->where('v.company_uuid', $companyUuid)
127
+ ->where('vgs.is_inside', true)
128
+ ->whereNull('v.deleted_at')
129
+ ->select([
130
+ DB::raw("'vehicle' as subject_type"),
131
+ 'v.public_id as subject_id',
132
+ 'v.uuid as subject_uuid',
133
+ DB::raw('COALESCE(v.name, v.plate_number, v.public_id) as subject_name'),
134
+ DB::raw('NULL as driver_uuid'),
135
+ DB::raw('NULL as driver_name'),
136
+ 'vgs.entered_at',
137
+ 'vgs.geofence_uuid',
138
+ DB::raw('COALESCE(z.name, sa.name) as geofence_name'),
139
+ 'vgs.geofence_type',
140
+ DB::raw('TIMESTAMPDIFF(MINUTE, vgs.entered_at, NOW()) as minutes_inside'),
141
+ ])
142
+ ->get();
143
+
144
+ $states = $driverStates->merge($vehicleStates)->sortBy('entered_at')->values();
145
+
146
+ return response()->json([
147
+ 'data' => $states,
148
+ 'total' => $states->count(),
149
+ ]);
150
+ }
151
+
152
+ /**
153
+ * GET /api/v1/geofences/dwell-report.
154
+ *
155
+ * Returns aggregated dwell time statistics per geofence for the
156
+ * authenticated company.
157
+ *
158
+ * Query parameters:
159
+ * - from (string) Start of reporting period (ISO 8601)
160
+ * - to (string) End of reporting period (ISO 8601)
161
+ */
162
+ public function dwellReport(Request $request): JsonResponse
163
+ {
164
+ $companyUuid = session('company');
165
+
166
+ $query = GeofenceEventLog::where('company_uuid', $companyUuid)
167
+ ->where('event_type', 'exited')
168
+ ->whereNotNull('dwell_duration_minutes');
169
+
170
+ if ($request->filled('from')) {
171
+ $query->where('occurred_at', '>=', $request->input('from'));
172
+ }
173
+
174
+ if ($request->filled('to')) {
175
+ $query->where('occurred_at', '<=', $request->input('to'));
176
+ }
177
+
178
+ $report = $query
179
+ ->groupBy('geofence_uuid', 'geofence_name', 'geofence_type')
180
+ ->select([
181
+ 'geofence_uuid',
182
+ 'geofence_name',
183
+ 'geofence_type',
184
+ DB::raw('COUNT(*) as visit_count'),
185
+ DB::raw('ROUND(AVG(dwell_duration_minutes), 1) as avg_dwell_minutes'),
186
+ DB::raw('MAX(dwell_duration_minutes) as max_dwell_minutes'),
187
+ DB::raw('MIN(dwell_duration_minutes) as min_dwell_minutes'),
188
+ DB::raw('SUM(dwell_duration_minutes) as total_dwell_minutes'),
189
+ ])
190
+ ->orderBy('visit_count', 'desc')
191
+ ->get();
192
+
193
+ return response()->json(['data' => $report]);
194
+ }
195
+
196
+ /**
197
+ * GET /api/v1/geofences/driver/{driverUuid}/history.
198
+ *
199
+ * Returns the geofence event history for a specific driver.
200
+ */
201
+ public function driverHistory(Request $request, string $driverUuid): JsonResponse
202
+ {
203
+ $companyUuid = session('company');
204
+ $perPage = min((int) $request->input('per_page', 50), 200);
205
+
206
+ $events = GeofenceEventLog::where('company_uuid', $companyUuid)
207
+ ->where('driver_uuid', $driverUuid)
208
+ ->with(['driver.vehicle', 'vehicle', 'order'])
209
+ ->orderBy('occurred_at', 'desc')
210
+ ->paginate($perPage);
211
+
212
+ $events->getCollection()->transform(fn (GeofenceEventLog $event) => $this->serializeEvent($event));
213
+
214
+ return response()->json($events);
215
+ }
216
+
217
+ protected function serializeEvent(GeofenceEventLog $event): array
218
+ {
219
+ $driver = $event->driver;
220
+ $vehicle = $event->vehicle ?? $driver?->vehicle;
221
+ $subjectType = $event->subject_type ?? ($driver ? 'driver' : 'vehicle');
222
+ $subject = $subjectType === 'vehicle' ? $vehicle : $driver;
223
+
224
+ return [
225
+ 'id' => $event->uuid,
226
+ 'event_type' => 'geofence.' . $event->event_type,
227
+ 'occurred_at' => optional($event->occurred_at)->toIso8601String(),
228
+ 'dwell_duration_minutes' => $event->dwell_duration_minutes,
229
+ 'subject' => [
230
+ 'type' => $subjectType,
231
+ 'id' => $subject?->public_id,
232
+ 'uuid' => $event->subject_uuid ?? $subject?->uuid,
233
+ 'name' => $event->subject_name ?? ($subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name),
234
+ ],
235
+ 'driver' => $driver ? [
236
+ 'id' => $driver->public_id,
237
+ 'uuid' => $driver->uuid,
238
+ 'name' => $driver->name,
239
+ 'phone' => $driver->phone,
240
+ ] : null,
241
+ 'vehicle' => $vehicle ? [
242
+ 'id' => $vehicle->public_id,
243
+ 'uuid' => $vehicle->uuid,
244
+ 'name' => $vehicle->display_name ?? $vehicle->name,
245
+ 'plate' => $vehicle->plate_number,
246
+ ] : null,
247
+ 'geofence' => [
248
+ 'uuid' => $event->geofence_uuid,
249
+ 'name' => $event->geofence_name,
250
+ 'type' => $event->geofence_type,
251
+ ],
252
+ 'location' => [
253
+ 'latitude' => $event->latitude,
254
+ 'longitude' => $event->longitude,
255
+ ],
256
+ 'order' => $event->order ? [
257
+ 'id' => $event->order->public_id,
258
+ 'uuid' => $event->order->uuid,
259
+ 'status' => $event->order->status,
260
+ ] : null,
261
+ ];
262
+ }
263
+ }
@@ -46,7 +46,7 @@ class OrderController extends Controller
46
46
  /**
47
47
  * Creates a new Fleetbase Order resource.
48
48
  *
49
- * @param Request|\Fleetbase\Http\Requests\CreateOrderRequest $request
49
+ * @param Request|CreateOrderRequest $request
50
50
  *
51
51
  * @return \Fleetbase\Http\Resources\Order
52
52
  */
@@ -354,8 +354,7 @@ class OrderController extends Controller
354
354
  /**
355
355
  * Updates a Fleetbase Order resource.
356
356
  *
357
- * @param string $id
358
- * @param \Fleetbase\Http\Requests\UpdateOrderRequest $request
357
+ * @param string $id
359
358
  *
360
359
  * @return \Fleetbase\Http\Resources\Order
361
360
  */
@@ -528,6 +527,12 @@ class OrderController extends Controller
528
527
  }
529
528
  }
530
529
 
530
+ // If adding a service quote for a purchase
531
+ $serviceQuote = ServiceQuote::resolveFromRequest($request);
532
+ if ($serviceQuote) {
533
+ $order->purchaseServiceQuote($serviceQuote);
534
+ }
535
+
531
536
  // dispatch if flagged true
532
537
  if ($request->boolean('dispatch')) {
533
538
  $order->dispatch();
@@ -24,7 +24,7 @@ class ServiceAreaController extends Controller
24
24
  public function create(CreateServiceAreaRequest $request)
25
25
  {
26
26
  // get request input
27
- $input = $request->only(['name', 'type', 'status']);
27
+ $input = $request->only(['name', 'type', 'status', 'country', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
28
28
 
29
29
  // get radius for creating service area border - default to 500 meters
30
30
  $radius = (int) $request->input('radius', 500);
@@ -98,7 +98,7 @@ class ServiceAreaController extends Controller
98
98
  }
99
99
 
100
100
  // get request input
101
- $input = $request->only(['name', 'type', 'status']);
101
+ $input = $request->only(['name', 'type', 'status', 'country', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
102
102
 
103
103
  // get radius for creating service area border - default to 500 meters
104
104
  $radius = $request->input('radius', 500);