@fleetbase/fleetops-engine 0.6.52 → 0.6.54

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 (151) hide show
  1. package/addon/components/device/details.hbs +106 -135
  2. package/addon/components/device/details.js +174 -1
  3. package/addon/components/device/panel-header.hbs +2 -2
  4. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.hbs +201 -0
  5. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +654 -0
  6. package/addon/components/layout/fleet-ops-sidebar.hbs +19 -233
  7. package/addon/components/layout/fleet-ops-sidebar.js +364 -395
  8. package/addon/components/maintenance-schedule/form.js +1 -0
  9. package/addon/components/order/form/details.hbs +1 -43
  10. package/addon/components/order/form/details.js +18 -53
  11. package/addon/components/order/form/orchestrator-constraints.hbs +40 -0
  12. package/addon/components/order/form/orchestrator-constraints.js +61 -0
  13. package/addon/components/order/form/payload.js +50 -2
  14. package/addon/components/order/form/route.js +15 -0
  15. package/addon/components/order/form/service-rate.hbs +62 -11
  16. package/addon/components/order/form/service-rate.js +124 -2
  17. package/addon/components/order/form.hbs +2 -0
  18. package/addon/components/telematic/details.hbs +7 -1
  19. package/addon/components/telematic/form.js +16 -8
  20. package/addon/components/work-order/details.hbs +5 -1
  21. package/addon/components/work-order/form.hbs +26 -4
  22. package/addon/components/work-order/form.js +5 -1
  23. package/addon/controllers/connectivity/devices/index/details/events.js +244 -1
  24. package/addon/controllers/connectivity/devices/index/details/sensors.js +199 -0
  25. package/addon/controllers/connectivity/devices/index/details/vehicle.js +74 -0
  26. package/addon/controllers/connectivity/devices/index/details.js +12 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -1
  28. package/addon/controllers/connectivity/telematics/details/attachments.js +220 -54
  29. package/addon/controllers/connectivity/telematics/details/devices.js +90 -41
  30. package/addon/controllers/connectivity/telematics/details/events.js +39 -11
  31. package/addon/controllers/connectivity/telematics/details/sensors.js +39 -19
  32. package/addon/controllers/maintenance/index.js +100 -0
  33. package/addon/controllers/maintenance/work-orders/index.js +18 -1
  34. package/addon/controllers/management/fuel-transactions/index.js +1 -1
  35. package/addon/controllers/management/index.js +88 -0
  36. package/addon/controllers/settings/index.js +142 -0
  37. package/addon/extension.js +21 -0
  38. package/addon/routes/analytics/index.js +3 -0
  39. package/addon/routes/connectivity/devices/index/details/events.js +37 -1
  40. package/addon/routes/connectivity/devices/index/details/sensors.js +35 -0
  41. package/addon/routes/connectivity/devices/index/details/vehicle.js +7 -0
  42. package/addon/routes/connectivity/devices/index.js +3 -0
  43. package/addon/routes/connectivity/telematics/details/attachments.js +72 -9
  44. package/addon/routes/connectivity/telematics/details/devices.js +4 -0
  45. package/addon/routes/connectivity/telematics/details/sensors.js +1 -0
  46. package/addon/routes/connectivity/telematics/details.js +1 -1
  47. package/addon/routes/connectivity/telematics/edit.js +1 -1
  48. package/addon/routes/maintenance/index.js +3 -0
  49. package/addon/routes/maintenance/work-orders/index.js +3 -0
  50. package/addon/routes/settings/index.js +3 -0
  51. package/addon/routes.js +2 -0
  52. package/addon/services/order-creation.js +54 -1
  53. package/addon/services/order-validation.js +3 -2
  54. package/addon/services/service-rate-actions.js +1 -0
  55. package/addon/styles/fleetops-engine.css +1821 -6
  56. package/addon/templates/analytics/index.hbs +14 -0
  57. package/addon/templates/analytics/reports/index.hbs +4 -4
  58. package/addon/templates/connectivity/devices/index/details/events.hbs +85 -1
  59. package/addon/templates/connectivity/devices/index/details/sensors.hbs +73 -0
  60. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +84 -0
  61. package/addon/templates/connectivity/telematics/details/attachments.hbs +198 -121
  62. package/addon/templates/connectivity/telematics/details/devices.hbs +4 -1
  63. package/addon/templates/connectivity/telematics/details/events.hbs +4 -1
  64. package/addon/templates/connectivity/telematics/details/sensors.hbs +3 -0
  65. package/addon/templates/maintenance/index.hbs +113 -0
  66. package/addon/templates/management/fuel-transactions/index/details.hbs +1 -1
  67. package/addon/templates/management/index.hbs +109 -73
  68. package/addon/templates/management/vehicles/index/edit.hbs +1 -1
  69. package/addon/templates/operations/orders/index.hbs +1 -1
  70. package/addon/templates/operations/service-rates/index.hbs +1 -1
  71. package/addon/templates/settings/index.hbs +83 -0
  72. package/addon/templates/settings/routing.hbs +2 -2
  73. package/addon/utils/fleet-ops-options.js +38 -0
  74. package/app/components/order/form/orchestrator-constraints.js +1 -0
  75. package/app/controllers/connectivity/devices/index/details/sensors.js +1 -0
  76. package/app/controllers/connectivity/devices/index/details/vehicle.js +1 -0
  77. package/app/controllers/maintenance/index.js +1 -0
  78. package/app/controllers/management/index.js +1 -0
  79. package/app/controllers/settings/index.js +1 -0
  80. package/app/routes/analytics/index.js +1 -0
  81. package/app/routes/connectivity/devices/index/details/sensors.js +1 -0
  82. package/app/routes/connectivity/devices/index/details/vehicle.js +1 -0
  83. package/app/routes/maintenance/index.js +1 -0
  84. package/app/routes/settings/index.js +1 -0
  85. package/app/templates/analytics/index.js +1 -0
  86. package/app/templates/connectivity/devices/index/details/sensors.js +1 -0
  87. package/app/templates/connectivity/devices/index/details/vehicle.js +1 -0
  88. package/app/templates/maintenance/index.js +1 -0
  89. package/app/templates/management/index.js +1 -0
  90. package/app/templates/settings/index.js +1 -0
  91. package/composer.json +1 -1
  92. package/extension.json +1 -1
  93. package/package.json +3 -3
  94. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +1 -0
  95. package/server/migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php +30 -0
  96. package/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php +32 -0
  97. package/server/seeders/Testing/MaintenanceSeeder.php +5 -4
  98. package/server/src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php +1 -0
  99. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +1 -0
  100. package/server/src/Console/Commands/SyncTelematics.php +89 -0
  101. package/server/src/Exceptions/TelematicProviderException.php +13 -2
  102. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -21
  103. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +112 -11
  104. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +16 -5
  105. package/server/src/Http/Controllers/Internal/v1/FleetController.php +9 -0
  106. package/server/src/Http/Controllers/Internal/v1/HubController.php +300 -0
  107. package/server/src/Http/Controllers/Internal/v1/LiveController.php +177 -0
  108. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +1 -0
  109. package/server/src/Http/Controllers/Internal/v1/OrderController.php +4 -32
  110. package/server/src/Http/Controllers/Internal/v1/SearchController.php +506 -0
  111. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +4 -4
  112. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +1 -1
  113. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +94 -10
  114. package/server/src/Http/Filter/DeviceEventFilter.php +44 -0
  115. package/server/src/Http/Filter/DeviceFilter.php +131 -0
  116. package/server/src/Http/Filter/DriverFilter.php +6 -0
  117. package/server/src/Http/Filter/SensorFilter.php +125 -0
  118. package/server/src/Http/Filter/VehicleFilter.php +19 -0
  119. package/server/src/Http/Requests/CreateDriverRequest.php +14 -13
  120. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -0
  121. package/server/src/Http/Resources/v1/Driver.php +11 -2
  122. package/server/src/Http/Resources/v1/WorkOrder.php +1 -0
  123. package/server/src/Jobs/FinalizeApiOrderCreation.php +47 -0
  124. package/server/src/Jobs/FinalizeInternalOrderCreation.php +35 -0
  125. package/server/src/Jobs/NotifyBulkAssignedDriver.php +47 -0
  126. package/server/src/Jobs/SyncTelematicDevicesJob.php +181 -66
  127. package/server/src/Models/Device.php +15 -1
  128. package/server/src/Models/DeviceEvent.php +1 -1
  129. package/server/src/Models/Driver.php +17 -1
  130. package/server/src/Models/Sensor.php +15 -2
  131. package/server/src/Models/Telematic.php +1 -1
  132. package/server/src/Models/Vehicle.php +5 -1
  133. package/server/src/Models/WorkOrder.php +5 -2
  134. package/server/src/Observers/DriverObserver.php +3 -3
  135. package/server/src/Observers/FleetObserver.php +23 -0
  136. package/server/src/Observers/VehicleObserver.php +3 -3
  137. package/server/src/Providers/FleetOpsServiceProvider.php +12 -0
  138. package/server/src/Support/LiveCacheService.php +1 -1
  139. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +188 -28
  140. package/server/src/Support/Telematics/Providers/SafeeProvider.php +44 -3
  141. package/server/src/Support/Telematics/TelematicService.php +330 -30
  142. package/server/src/routes.php +10 -0
  143. package/server/tests/DeviceFilterTest.php +36 -0
  144. package/server/tests/HubRoutesTest.php +62 -0
  145. package/server/tests/OperationsMonitorEndpointTest.php +38 -0
  146. package/server/tests/OrderQueuedClosureRegressionTest.php +36 -0
  147. package/server/tests/TelematicsHardeningTest.php +455 -3
  148. package/server/tests/VehicleDriverAssignmentTest.php +27 -0
  149. package/translations/ar-ae.yml +1 -1
  150. package/translations/en-us.yaml +1 -0
  151. package/translations/vi-vn.yaml +1 -1
