@fleetbase/fleetops-engine 0.6.53 → 0.6.55

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 (212) hide show
  1. package/addon/components/admin/routing-settings.hbs +2 -1
  2. package/addon/components/cell/attached-vehicle.hbs +10 -0
  3. package/addon/components/cell/attached-vehicle.js +58 -0
  4. package/addon/components/cell/device-identity.hbs +24 -0
  5. package/addon/components/cell/device-identity.js +129 -0
  6. package/addon/components/cell/driver-identity.hbs +33 -0
  7. package/addon/components/cell/driver-identity.js +143 -0
  8. package/addon/components/cell/equipment-identity.hbs +5 -0
  9. package/addon/components/cell/equipment-identity.js +48 -0
  10. package/addon/components/cell/part-identity.hbs +5 -0
  11. package/addon/components/cell/part-identity.js +63 -0
  12. package/addon/components/cell/telematic-device.hbs +38 -0
  13. package/addon/components/cell/telematic-device.js +44 -0
  14. package/addon/components/cell/telematic-provider.hbs +33 -16
  15. package/addon/components/cell/telematic-provider.js +50 -8
  16. package/addon/components/cell/vehicle-identity.hbs +33 -0
  17. package/addon/components/cell/vehicle-identity.js +138 -0
  18. package/addon/components/device/details.hbs +106 -135
  19. package/addon/components/device/details.js +174 -1
  20. package/addon/components/device/form.hbs +2 -2
  21. package/addon/components/device/form.js +7 -0
  22. package/addon/components/device/panel-header.hbs +46 -21
  23. package/addon/components/device/panel-header.js +49 -1
  24. package/addon/components/device/panel-tabs/events.hbs +30 -0
  25. package/addon/components/device/panel-tabs/events.js +128 -0
  26. package/addon/components/device/panel-tabs/sensors.hbs +30 -0
  27. package/addon/components/device/panel-tabs/sensors.js +93 -0
  28. package/addon/components/device/panel-tabs/vehicle.hbs +68 -0
  29. package/addon/components/device/panel-tabs/vehicle.js +90 -0
  30. package/addon/components/equipment/form.js +8 -1
  31. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +76 -11
  32. package/addon/components/layout/fleet-ops-sidebar.hbs +1 -1
  33. package/addon/components/layout/fleet-ops-sidebar.js +101 -17
  34. package/addon/components/map/drawer/device-event-listing.js +2 -0
  35. package/addon/components/map/drawer/driver-listing.js +2 -0
  36. package/addon/components/map/drawer/place-listing.js +2 -0
  37. package/addon/components/map/drawer/position-listing.hbs +11 -6
  38. package/addon/components/map/drawer/position-listing.js +17 -4
  39. package/addon/components/map/drawer/vehicle-listing.js +2 -0
  40. package/addon/components/order/form/details.hbs +1 -43
  41. package/addon/components/order/form/details.js +18 -53
  42. package/addon/components/order/form/orchestrator-constraints.hbs +40 -0
  43. package/addon/components/order/form/orchestrator-constraints.js +61 -0
  44. package/addon/components/order/form/payload.js +48 -2
  45. package/addon/components/order/form/route.js +15 -0
  46. package/addon/components/order/form/service-rate.hbs +62 -11
  47. package/addon/components/order/form/service-rate.js +124 -2
  48. package/addon/components/order/form.hbs +2 -0
  49. package/addon/components/sensor/panel-header.hbs +47 -15
  50. package/addon/components/sensor/panel-header.js +59 -1
  51. package/addon/components/table/cell/dropdown/action-item.hbs +31 -0
  52. package/addon/components/table/cell/dropdown/action-item.js +62 -0
  53. package/addon/components/table/cell/dropdown.hbs +28 -0
  54. package/addon/components/table/cell/dropdown.js +91 -0
  55. package/addon/components/telematic/details.hbs +7 -1
  56. package/addon/components/telematic/form.js +16 -8
  57. package/addon/components/vendor/panel-header.hbs +82 -0
  58. package/addon/components/vendor/panel-header.js +48 -0
  59. package/addon/components/work-order/details.hbs +5 -1
  60. package/addon/components/work-order/form.hbs +26 -4
  61. package/addon/components/work-order/form.js +5 -1
  62. package/addon/controllers/connectivity/devices/index/details/events.js +244 -1
  63. package/addon/controllers/connectivity/devices/index/details/sensors.js +199 -0
  64. package/addon/controllers/connectivity/devices/index/details/vehicle.js +122 -0
  65. package/addon/controllers/connectivity/devices/index/details.js +12 -0
  66. package/addon/controllers/connectivity/devices/index.js +144 -129
  67. package/addon/controllers/connectivity/events/details.js +3 -0
  68. package/addon/controllers/connectivity/events/index.js +187 -11
  69. package/addon/controllers/connectivity/sensors/index.js +156 -14
  70. package/addon/controllers/connectivity/telematics/details/attachments.js +220 -54
  71. package/addon/controllers/connectivity/telematics/details/devices.js +170 -129
  72. package/addon/controllers/connectivity/telematics/details/events.js +39 -11
  73. package/addon/controllers/connectivity/telematics/details/sensors.js +39 -19
  74. package/addon/controllers/maintenance/equipment/index.js +1 -2
  75. package/addon/controllers/maintenance/parts/index.js +1 -2
  76. package/addon/controllers/maintenance/work-orders/index.js +18 -1
  77. package/addon/controllers/management/drivers/index.js +82 -42
  78. package/addon/controllers/management/fuel-reports/index.js +63 -4
  79. package/addon/controllers/management/issues/index.js +63 -4
  80. package/addon/controllers/management/vehicles/index.js +33 -6
  81. package/addon/routes/connectivity/devices/index/details/events.js +37 -1
  82. package/addon/routes/connectivity/devices/index/details/sensors.js +35 -0
  83. package/addon/routes/connectivity/devices/index/details/vehicle.js +29 -0
  84. package/addon/routes/connectivity/devices/index.js +7 -0
  85. package/addon/routes/connectivity/events/details.js +3 -0
  86. package/addon/routes/connectivity/events/index.js +6 -2
  87. package/addon/routes/connectivity/sensors/index.js +7 -2
  88. package/addon/routes/connectivity/telematics/details/attachments.js +72 -9
  89. package/addon/routes/connectivity/telematics/details/devices.js +6 -0
  90. package/addon/routes/connectivity/telematics/details/sensors.js +1 -0
  91. package/addon/routes/connectivity/telematics/details.js +1 -1
  92. package/addon/routes/connectivity/telematics/edit.js +1 -1
  93. package/addon/routes/maintenance/work-orders/index.js +1 -0
  94. package/addon/routes.js +2 -0
  95. package/addon/services/device-actions.js +30 -0
  96. package/addon/services/device-event-actions.js +1 -1
  97. package/addon/services/equipment-actions.js +1 -0
  98. package/addon/services/order-creation.js +54 -1
  99. package/addon/services/order-validation.js +3 -2
  100. package/addon/services/sensor-actions.js +3 -0
  101. package/addon/services/service-rate-actions.js +40 -1
  102. package/addon/services/vehicle-actions.js +24 -12
  103. package/addon/services/vendor-actions.js +3 -0
  104. package/addon/styles/fleetops-engine.css +28 -4
  105. package/addon/templates/connectivity/devices/index/details/events.hbs +85 -1
  106. package/addon/templates/connectivity/devices/index/details/sensors.hbs +73 -0
  107. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +147 -0
  108. package/addon/templates/connectivity/telematics/details/attachments.hbs +198 -121
  109. package/addon/templates/connectivity/telematics/details/devices.hbs +4 -1
  110. package/addon/templates/connectivity/telematics/details/events.hbs +4 -1
  111. package/addon/templates/connectivity/telematics/details/sensors.hbs +3 -0
  112. package/addon/templates/management/vehicles/index/edit.hbs +1 -1
  113. package/addon/templates/management/vendors/index/details.hbs +2 -2
  114. package/addon/templates/settings/notifications.hbs +2 -1
  115. package/addon/templates/settings/routing.hbs +1 -0
  116. package/addon/utils/device-table-columns.js +288 -0
  117. package/addon/utils/fleet-ops-options.js +38 -0
  118. package/addon/utils/identity-cell-resource.js +20 -0
  119. package/addon/utils/map-drawer-dropdown-position.js +47 -0
  120. package/addon/utils/trackable-option.js +93 -0
  121. package/app/components/cell/attached-vehicle.js +1 -0
  122. package/app/components/cell/device-identity.js +1 -0
  123. package/app/components/cell/driver-identity.js +1 -0
  124. package/app/components/cell/equipment-identity.js +1 -0
  125. package/app/components/cell/part-identity.js +1 -0
  126. package/app/components/cell/telematic-device.js +1 -0
  127. package/app/components/cell/vehicle-identity.js +1 -0
  128. package/app/components/device/panel-tabs/events.hbs +30 -0
  129. package/app/components/device/panel-tabs/events.js +128 -0
  130. package/app/components/device/panel-tabs/sensors.hbs +30 -0
  131. package/app/components/device/panel-tabs/sensors.js +93 -0
  132. package/app/components/device/panel-tabs/vehicle.hbs +68 -0
  133. package/app/components/device/panel-tabs/vehicle.js +90 -0
  134. package/app/components/order/form/orchestrator-constraints.js +1 -0
  135. package/app/components/table/cell/dropdown/action-item.js +1 -0
  136. package/app/components/table/cell/dropdown.js +1 -0
  137. package/app/components/vendor/panel-header.js +1 -0
  138. package/app/controllers/connectivity/devices/index/details/sensors.js +1 -0
  139. package/app/controllers/connectivity/devices/index/details/vehicle.js +1 -0
  140. package/app/controllers/connectivity/events/{index/details.js → details.js} +1 -1
  141. package/app/routes/connectivity/devices/index/details/sensors.js +1 -0
  142. package/app/routes/connectivity/devices/index/details/vehicle.js +1 -0
  143. package/app/routes/connectivity/events/{index/details.js → details.js} +1 -1
  144. package/app/templates/connectivity/devices/index/details/sensors.js +1 -0
  145. package/app/templates/connectivity/devices/index/details/vehicle.js +1 -0
  146. package/app/templates/connectivity/events/{index/details.js → details.js} +1 -1
  147. package/app/utils/map-drawer-dropdown-position.js +1 -0
  148. package/app/utils/trackable-option.js +1 -0
  149. package/composer.json +1 -1
  150. package/extension.json +1 -1
  151. package/package.json +4 -4
  152. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +1 -0
  153. package/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php +32 -0
  154. package/server/seeders/Testing/MaintenanceSeeder.php +5 -4
  155. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +1 -0
  156. package/server/src/Console/Commands/SyncTelematics.php +89 -0
  157. package/server/src/Exceptions/TelematicProviderException.php +13 -2
  158. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -21
  159. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +55 -1
  160. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +17 -6
  161. package/server/src/Http/Controllers/Internal/v1/FleetController.php +9 -0
  162. package/server/src/Http/Controllers/Internal/v1/LiveController.php +178 -1
  163. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +1 -0
  164. package/server/src/Http/Controllers/Internal/v1/OrderController.php +4 -32
  165. package/server/src/Http/Controllers/Internal/v1/SearchController.php +2 -2
  166. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +4 -4
  167. package/server/src/Http/Controllers/Internal/v1/ServiceRateController.php +82 -13
  168. package/server/src/Http/Filter/DeviceEventFilter.php +65 -0
  169. package/server/src/Http/Filter/DeviceFilter.php +80 -0
  170. package/server/src/Http/Filter/SensorFilter.php +125 -0
  171. package/server/src/Http/Filter/VehicleFilter.php +13 -0
  172. package/server/src/Http/Requests/CreateDriverRequest.php +14 -13
  173. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -0
  174. package/server/src/Http/Resources/v1/Driver.php +11 -2
  175. package/server/src/Http/Resources/v1/Index/Vehicle.php +19 -0
  176. package/server/src/Http/Resources/v1/WorkOrder.php +1 -0
  177. package/server/src/Jobs/FinalizeApiOrderCreation.php +47 -0
  178. package/server/src/Jobs/FinalizeInternalOrderCreation.php +35 -0
  179. package/server/src/Jobs/NotifyBulkAssignedDriver.php +47 -0
  180. package/server/src/Jobs/SyncTelematicDevicesJob.php +180 -123
  181. package/server/src/Models/Device.php +17 -1
  182. package/server/src/Models/DeviceEvent.php +82 -1
  183. package/server/src/Models/Driver.php +16 -0
  184. package/server/src/Models/Equipment.php +55 -0
  185. package/server/src/Models/Sensor.php +15 -2
  186. package/server/src/Models/Telematic.php +1 -1
  187. package/server/src/Models/Vehicle.php +5 -1
  188. package/server/src/Models/WorkOrder.php +5 -2
  189. package/server/src/Observers/DriverObserver.php +3 -3
  190. package/server/src/Observers/FleetObserver.php +23 -0
  191. package/server/src/Observers/VehicleObserver.php +3 -3
  192. package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
  193. package/server/src/Support/LiveCacheService.php +1 -1
  194. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +143 -20
  195. package/server/src/Support/Telematics/Providers/FlespiProvider.php +89 -15
  196. package/server/src/Support/Telematics/Providers/GeotabProvider.php +123 -35
  197. package/server/src/Support/Telematics/Providers/SafeeProvider.php +70 -5
  198. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +122 -14
  199. package/server/src/Support/Telematics/TelematicService.php +330 -30
  200. package/server/src/routes.php +1 -1
  201. package/server/tests/DeviceFilterTest.php +45 -0
  202. package/server/tests/DriverVehicleStatusDefaultTest.php +19 -2
  203. package/server/tests/EquipmentAssignmentMorphTypeTest.php +35 -0
  204. package/server/tests/OperationsMonitorEndpointTest.php +38 -0
  205. package/server/tests/OrderQueuedClosureRegressionTest.php +36 -0
  206. package/server/tests/ServiceRateRouteValidationTest.php +101 -0
  207. package/server/tests/TelematicsHardeningTest.php +598 -6
  208. package/server/tests/VehicleDriverAssignmentTest.php +27 -0
  209. package/translations/en-us.yaml +1 -0
  210. package/addon/controllers/connectivity/events/index/details.js +0 -3
  211. package/addon/routes/connectivity/events/index/details.js +0 -3
  212. /package/addon/templates/connectivity/events/{index/details.hbs → details.hbs} +0 -0