@@ -2,12 +2,17 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Support\Telematics;
4
4
 
5
+ use Fleetbase\FleetOps\Contracts\TelematicProviderInterface;
6
+ use Fleetbase\FleetOps\Events\VehicleLocationChanged;
5
7
  use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob;
6
8
  use Fleetbase\FleetOps\Jobs\TestTelematicConnectionJob;
7
9
  use Fleetbase\FleetOps\Models\Device;
8
10
  use Fleetbase\FleetOps\Models\DeviceEvent;
9
11
  use Fleetbase\FleetOps\Models\Sensor;
10
12
  use Fleetbase\FleetOps\Models\Telematic;
13
+ use Fleetbase\FleetOps\Models\Vehicle;
14
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
15
+ use Illuminate\Support\Carbon;
11
16
  use Illuminate\Support\Facades\Crypt;
12
17
  use Illuminate\Support\Facades\Validator;
13
18
  use Illuminate\Support\Str;
@@ -21,6 +26,14 @@ use Illuminate\Validation\ValidationException;
21
26
  */
22
27
  class TelematicService
23
28
  {
29
+ protected const PROTECTED_DEVICE_STATUSES = [
30
+ 'disabled',
31
+ 'decommissioned',
32
+ 'maintenance',
33
+ 'provisioning',
34
+ 'pending_activation',
35
+ ];
36
+
24
37
  protected TelematicProviderRegistry $registry;
25
38
 
26
39
  public function __construct(TelematicProviderRegistry $registry)
@@ -168,42 +181,34 @@ class TelematicService
168
181
  'device_id' => $externalId,
169
182
  ]);
170
183
 
171
- $device->company_uuid = $telematic->company_uuid;
172
- $device->name = $deviceData['name'] ?? $deviceData['device_name'] ?? $device->name ?? 'Unknown Device';
173
- $device->model = $deviceData['model'] ?? $deviceData['device_model'] ?? $device->model;
174
- $device->provider = $deviceData['provider'] ?? $deviceData['device_provider'] ?? $telematic->provider;
175
- $device->type = $deviceData['type'] ?? null;
176
- $device->internal_id = $deviceData['internal_id'] ?? $externalId;
177
- $device->imei = $deviceData['imei'] ?? null;
178
- $device->imsi = $deviceData['imsi'] ?? null;
179
- $device->serial_number = $deviceData['serial_number'] ?? null;
180
- $device->firmware_version = $deviceData['firmware_version'] ?? null;
181
- $device->status = $deviceData['status'] ?? $device->status ?? 'active';
184
+ $this->reconcileDeviceTelemetry($device, $telematic, array_merge($deviceData, [
185
+ 'external_id' => $externalId,
186
+ ]));
182
187
 
183
- if (array_key_exists('online', $deviceData) && $deviceData['online'] !== null) {
184
- $device->online = (bool) $deviceData['online'];
185
- } elseif (!$device->exists) {
186
- $device->online = $device->status === 'active';
187
- }
188
+ $device->save();
188
189
 
189
- if (!empty($deviceData['last_online_at']) || !empty($deviceData['last_seen_at'])) {
190
- $device->last_online_at = $deviceData['last_online_at'] ?? $deviceData['last_seen_at'];
191
- }
190
+ return $device;
191
+ }
192
192
 
193
- $device->meta = array_merge($device->meta ?? [], [
194
- 'external_id' => $externalId,
195
- ], $deviceData['meta'] ?? []);
193
+ public function ingestDeviceSnapshot(Telematic $telematic, TelematicProviderInterface $provider, array $payload): array
194
+ {
195
+ $device = $this->linkDevice($telematic, $provider->normalizeDevice($payload));
196
196
 
197
- $location = $this->normalizeLocation($deviceData['location'] ?? null);
198
- if ($location) {
199
- $device->last_position = $location;
200
- } elseif (!$device->exists || !$device->last_position) {
201
- $device->last_position = $this->defaultLocation();
197
+ $event = null;
198
+ try {
199
+ $eventData = $provider->normalizeEvent($payload);
200
+ if ($this->hasEventSignal($eventData)) {
201
+ $event = $this->storeDeviceEvent($telematic, $eventData, $device);
202
+ }
203
+ } catch (\Throwable) {
204
+ $event = null;
202
205
  }
203
206
 
204
- $device->save();
205
-
206
- return $device;
207
+ return [
208
+ 'device' => $device,
209
+ 'event' => $event,
210
+ 'sensors' => $this->storeSnapshotSensors($telematic, $provider, $payload, $device),
211
+ ];
207
212
  }
208
213
 
209
214
  public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $device = null): DeviceEvent
@@ -214,6 +219,7 @@ class TelematicService
214
219
 
215
220
  $eventKey = $this->makeEventKey($telematic, $eventData, $device);
216
221
  $event = $eventKey ? DeviceEvent::firstOrNew(['_key' => $eventKey]) : new DeviceEvent();
222
+ $wasRecentlyCreated = !$event->exists;
217
223
  $event->company_uuid = $telematic->company_uuid;