@@ -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];
@@ -476,7 +476,6 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
476
476
  $router->delete('bulk-delete', $controller('bulkDelete'));
477
477
  $router->get('for-route', $controller('getServicesForRoute'));
478
478
  $router->match(['get', 'post'], 'export', $controller('export'));
479
- $router->get('for-route', $controller('getServicesForRoute'));
480
479
  }
481
480
  );
482
481
  $router->fleetbaseRoutes('tracking-numbers');
@@ -612,6 +611,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
612
611
  $router->get('coordinates', 'LiveController@coordinates');
613
612
  $router->get('routes', 'LiveController@routes');
614
613
  $router->get('orders', 'LiveController@orders');
614
+ $router->get('operations-monitor', 'LiveController@operationsMonitor');
615
615
  $router->get('drivers', 'LiveController@drivers');
616
616
  $router->get('vehicles', 'LiveController@vehicles');
617
617
  $router->get('places', 'LiveController@places');
@@ -0,0 +1,45 @@
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("'type'")
13
+ ->toContain("'serial_number'")
14
+ ->toContain("'last_online_at'")
15
+ ->toContain("'updated_at'");
16
+
17
+ expect($filter)
18
+ ->toContain('public function query(?string $searchQuery)')
19
+ ->toContain('public function deviceId(?string $deviceId)')
20
+ ->toContain("where('device_id', 'like'")
21
+ ->toContain('public function type(string|array|null $type)')
22
+ ->toContain("whereIn('type', \$type)")
23
+ ->toContain('public function serialNumber(?string $serialNumber)')
24
+ ->toContain("where('serial_number', 'like'")
25
+ ->toContain('public function vehicle(?string $vehicle)')
26
+ ->toContain("where('attachable_uuid', \$vehicle)")
27
+ ->toContain("where('public_id', \$vehicle)")
28
+ ->toContain('public function connectionStatus')
29
+ ->toContain("'online'")
30
+ ->toContain("'recently_offline'")
31
+ ->toContain("'offline'")
32
+ ->toContain("'long_offline'")
33
+ ->toContain("'never_connected'")
34
+ ->toContain('public function lastOnlineAt')
35
+ ->toContain('public function updatedAt')
36
+ ->toContain('Utils::dateRange')
37
+ ->toContain('protected function filterDate');
38
+
39
+ expect($controller)
40
+ ->toContain("withCount('sensors')")
41
+ ->toContain("filled('connection_status')")
42
+ ->toContain("filled('serial_number')")
43
+ ->toContain("filled('last_online_at')")
44
+ ->toContain("filled('updated_at')");
45
+ });
@@ -1,16 +1,19 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Equipment;
4
5
  use Fleetbase\FleetOps\Models\Vehicle;