218
224
  $event->device_uuid = $device?->uuid;
219
225
  $event->event_type = $eventData['event_type'] ?? $eventData['type'] ?? 'telemetry_update';
@@ -228,6 +234,7 @@ class TelematicService
228
234
  $event->data = $eventData['data'] ?? array_filter([
229
235
  'speed' => $eventData['speed'] ?? null,
230
236
  'heading' => $eventData['heading'] ?? null,
237
+ 'altitude' => $eventData['altitude'] ?? null,
231
238
  'odometer' => $eventData['odometer'] ?? null,
232
239
  'ignition' => $eventData['ignition'] ?? null,
233
240
  'fuel_level' => $eventData['fuel_level'] ?? null,
@@ -241,6 +248,7 @@ class TelematicService
241
248
  'occurred_at' => $eventData['occurred_at'] ?? null,
242
249
  'speed' => $eventData['speed'] ?? null,
243
250
  'heading' => $eventData['heading'] ?? null,
251
+ 'altitude' => $eventData['altitude'] ?? null,
244
252
  'odometer' => $eventData['odometer'] ?? null,
245
253
  'ignition' => $eventData['ignition'] ?? null,
246
254
  'fuel_level' => $eventData['fuel_level'] ?? null,
@@ -252,6 +260,7 @@ class TelematicService
252
260
  }
253
261
 
254
262
  $event->save();
263
+ $this->applyDeviceEventTelemetry($event, $eventData, $device, $wasRecentlyCreated, $telematic);
255
264
 
256
265
  return $event;
257
266
  }
@@ -470,6 +479,297 @@ class TelematicService
470
479
  return ['latitude' => (float) $lat, 'longitude' => (float) $lng];
471
480
  }
472
481
 
482
+ protected function reconcileDeviceTelemetry(Device $device, ?Telematic $telematic, array $payload): void
483
+ {
484
+ $externalId = $this->resolveExternalId($payload);
485
+
486
+ if ($telematic) {
487
+ $device->company_uuid = $telematic->company_uuid;
488
+ }
489
+
490
+ $this->setDeviceAttributeIfPresent($device, 'name', $payload['name'] ?? $payload['device_name'] ?? (!$device->exists ? 'Unknown Device' : null));
491
+ $this->setDeviceAttributeIfPresent($device, 'model', $payload['model'] ?? $payload['device_model'] ?? null);
492
+ $this->setDeviceAttributeIfPresent($device, 'provider', $payload['provider'] ?? $payload['device_provider'] ?? $telematic?->provider);
493
+ $this->setDeviceAttributeIfPresent($device, 'type', $payload['type'] ?? null);
494
+ $this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? $externalId);
495
+ $this->setDeviceAttributeIfPresent($device, 'imei', $payload['imei'] ?? null);
496
+ $this->setDeviceAttributeIfPresent($device, 'imsi', $payload['imsi'] ?? null);
497
+ $this->setDeviceAttributeIfPresent($device, 'serial_number', $payload['serial_number'] ?? null);
498
+ $this->setDeviceAttributeIfPresent($device, 'firmware_version', $payload['firmware_version'] ?? null);
499
+
500
+ $location = $this->normalizeLocation($payload['location'] ?? null);
501
+ if ($location) {
502
+ $device->last_position = $location;
503
+ } elseif (!$device->exists || !$device->last_position) {
504
+ $device->last_position = $this->defaultLocation();
505
+ }
506
+
507
+ $lastSeen = $this->resolveTelemetryTimestamp($payload);
508
+ $reportedOnline = $this->resolveReportedOnline($payload);
509
+
510
+ if (!$lastSeen && $reportedOnline === true) {
511
+ $lastSeen = now();
512
+ }
513
+
514
+ if ($lastSeen) {
515
+ $device->last_online_at = $lastSeen;
516
+ }
517
+
518
+ $connectionStatus = $this->connectionStatusForDevice($device, $lastSeen, $reportedOnline);
519
+ $device->online = $connectionStatus === 'online';
520
+
521
+ if (!$this->isProtectedDeviceStatus($device->status)) {
522
+ $device->status = $connectionStatus;
523
+ }
524
+
525
+ $device->meta = array_merge($device->meta ?? [], [
526
+ 'external_id' => $externalId,
527
+ 'provider_status' => array_filter([
528
+ 'status' => $payload['status'] ?? null,
529
+ 'state' => $payload['state'] ?? null,
530
+ 'active' => $payload['active'] ?? data_get($payload, 'meta.active'),
531
+ 'online' => $payload['online'] ?? null,
532
+ 'is_online' => $payload['is_online'] ?? null,
533
+ ], fn ($value) => $value !== null),
534
+ 'telemetry_summary' => array_filter([
535
+ 'last_seen_at' => $lastSeen?->toDateTimeString(),
536
+ 'status' => $connectionStatus,
537
+ 'speed' => $payload['speed'] ?? null,
538
+ 'heading' => $payload['heading'] ?? null,
539
+ 'altitude' => $payload['altitude'] ?? null,
540
+ 'odometer' => $payload['odometer'] ?? null,
541
+ 'ignition' => $payload['ignition'] ?? null,
542
+ 'fuel_level' => $payload['fuel_level'] ?? null,
543
+ ], fn ($value) => $value !== null),
544
+ ], $payload['meta'] ?? []);
545
+ }
546
+
547
+ protected function setDeviceAttributeIfPresent(Device $device, string $key, mixed $value): void
548
+ {
549
+ if ($value === null || $value === '') {
550
+ return;
551
+ }
552
+
553
+ $device->{$key} = $value;
554
+ }
555
+
556
+ protected function resolveTelemetryTimestamp(array $payload): ?Carbon
557
+ {
558
+ $value = $payload['last_online_at']
559
+ ?? $payload['last_seen_at']
560
+ ?? $payload['occurred_at']
561
+ ?? $payload['recorded_at']
562
+ ?? $payload['timestamp']
563
+ ?? data_get($payload, 'meta.last_update.occurred_at')
564
+ ?? data_get($payload, 'meta.occurred_at')
565
+ ?? null;
566
+
567
+ if (!$value) {
568
+ return null;
569
+ }
570
+
571
+ try {
572
+ return $value instanceof Carbon ? $value : Carbon::parse($value);
573
+ } catch (\Throwable) {
574
+ return null;
575
+ }
576
+ }
577
+
578
+ protected function resolveReportedOnline(array $payload): ?bool
579
+ {
580
+ $value = $payload['online'] ?? $payload['is_online'] ?? null;
581
+
582
+ if ($value === null) {
583
+ return null;
584
+ }
585
+
586
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value;
587
+ }
588
+
589
+ protected function connectionStatusForDevice(Device $device, ?Carbon $lastSeen = null, ?bool $reportedOnline = null): string
590
+ {
591
+ if ($reportedOnline === true) {
592
+ return 'online';
593
+ }
594
+
595
+ $lastSeen ??= $device->last_online_at;
596
+
597
+ if (!$lastSeen) {
598
+ return 'never_connected';
599
+ }
600
+
601
+ $minutesOffline = Carbon::parse($lastSeen)->diffInMinutes(now());
602
+
603
+ if ($minutesOffline <= 10) {
604
+ return 'online';
605
+ }
606
+
607
+ if ($minutesOffline <= 60) {
608
+ return 'recently_offline';
609
+ }
610
+
611
+ if ($minutesOffline <= 1440) {
612
+ return 'offline';
613
+ }
614
+
615
+ return 'long_offline';
616
+ }
617
+
618
+ protected function isProtectedDeviceStatus(?string $status): bool
619
+ {
620
+ return in_array($status, self::PROTECTED_DEVICE_STATUSES, true);
621
+ }
622
+
623
+ protected function applyDeviceEventTelemetry(DeviceEvent $event, array $eventData, ?Device $device = null, bool $wasRecentlyCreated = true, ?Telematic $telematic = null): void
624
+ {
625
+ $location = $this->normalizeLocation($eventData['location'] ?? null);
626
+
627
+ $device ??= $event->device;
628
+ if ($device) {
629
+ $this->reconcileDeviceTelemetry($device, $telematic, $eventData);
630
+ $device->save();
631
+ $device->loadMissing('attachable');
632
+ }
633
+
634
+ if (!$location) {
635
+ return;
636
+ }
637
+
638
+ $positionData = $this->makePositionData($location, $eventData);
639
+ if ($wasRecentlyCreated && $positionData) {
640
+ $event->createPosition($positionData);
641
+ }
642
+
643
+ $attachable = $device?->attachable;
644
+ if ($attachable instanceof Vehicle) {
645
+ $this->updateVehicleTelemetry($attachable, $location, $eventData, $event);
646
+ }
647
+ }
648
+
649
+ protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, array $eventData, DeviceEvent $event): void
650
+ {
651
+ $vehicle->location = new SpatialPoint($location['latitude'], $location['longitude']);
652
+ $vehicle->online = array_key_exists('online', $eventData) && $eventData['online'] !== null ? (bool) $eventData['online'] : true;
653
+
654
+ if (array_key_exists('speed', $eventData) && $eventData['speed'] !== null) {
655
+ $vehicle->speed = $eventData['speed'];
656
+ }
657
+
658
+ if (array_key_exists('heading', $eventData) && $eventData['heading'] !== null) {
659
+ $vehicle->heading = $eventData['heading'];
660
+ }
661
+
662
+ if (array_key_exists('altitude', $eventData) && $eventData['altitude'] !== null) {
663
+ $vehicle->altitude = $eventData['altitude'];
664
+ }
665
+
666
+ $vehicle->telematics = array_merge($vehicle->telematics ?? [], [
667
+ 'last_event_uuid' => $event->uuid,
668
+ 'last_event_id' => $event->public_id,
669
+ 'last_event_type' => $event->event_type,
670
+ 'last_event_at' => optional($event->occurred_at)->toDateTimeString() ?? now()->toDateTimeString(),
671
+ 'last_device_uuid' => $event->device_uuid,
672
+ 'last_provider' => $event->provider,
673
+ 'last_telemetry_data' => array_filter([
674
+ 'speed' => $eventData['speed'] ?? null,
675
+ 'heading' => $eventData['heading'] ?? null,
676
+ 'altitude' => $eventData['altitude'] ?? null,
677
+ 'odometer' => $eventData['odometer'] ?? null,
678
+ 'ignition' => $eventData['ignition'] ?? null,
679
+ 'fuel_level' => $eventData['fuel_level'] ?? null,
680
+ ], fn ($value) => $value !== null),
681
+ ]);
682
+ $vehicle->save();
683
+
684
+ broadcast(new VehicleLocationChanged($vehicle, [
685
+ 'source' => 'telematics',
686
+ 'device_event_uuid' => $event->uuid,
687
+ 'provider' => $event->provider,
688
+ ]));
689
+ }
690
+
691
+ protected function makePositionData(array $location, array $eventData): array
692
+ {
693
+ return array_filter([
694
+ 'latitude' => $location['latitude'],
695
+ 'longitude' => $location['longitude'],
696
+ 'heading' => $eventData['heading'] ?? null,
697
+ 'bearing' => $eventData['heading'] ?? null,
698
+ 'speed' => $eventData['speed'] ?? null,
699
+ 'altitude' => $eventData['altitude'] ?? null,
700
+ ], fn ($value) => $value !== null);
701
+ }
702
+
703
+ protected function hasEventSignal(array $eventData): bool
704
+ {
705
+ return (bool) array_filter([
706
+ $eventData['event_id'] ?? null,
707
+ $eventData['external_event_id'] ?? null,
708
+ $eventData['ident'] ?? null,
709
+ $eventData['event_type'] ?? null,
710
+ $eventData['occurred_at'] ?? null,
711
+ $eventData['recorded_at'] ?? null,
712
+ $eventData['timestamp'] ?? null,
713
+ $eventData['location']['lat'] ?? null,
714
+ $eventData['location']['latitude'] ?? null,
715
+ $eventData['speed'] ?? null,
716
+ $eventData['heading'] ?? null,
717
+ $eventData['altitude'] ?? null,
718
+ $eventData['odometer'] ?? null,
719
+ $eventData['ignition'] ?? null,
720
+ $eventData['fuel_level'] ?? null,
721
+ ], fn ($value) => $value !== null);
722
+ }
723
+
724
+ protected function storeSnapshotSensors(Telematic $telematic, TelematicProviderInterface $provider, array $payload, Device $device): int
725
+ {
726
+ $rawSensors = $payload['sensors'] ?? $payload['sensors_last_val'] ?? [];
727
+ if (!is_array($rawSensors) || empty($rawSensors)) {
728
+ return 0;
729
+ }
730
+
731
+ $stored = 0;
732
+ $externalId = $this->resolveExternalId($payload);
733
+
734
+ foreach ($this->normalizeRawSensorList($rawSensors) as $rawSensor) {
735
+ try {
736
+ $sensorData = $provider->normalizeSensor(array_merge([
737
+ 'device_id' => $externalId,
738
+ ], $rawSensor));
739
+ $sensorData['device_id'] ??= $externalId;
740
+
741
+ $this->storeSensor($telematic, $sensorData, $device);
742
+ $stored++;
743
+ } catch (\Throwable) {
744
+ continue;
745
+ }
746
+ }
747
+
748
+ return $stored;
749
+ }
750
+
751
+ protected function normalizeRawSensorList(array $rawSensors): array
752
+ {
753
+ if (array_is_list($rawSensors)) {
754
+ return array_filter($rawSensors, fn ($sensor) => is_array($sensor));
755
+ }
756
+
757
+ return collect($rawSensors)
758
+ ->map(function ($sensor, $name) {
759
+ if (is_array($sensor)) {
760
+ return array_merge(['name' => $name], $sensor);
761
+ }
762
+
763
+ return [
764
+ 'name' => $name,
765
+ 'type' => $name,
766
+ 'value' => $sensor,
767
+ ];
768
+ })
769
+ ->values()
770
+ ->all();
771
+ }
772
+
473
773
  protected function defaultLocation(): array