5
6
 
6
- test('driver and vehicle statuses default to available', function () {
7
+ test('driver vehicle and equipment statuses default to available', function () {
7
8
  expect((new Driver())->status)
8
9
  ->toBe('available')
9
10
  ->and((new Vehicle())->status)
11
+ ->toBe('available')
12
+ ->and((new Equipment())->status)
10
13
  ->toBe('available');
11
14
  });
12
15
 
13
- test('legacy active and null driver and vehicle statuses normalize to available', function () {
16
+ test('legacy active and null driver vehicle and equipment statuses normalize to available', function () {
14
17
  $driver = new Driver();
15
18
  $driver->status = 'active';
16
19
  expect($driver->status)->toBe('available');
@@ -24,6 +27,20 @@ test('legacy active and null driver and vehicle statuses normalize to available'
24
27
 
25
28
  $vehicle->status = null;
26
29
  expect($vehicle->status)->toBe('available');
30
+
31
+ $equipment = new Equipment();
32
+ $equipment->status = 'active';
33
+ expect($equipment->status)->toBe('available');
34
+
35
+ $equipment->status = null;
36
+ expect($equipment->status)->toBe('available');
37
+ });
38
+
39
+ test('equipment preserves explicit non default status values', function () {
40
+ $equipment = new Equipment();
41
+ $equipment->status = 'maintenance';
42
+
43
+ expect($equipment->status)->toBe('maintenance');
27
44
  });
28
45
 
29
46
  test('dispatch driver availability queries use available status', function () {
@@ -0,0 +1,35 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Equipment;
5
+ use Fleetbase\FleetOps\Models\Vehicle;
6
+ use Illuminate\Database\Eloquent\Relations\Relation;
7
+
8
+ test('equipment assignment type aliases normalize to model classes', function () {
9
+ $equipment = new Equipment();
10
+
11
+ $equipment->equipable_type = 'fleet-ops:vehicle';
12
+ expect($equipment->equipable_type)->toBe(Vehicle::class);
13
+
14
+ $equipment->equipable_type = 'fleet-ops:driver';
15
+ expect($equipment->equipable_type)->toBe(Driver::class);
16
+ });
17
+
18
+ test('equipment assignment type preserves null and existing model classes', function () {
19
+ $equipment = new Equipment();
20
+
21
+ $equipment->equipable_type = null;
22
+ expect($equipment->equipable_type)->toBeNull();
23
+
24
+ $equipment->equipable_type = Vehicle::class;
25
+ expect($equipment->equipable_type)->toBe(Vehicle::class);
26
+ });
27
+
28
+ test('legacy equipment assignment aliases are registered for morph reads', function () {
29
+ new Equipment();
30
+
31
+ expect(Relation::getMorphedModel('fleet-ops:vehicle'))
32
+ ->toBe(Vehicle::class)
33
+ ->and(Relation::getMorphedModel('fleet-ops:driver'))
34
+ ->toBe(Driver::class);
35
+ });
@@ -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
+ });