474
774
  {
475
775
  return ['latitude' => 0, 'longitude' => 0];
@@ -305,6 +305,8 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
305
305
  ],
306
306
  ],
307
307
  function ($router) {
308
+ $router->get('search', 'SearchController@search');
309
+
308
310
  $router->fleetbaseRoutes(
309
311
  'contacts',
310
312
  function ($router, $controller) {
@@ -610,6 +612,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
610
612
  $router->get('coordinates', 'LiveController@coordinates');
611
613
  $router->get('routes', 'LiveController@routes');
612
614
  $router->get('orders', 'LiveController@orders');
615
+ $router->get('operations-monitor', 'LiveController@operationsMonitor');
613
616
  $router->get('drivers', 'LiveController@drivers');
614
617
  $router->get('vehicles', 'LiveController@vehicles');
615
618
  $router->get('places', 'LiveController@places');
@@ -671,6 +674,13 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
671
674
  $router->get('live-fleet', 'AnalyticsController@liveFleet');
672
675
  }
673
676
  );
677
+ $router->group(
678
+ ['prefix' => 'hubs'],
679
+ function ($router) {
680
+ $router->get('resources', 'HubController@resources');
681
+ $router->get('maintenance', 'HubController@maintenance');
682
+ }
683
+ );
674
684
  $router->group(
675
685
  ['prefix' => 'getting-started'],
676
686
  function ($router) {
@@ -0,0 +1,36 @@
1
+ <?php
2
+
3
+ test('device filter exposes telematics detail filter contract', function () {
4
+ $filter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/DeviceFilter.php');
5
+ $model = file_get_contents(dirname(__DIR__) . '/src/Models/Device.php');
6
+ $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DeviceController.php');
7
+
8
+ expect($model)
9
+ ->toContain("'vehicle'")
10
+ ->toContain("'connection_status'")
11
+ ->toContain("'device_id'")
12
+ ->toContain("'last_online_at'")
13
+ ->toContain("'updated_at'");
14
+
15
+ expect($filter)
16
+ ->toContain('public function query(?string $searchQuery)')
17
+ ->toContain('public function deviceId(?string $deviceId)')
18
+ ->toContain("where('device_id', 'like'")
19
+ ->toContain('public function vehicle(?string $vehicle)')
20
+ ->toContain("where('attachable_uuid', \$vehicle)")
21
+ ->toContain('public function connectionStatus')
22
+ ->toContain("'online'")
23
+ ->toContain("'recently_offline'")
24
+ ->toContain("'offline'")
25
+ ->toContain("'long_offline'")
26
+ ->toContain("'never_connected'")
27
+ ->toContain('public function lastOnlineAt')
28
+ ->toContain('public function updatedAt')
29
+ ->toContain('Utils::dateRange')
30
+ ->toContain('protected function filterDate');
31
+
32
+ expect($controller)
33
+ ->toContain("filled('connection_status')")
34
+ ->toContain("filled('last_online_at')")
35
+ ->toContain("filled('updated_at')");
36
+ });
@@ -0,0 +1,62 @@
1
+ <?php
2
+
3
+ test('hub routes are registered', function () {
4
+ $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
5
+
6
+ expect($routes)
7
+ ->toContain("['prefix' => 'hubs']")
8
+ ->toContain("\$router->get('resources', 'HubController@resources');")
9
+ ->toContain("\$router->get('maintenance', 'HubController@maintenance');");
10
+ });
11
+
12
+ test('hub controller exposes resources and maintenance contracts', function () {
13
+ $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/HubController.php');
14
+
15
+ expect($controller)
16
+ ->toContain('public function resources(Request $request)')
17
+ ->toContain('public function maintenance(Request $request)')
18
+ ->toContain("'kpis'")
19
+ ->toContain("'actions'")
20
+ ->toContain("'sections'")
21
+ ->toContain("'docs'");
22
+ });
23
+
24
+ test('resources hub action queue includes cross workflow resource actions', function () {
25
+ $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/HubController.php');
26
+
27
+ expect($controller)
28
+ ->toContain("'drivers_without_vehicles'")
29
+ ->toContain("'vehicles_without_drivers'")
30
+ ->toContain("'vehicles_without_devices'")
31
+ ->toContain("'unattached_devices'")
32
+ ->toContain("'resource_issues'")
33
+ ->toContain("'overdue_vehicle_schedules'")
34
+ ->toContain("'open_resource_work_orders'")
35
+ ->toContain("'low_stock_parts'")
36
+ ->toContain("'unmatched_fuel_transactions'")
37
+ ->toContain("'assign_vehicles_to_drivers', 'Assign vehicles to drivers'")
38
+ ->toContain("'assign_drivers_to_vehicles', 'Assign drivers to vehicles'")
39
+ ->toContain("'attach_devices_to_vehicles', 'Attach devices to vehicles'")
40
+ ->toContain("'prepare_vehicle_maintenance', 'Prepare upcoming maintenance'")
41
+ ->toContain("'close_overdue_work_orders', 'Close overdue work orders'")
42
+ ->toContain("'replenish_low_stock_parts', 'Replenish low-stock parts'")
43
+ ->toContain("'review_unmatched_fuel_transactions', 'Review unmatched fuel transactions'")
44
+ ->toContain("'add_operating_places', 'Add operating places'")
45
+ ->toContain("'add_service_vendors', 'Add vendors for service work'")
46
+ ->toContain("'Core resources look ready'");
47
+ });
48
+
49
+ test('resource action target filters are supported', function () {
50
+ $deviceFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/DeviceFilter.php');
51
+ $driverFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/DriverFilter.php');
52
+ $vehicleFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/VehicleFilter.php');
53
+
54
+ expect($deviceFilter)
55
+ ->toContain('public function attachmentState')
56
+ ->toContain("attachmentState === 'unattached'")
57
+ ->toContain('public function status')
58
+ ->toContain('public function telematic');
59
+
60
+ expect($driverFilter)->toContain("\$vehicle === 'unassigned'");
61
+ expect($vehicleFilter)->toContain("\$driverId === 'unassigned'");
62
+ });
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ test('operations monitor live route is registered on the internal fleetops surface', function () {
4
+ $routes = file_get_contents(__DIR__ . '/../src/routes.php');
5
+
6
+ expect($routes)
7
+ ->toContain("\$router->get('operations-monitor', 'LiveController@operationsMonitor');")
8
+ ->toContain("['prefix' => 'live']");
9
+ });
10
+
11
+ test('operations monitor endpoint returns a cached flat resource snapshot with id linked fleets', function () {
12
+ $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/LiveController.php');
13
+
14
+ expect($controller)
15
+ ->toContain("LiveCacheService::remember('operations-monitor'")
16
+ ->toContain("'drivers' => \$drivers->map")
17
+ ->toContain("'vehicles' => \$vehicles->map")
18
+ ->toContain("'fleets' => \$this->buildOperationsMonitorFleetTree")
19
+ ->toContain("'driver_ids' => \$driverIds->all()")
20
+ ->toContain("'vehicle_ids' => \$vehicleIds->all()")
21
+ ->toContain("'meta' => [")
22
+ ->not->toContain("'drivers' => \$this->whenLoaded('drivers'")
23
+ ->not->toContain("'vehicles' => \$this->whenLoaded('vehicles'");
24
+ });
25
+
26
+ test('operations monitor cache is invalidated by live cache and resource mutations', function () {
27
+ $cache = file_get_contents(__DIR__ . '/../src/Support/LiveCacheService.php');
28
+ $driverObserver = file_get_contents(__DIR__ . '/../src/Observers/DriverObserver.php');
29
+ $vehicleObserver = file_get_contents(__DIR__ . '/../src/Observers/VehicleObserver.php');
30
+ $fleetObserver = file_get_contents(__DIR__ . '/../src/Observers/FleetObserver.php');
31
+ $fleetController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/FleetController.php');
32
+
33
+ expect($cache)->toContain("'operations-monitor'");
34
+ expect($driverObserver)->toContain("LiveCacheService::invalidateMultiple(['drivers', 'operations-monitor'])");
35
+ expect($vehicleObserver)->toContain("LiveCacheService::invalidateMultiple(['vehicles', 'operations-monitor'])");
36
+ expect($fleetObserver)->toContain("LiveCacheService::invalidate('operations-monitor')");
37
+ expect(substr_count($fleetController, "LiveCacheService::invalidate('operations-monitor')"))->toBe(4);
38
+ });
@@ -0,0 +1,36 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Jobs\FinalizeApiOrderCreation;
4
+ use Fleetbase\FleetOps\Jobs\FinalizeInternalOrderCreation;
5
+ use Fleetbase\FleetOps\Jobs\NotifyBulkAssignedDriver;
6
+ use Illuminate\Contracts\Queue\ShouldQueue;
7
+
8
+ test('order workflows dispatch named queue jobs instead of signed closures', function () {
9
+ $apiController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/OrderController.php');
10
+ $internalController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/OrderController.php');
11
+
12
+ expect($apiController)
13
+ ->not->toContain('dispatch(function')
14
+ ->toContain('FinalizeApiOrderCreation::dispatch(')
15
+ ->toContain(')->afterCommit()')
16
+ ->and($internalController)
17
+ ->not->toContain('dispatch(function')
18
+ ->toContain('FinalizeInternalOrderCreation::dispatch($order->uuid)->afterCommit()')
19
+ ->toContain('NotifyBulkAssignedDriver::dispatch($orderUuids->all(), $driver->uuid)->afterCommit()');
20
+ });
21
+
22
+ test('order queue jobs serialize only scalar identifiers and options', function () {
23
+ $apiJob = new FinalizeApiOrderCreation('order-uuid', 'service-quote-uuid', true);
24
+ $internalJob = new FinalizeInternalOrderCreation('order-uuid');
25
+ $bulkJob = new NotifyBulkAssignedDriver(['order-uuid-1', 'order-uuid-2'], 'driver-uuid');
26
+
27
+ expect($apiJob)->toBeInstanceOf(ShouldQueue::class)
28
+ ->and($apiJob->orderUuid)->toBe('order-uuid')
29
+ ->and($apiJob->serviceQuoteUuid)->toBe('service-quote-uuid')
30
+ ->and($apiJob->shouldDispatch)->toBeTrue()
31
+ ->and($internalJob)->toBeInstanceOf(ShouldQueue::class)
32
+ ->and($internalJob->orderUuid)->toBe('order-uuid')
33
+ ->and($bulkJob)->toBeInstanceOf(ShouldQueue::class)
34
+ ->and($bulkJob->orderUuids)->toBe(['order-uuid-1', 'order-uuid-2'])
35
+ ->and($bulkJob->driverUuid)->toBe('driver-uuid');
36
+ });