@fleetbase/fleetops-engine 0.6.55 → 0.6.57

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 (161) hide show
  1. package/addon/components/admin/map-settings.hbs +1 -1
  2. package/addon/components/ai/create-order-preview.hbs +297 -0
  3. package/addon/components/ai/create-order-preview.js +362 -0
  4. package/addon/components/ai/route-preview-map.hbs +79 -0
  5. package/addon/components/ai/route-preview-map.js +527 -0
  6. package/addon/components/device/details.js +1 -1
  7. package/addon/components/device-event/details.hbs +192 -0
  8. package/addon/components/device-event/details.js +151 -0
  9. package/addon/components/driver/schedule.js +1 -1
  10. package/addon/components/fleet/details.hbs +21 -8
  11. package/addon/components/fleet/details.js +55 -1
  12. package/addon/components/fleet/hierarchy-tree.hbs +137 -0
  13. package/addon/components/fleet/hierarchy-tree.js +528 -0
  14. package/addon/components/layout/fleet-ops-sidebar.js +15 -9
  15. package/addon/components/map/drawer/device-event-listing.js +1 -0
  16. package/addon/components/map/drawer/driver-listing.js +1 -0
  17. package/addon/components/map/drawer/place-listing.js +1 -0
  18. package/addon/components/map/drawer/vehicle-listing.js +1 -0
  19. package/addon/components/order/details.hbs +34 -1
  20. package/addon/components/order/form/details.hbs +4 -0
  21. package/addon/components/order/form.hbs +9 -1
  22. package/addon/components/telematic/details.hbs +1 -1
  23. package/addon/components/telematic/form.hbs +2 -2
  24. package/addon/components/telematic/settings.hbs +1 -1
  25. package/addon/components/vehicle/card.hbs +14 -2
  26. package/addon/components/vehicle/form.hbs +13 -1
  27. package/addon/controllers/connectivity/devices/index/details/events.js +1 -1
  28. package/addon/controllers/connectivity/devices/index/details/vehicle.js +2 -2
  29. package/addon/controllers/connectivity/events/details.js +68 -1
  30. package/addon/controllers/connectivity/sensors/index.js +4 -0
  31. package/addon/controllers/connectivity/telematics/details/events.js +1 -1
  32. package/addon/controllers/connectivity/telematics/details/sensors.js +4 -0
  33. package/addon/controllers/settings/map.js +19 -0
  34. package/addon/extension.js +14 -0
  35. package/addon/instance-initializers/register-vroom-allocation.js +1 -1
  36. package/addon/routes/connectivity/events/details.js +26 -1
  37. package/addon/routes/management/fleets/index/details.js +1 -1
  38. package/addon/routes/management/vehicles/index/details.js +3 -1
  39. package/addon/services/map-adapter/google.js +66 -1
  40. package/addon/services/map-adapter/leaflet.js +1 -1
  41. package/addon/services/map-adapter-interface.js +1 -1
  42. package/addon/services/map-manager.js +16 -1
  43. package/addon/services/map-settings.js +15 -0
  44. package/addon/styles/fleetops-engine.css +487 -1
  45. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +2 -1
  46. package/addon/templates/connectivity/events/details.hbs +40 -1
  47. package/addon/templates/settings/map.hbs +38 -9
  48. package/addon/utils/leaflet-plugin-loader.js +5 -5
  49. package/addon/utils/map-drawer-dropdown-position.js +11 -13
  50. package/addon/utils/route-colors.js +1 -1
  51. package/app/components/ai/create-order-preview.js +1 -0
  52. package/app/components/ai/route-preview-map.js +1 -0
  53. package/app/components/device-event/details.hbs +192 -0
  54. package/app/components/device-event/details.js +1 -0
  55. package/app/components/fleet/hierarchy-tree.js +1 -0
  56. package/app/services/route-engine.js +1 -0
  57. package/composer.json +1 -1
  58. package/extension.json +1 -1
  59. package/package.json +6 -4
  60. package/scripts/normalize-rgba-alpha.js +90 -0
  61. package/server/migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php +34 -0
  62. package/server/src/Exports/ContactExport.php +12 -5
  63. package/server/src/Exports/DeviceExport.php +92 -0
  64. package/server/src/Exports/DriverExport.php +28 -3
  65. package/server/src/Exports/EquipmentExport.php +94 -0
  66. package/server/src/Exports/FleetExport.php +22 -7
  67. package/server/src/Exports/FuelReportExport.php +16 -1
  68. package/server/src/Exports/IssueExport.php +25 -3
  69. package/server/src/Exports/MaintenanceExport.php +106 -0
  70. package/server/src/Exports/MaintenanceScheduleExport.php +99 -0
  71. package/server/src/Exports/OrderExport.php +24 -5
  72. package/server/src/Exports/PartExport.php +99 -0
  73. package/server/src/Exports/PlaceExport.php +33 -7
  74. package/server/src/Exports/SensorExport.php +94 -0
  75. package/server/src/Exports/ServiceAreaExport.php +31 -7
  76. package/server/src/Exports/ServiceRateExport.php +41 -1
  77. package/server/src/Exports/TelematicExport.php +90 -0
  78. package/server/src/Exports/VehicleExport.php +274 -8
  79. package/server/src/Exports/VendorExport.php +15 -2
  80. package/server/src/Exports/WorkOrderExport.php +92 -0
  81. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +176 -0
  82. package/server/src/Http/Controllers/Api/v1/DeviceController.php +225 -0
  83. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +114 -0
  84. package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +185 -0
  85. package/server/src/Http/Controllers/Api/v1/PartController.php +120 -0
  86. package/server/src/Http/Controllers/Api/v1/SensorController.php +132 -0
  87. package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +161 -0
  88. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +18 -0
  89. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +17 -0
  90. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +17 -0
  91. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +17 -0
  92. package/server/src/Http/Controllers/Internal/v1/PartController.php +17 -0
  93. package/server/src/Http/Controllers/Internal/v1/SensorController.php +18 -0
  94. package/server/src/Http/Controllers/Internal/v1/SettingController.php +29 -5
  95. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +18 -0
  96. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +17 -0
  97. package/server/src/Http/Filter/DeviceFilter.php +33 -10
  98. package/server/src/Http/Filter/EquipmentFilter.php +23 -0
  99. package/server/src/Http/Filter/FuelProviderTransactionFilter.php +57 -2
  100. package/server/src/Http/Filter/PartFilter.php +47 -0
  101. package/server/src/Http/Filter/SensorFilter.php +34 -6
  102. package/server/src/Http/Filter/WorkOrderFilter.php +23 -0
  103. package/server/src/Http/Requests/CreateDeviceRequest.php +51 -0
  104. package/server/src/Http/Requests/CreateEquipmentRequest.php +35 -0
  105. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +50 -0
  106. package/server/src/Http/Requests/CreatePartRequest.php +40 -0
  107. package/server/src/Http/Requests/CreateSensorRequest.php +47 -0
  108. package/server/src/Http/Requests/CreateWorkOrderRequest.php +42 -0
  109. package/server/src/Http/Requests/UpdateDeviceRequest.php +7 -0
  110. package/server/src/Http/Requests/UpdateEquipmentRequest.php +7 -0
  111. package/server/src/Http/Requests/UpdateFuelTransactionRequest.php +7 -0
  112. package/server/src/Http/Requests/UpdatePartRequest.php +7 -0
  113. package/server/src/Http/Requests/UpdateSensorRequest.php +7 -0
  114. package/server/src/Http/Requests/UpdateWorkOrderRequest.php +7 -0
  115. package/server/src/Http/Resources/v1/Device.php +72 -0
  116. package/server/src/Http/Resources/v1/Equipment.php +57 -0
  117. package/server/src/Http/Resources/v1/Fleet.php +8 -2
  118. package/server/src/Http/Resources/v1/FuelTransaction.php +69 -0
  119. package/server/src/Http/Resources/v1/Part.php +64 -0
  120. package/server/src/Http/Resources/v1/Sensor.php +68 -0
  121. package/server/src/Jobs/SyncTelematicDevicesJob.php +194 -38
  122. package/server/src/Models/Driver.php +6 -0
  123. package/server/src/Models/Order.php +20 -7
  124. package/server/src/Models/Place.php +11 -3
  125. package/server/src/Observers/PurchaseRateObserver.php +2 -1
  126. package/server/src/Providers/FleetOpsServiceProvider.php +26 -0
  127. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +88 -0
  128. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +108 -0
  129. package/server/src/Support/Ai/Capabilities/ConsoleNavigationCapability.php +55 -0
  130. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +508 -0
  131. package/server/src/Support/Ai/Capabilities/DocsHelpCapability.php +49 -0
  132. package/server/src/Support/Ai/Capabilities/ImportOrdersPreviewCapability.php +66 -0
  133. package/server/src/Support/Ai/Capabilities/OperationalQueryCapability.php +319 -0
  134. package/server/src/Support/Ai/Capabilities/OptimizeOrderRouteCapability.php +240 -0
  135. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +95 -0
  136. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +195 -0
  137. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +150 -0
  138. package/server/src/Support/Analytics/RevenueTrend.php +3 -8
  139. package/server/src/Support/Metrics/ActiveRevenueQuery.php +121 -0
  140. package/server/src/Support/Metrics/AvgOrderValueMetric.php +1 -7
  141. package/server/src/Support/Metrics/EarningsMetric.php +1 -10
  142. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +104 -5
  143. package/server/src/Support/Telematics/Providers/SafeeProvider.php +550 -54
  144. package/server/src/Support/Telematics/TelematicService.php +28 -9
  145. package/server/src/Support/Utils.php +52 -0
  146. package/server/src/routes.php +67 -14
  147. package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
  148. package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
  149. package/server/tests/ExportCoverageTest.php +79 -0
  150. package/server/tests/FleetHierarchyResourceTest.php +12 -0
  151. package/server/tests/MetricsRegistryTest.php +45 -5
  152. package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
  153. package/server/tests/PointResolutionTest.php +63 -0
  154. package/server/tests/PublicFleetOpsRestEndpointsTest.php +191 -0
  155. package/server/tests/TelematicsHardeningTest.php +636 -1
  156. package/addon/components/table/cell/dropdown/action-item.hbs +0 -31
  157. package/addon/components/table/cell/dropdown/action-item.js +0 -62
  158. package/addon/components/table/cell/dropdown.hbs +0 -28
  159. package/addon/components/table/cell/dropdown.js +0 -91
  160. package/app/components/table/cell/dropdown/action-item.js +0 -1
  161. package/app/components/table/cell/dropdown.js +0 -1
@@ -1,12 +1,14 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Contracts\TelematicProviderDescriptor;
4
+ use Fleetbase\FleetOps\Models\Telematic;
4
5
  use Fleetbase\FleetOps\Support\Telematics\Providers\AfaqyProvider;
5
6
  use Fleetbase\FleetOps\Support\Telematics\Providers\FlespiProvider;
6
7
  use Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider;
7
8
  use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
8
9
  use Fleetbase\FleetOps\Support\Telematics\Providers\SamsaraProvider;
9
10
  use Illuminate\Http\Client\ConnectionException;
11
+ use Illuminate\Support\Carbon;
10
12
  use Illuminate\Support\Facades\Http;
11
13
 
12
14
  test('device event model and migration expose lifecycle fields used by telematics workflows', function () {
@@ -54,6 +56,7 @@ test('telematics service requires provider identity and stores idempotent event
54
56
  expect($service)
55
57
  ->toContain('Provider device identity is required to link a telematics device.')
56
58
  ->toContain('public function ingestDeviceSnapshot')
59
+ ->toContain('normalizeEvents')
57
60
  ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])')
58
61
  ->toContain('reconcileDeviceTelemetry')
59
62
  ->toContain('PROTECTED_DEVICE_STATUSES')
@@ -63,9 +66,19 @@ test('telematics service requires provider identity and stores idempotent event
63
66
  ->toContain('applyDeviceEventTelemetry')
64
67
  ->toContain('$event->createPosition($positionData)')
65
68
  ->toContain('updateVehicleTelemetry')
69
+ ->toContain('$vehicle->odometer = $eventData[\'odometer\'];')
66
70
  ->toContain('broadcast(new VehicleLocationChanged')
71
+ ->toContain("'heading' => \$eventData['heading'] ?? null")
72
+ ->toContain("'bearing' => \$eventData['heading'] ?? null")
73
+ ->toContain("'speed' => \$eventData['speed'] ?? null")
74
+ ->toContain("'altitude' => \$eventData['altitude'] ?? null")
67
75
  ->toContain('storeSnapshotSensors')
68
76
  ->toContain("\$payload['sensors'] ?? \$payload['sensors_last_val']")
77
+ ->toContain("'sensor_key' => \$name")
78
+ ->toContain('Sensor::firstOrNew')
79
+ ->toContain("'telematic_uuid' => \$telematic->uuid")
80
+ ->toContain("'device_uuid' => \$device?->uuid")
81
+ ->toContain("'internal_id' => \$sensorIdentity ?? \$this->makeSensorIdentity")
69
82
  ->toContain('protected function makeEventKey')
70
83
  ->toContain('$telematic->public_id ?? $telematic->uuid')
71
84
  ->toContain('resolveWebhookTelematic')
@@ -191,7 +204,6 @@ test('safee telemetry includes online and altitude event fields', function () {
191
204
  ]);
192
205
 
193
206
  expect($event)->toMatchArray([
194
- 'external_id' => 'event-1',
195
207
  'device_id' => 'device-1',
196
208
  'online' => true,
197
209
  'location' => ['lat' => 25.2048, 'lng' => 55.2708],
@@ -199,6 +211,561 @@ test('safee telemetry includes online and altitude event fields', function () {
199
211
  'heading' => 100,
200
212
  'altitude' => 15,
201
213
  ]);
214
+ expect($event['external_id'])->toContain('event-1');
215
+ });
216
+
217
+ test('safee sync returns inventory first then enriches with last info positions and events', function () {
218
+ Carbon::setTestNow(Carbon::parse('2026-06-23T09:15:40Z'));
219
+ $requests = [];
220
+
221
+ Http::fake(function ($request) use (&$requests) {
222
+ $requests[] = $request;
223
+ $path = parse_url($request->url(), PHP_URL_PATH);
224
+ $body = $request->data();
225
+
226
+ return match ($path) {
227
+ '/api/v2/vehicle/list-info' => Http::response([
228
+ 'code' => 0,
229
+ 'result' => [
230
+ [
231
+ 'id' => 105,
232
+ 'uuid' => 'vehicle-uuid-105',
233
+ 'plateNo' => 'ABC-1234',
234
+ 'driver' => ['id' => 2, 'name' => 'Ahmad'],
235
+ 'device' => ['id' => 315, 'imei' => 'imei-315', 'serial' => 'serial-315'],
236
+ ],
237
+ ],
238
+ ], 200),
239
+ '/api/v2/vehicle/last-state' => Http::response([
240
+ 'code' => 0,
241
+ 'result' => [
242
+ [
243
+ 'id' => 105,
244
+ 'plateNo' => 'ABC-1234',
245
+ 'date' => 1782206130.5,
246
+ 'speed' => 41,
247
+ 'heading' => 90,
248
+ 'status' => 'moving',
249
+ 'position' => ['lat' => 25.1, 'lon' => 55.1, 'alt' => 10],
250
+ 'event' => ['id' => 2, 'code' => 'ignition_on', 'name' => 'Ignition On'],
251
+ ],
252
+ ],
253
+ ], 200),
254
+ '/api/v2/vehicle/last-info' => Http::response([
255
+ 'code' => 0,
256
+ 'result' => [
257
+ 'id' => 105,
258
+ 'plateNo' => 'ABC-1234',
259
+ 'date' => 1782206140.5,
260
+ 'speed' => 45,
261
+ 'heading' => 100,
262
+ 'status' => 'moving',
263
+ 'position' => ['lat' => 25.2, 'lon' => 55.2, 'alt' => 12],
264
+ 'event' => ['id' => 3, 'code' => 'over_speed', 'name' => 'Over Speed'],
265
+ 'odometer' => 1232.4,
266
+ 'temperature' => ['Temp Sensor 1' => 36.8],
267
+ 'door' => ['Door1' => 'Open'],
268
+ 'humidity' => ['Humidity Sensor 1' => 44.2],
269
+ ],
270
+ ], 200),
271
+ '/api/v2/vehicle/positions' => Http::response([
272
+ 'code' => 0,
273
+ 'result' => [
274
+ [
275
+ 'id' => 501,
276
+ 'plateNo' => 'ABC-1234',
277
+ 'date' => 1782206100.5,
278
+ 'speed' => 35,
279
+ 'heading' => 80,
280
+ 'status' => 'moving',
281
+ 'position' => ['lat' => 25.0, 'lon' => 55.0, 'alt' => 8],
282
+ 'event' => ['id' => 2, 'code' => 'ignition_on', 'name' => 'Ignition On'],
283
+ ],
284
+ ],
285
+ ], 200),
286
+ '/api/v2/vehicle/events' => Http::response([
287
+ 'code' => 0,
288
+ 'result' => [
289
+ [
290
+ 'id' => 601,
291
+ 'plateNo' => 'ABC-1234',
292
+ 'reason' => 'External Power Disconnected',
293
+ 'date' => 1782206110.5,
294
+ 'speed' => 12,
295
+ 'heading' => 20,
296
+ 'status' => 'moving',
297
+ 'position' => ['lat' => 25.05, 'lon' => 55.05, 'alt' => 9],
298
+ 'type' => ['key' => '1212', 'value' => 'External Power Disconnected'],
299
+ 'vehicle' => ['id' => 105, 'name' => 'ABC-1234'],
300
+ 'event' => ['id' => 4, 'code' => 'external_power_disconnected', 'name' => 'External Power Disconnected'],
301
+ 'arguments' => [['name' => 'Speed', 'type' => 'double', 'value' => '12']],
302
+ ],
303
+ ],
304
+ ], 200),
305
+ default => Http::response(['code' => 0, 'result' => []], 200),
306
+ };
307
+ });
308
+
309
+ $telematic = new Telematic();
310
+ $telematic->meta = [];
311
+
312
+ $provider = new class extends SafeeProvider {
313
+ public function fetchDevicesForTest(Telematic $telematic): array
314
+ {
315
+ $this->telematic = $telematic;
316
+ $this->baseUrl = 'https://fms.example.test';
317
+ $this->headers = ['Authorization' => 'Bearer testing-token'];
318
+
319
+ return $this->fetchDevices();
320
+ }
321
+
322
+ public function fetchTelemetryForTest(array $devices): array
323
+ {
324
+ return $this->fetchDeviceTelemetrySnapshots($devices);
325
+ }
326
+ };
327
+
328
+ $inventoryResult = $provider->fetchDevicesForTest($telematic);
329
+ $inventoryDevice = $inventoryResult['devices'][0];
330
+
331
+ expect($inventoryResult['devices'])->toHaveCount(1);
332
+ expect($inventoryDevice['_safee']['identity']['plateNo'])->toBe('ABC-1234');
333
+ expect($inventoryDevice['_safee']['current_info'])->toBeNull();
334
+ expect($inventoryDevice['_safee']['current_state'])->toMatchArray([
335
+ 'id' => 105,
336
+ 'status' => 'moving',
337
+ 'speed' => 45,
338
+ 'heading' => 100,
339
+ 'position' => ['lat' => 25.2, 'lon' => 55.2, 'alt' => 12],
340
+ ]);
341
+ expect($inventoryDevice['_safee']['positions'])->toBe([]);
342
+ expect($inventoryDevice['_safee']['events'])->toBe([]);
343
+ expect($inventoryDevice['sensors'])->toBe([]);
344
+ expect(data_get($inventoryResult, 'sync_meta.safee_last_endpoint_counts'))->toMatchArray([
345
+ 'vehicles_listed' => 1,
346
+ 'unique_vehicle_ids' => 1,
347
+ 'missing_vehicle_ids' => 0,
348
+ 'duplicate_vehicle_ids' => [],
349
+ 'devices_returned_for_ingestion' => 1,
350
+ 'list_info_page_size' => 0,
351
+ 'list_info_requested_unpaginated' => true,
352
+ 'last_state_fetched' => 1,
353
+ 'last_info_fetched' => 0,
354
+ 'positions_fetched' => 0,
355
+ 'events_fetched' => 0,
356
+ ]);
357
+
358
+ expect(array_map(fn ($request) => parse_url($request->url(), PHP_URL_PATH), $requests))->toBe([
359
+ '/api/v2/vehicle/list-info',
360
+ '/api/v2/vehicle/last-state',
361
+ ]);
362
+
363
+ $result = $provider->fetchTelemetryForTest($inventoryResult['devices']);
364
+ $device = $result['devices'][0];
365
+
366
+ expect($result['sync_meta'])->toMatchArray([
367
+ 'safee_last_telemetry_synced_at' => 1782206140.0,
368
+ 'safee_last_enrichment_total' => 1,
369
+ 'safee_last_enrichment_completed' => 1,
370
+ 'safee_last_enrichment_failures' => [],
371
+ ]);
372
+ expect(data_get($result, 'sync_meta.safee_last_sync_window'))->toMatchArray([
373
+ 'startDate' => 1782205240.0,
374
+ 'endDate' => 1782206140.0,
375
+ ]);
376
+ expect($device['_safee']['identity']['plateNo'])->toBe('ABC-1234');
377
+ expect($device['_safee']['current_info']['odometer'])->toBe(1232.4);
378
+ expect($device['_safee']['positions'])->toHaveCount(1);
379
+ expect($device['_safee']['events'])->toHaveCount(1);
380
+ expect($device['sensors'])->toHaveCount(3);
381
+ expect($device['sensors'][0])->toMatchArray([
382
+ 'internal_id' => 'safee:105:temperature:Temp Sensor 1',
383
+ 'name' => 'Temp Sensor 1',
384
+ 'type' => 'temperature',
385
+ 'value' => 36.8,
386
+ ]);
387
+ expect($device['sensors'][1])->toMatchArray([
388
+ 'internal_id' => 'safee:105:door:Door1',
389
+ 'name' => 'Door1',
390
+ 'type' => 'door',
391
+ 'value' => 'Open',
392
+ ]);
393
+ expect($device['sensors'][2])->toMatchArray([
394
+ 'internal_id' => 'safee:105:humidity:Humidity Sensor 1',
395
+ 'name' => 'Humidity Sensor 1',
396
+ 'type' => 'humidity',
397
+ 'value' => 44.2,
398
+ ]);
399
+
400
+ $paths = array_map(fn ($request) => parse_url($request->url(), PHP_URL_PATH), $requests);
401
+ expect($paths)->toBe([
402
+ '/api/v2/vehicle/list-info',
403
+ '/api/v2/vehicle/last-state',
404
+ '/api/v2/vehicle/last-info',
405
+ '/api/v2/vehicle/positions',
406
+ '/api/v2/vehicle/events',
407
+ ]);
408
+
409
+ expect($requests[0]->data())->toMatchArray(['pageSize' => 0]);
410
+ expect($requests[1]->data())->toMatchArray([
411
+ 'live' => true,
412
+ 'startDate' => null,
413
+ 'endDate' => null,
414
+ 'vehicles' => [105],
415
+ ]);
416
+ expect($requests[2]->data())->toMatchArray(['vehicleId' => 105]);
417
+ expect($requests[3]->data())->toMatchArray(['vehicleId' => 105]);
418
+ expect($requests[4]->data())->toMatchArray(['vehicleId' => 105, 'status' => 'ALL']);
419
+
420
+ Carbon::setTestNow();
421
+ });
422
+
423
+ test('safee list info requests all vehicles and preserves filters', function () {
424
+ Carbon::setTestNow(Carbon::parse('2026-06-23T09:15:40Z'));
425
+ $requests = [];
426
+
427
+ Http::fake(function ($request) use (&$requests) {
428
+ $requests[] = $request;
429
+ $path = parse_url($request->url(), PHP_URL_PATH);
430
+
431
+ if ($path === '/api/v2/vehicle/list-info') {
432
+ return Http::response([
433
+ 'code' => 0,
434
+ 'result' => array_map(fn ($id) => [
435
+ 'id' => $id,
436
+ 'plateNo' => 'SAFE-' . $id,
437
+ 'device' => ['id' => 'device-' . $id],
438
+ ], range(101, 107)),
439
+ ], 200);
440
+ }
441
+
442
+ return Http::response(['code' => 0, 'result' => []], 200);
443
+ });
444
+
445
+ $telematic = new Telematic();
446
+ $telematic->meta = [];
447
+
448
+ $provider = new class extends SafeeProvider {
449
+ public function fetchDevicesForTest(Telematic $telematic): array
450
+ {
451
+ $this->telematic = $telematic;
452
+ $this->baseUrl = 'https://fms.example.test';
453
+ $this->headers = ['Authorization' => 'Bearer testing-token'];
454
+
455
+ return $this->fetchDevices([
456
+ 'filter' => [
457
+ 'plateNo' => 'SAFE',
458
+ ],
459
+ ]);
460
+ }
461
+ };
462
+
463
+ $result = $provider->fetchDevicesForTest($telematic);
464
+
465
+ expect($result['devices'])->toHaveCount(7);
466
+ expect($requests)->toHaveCount(2);
467
+ expect($requests[0]->data())->toMatchArray([
468
+ 'plateNo' => 'SAFE',
469
+ 'pageSize' => 0,
470
+ ]);
471
+ expect($requests[1]->data())->toMatchArray([
472
+ 'vehicles' => range(101, 107),
473
+ ]);
474
+ expect(data_get($result, 'sync_meta.safee_last_endpoint_counts'))->toMatchArray([
475
+ 'vehicles_listed' => 7,
476
+ 'unique_vehicle_ids' => 7,
477
+ 'missing_vehicle_ids' => 0,
478
+ 'duplicate_vehicle_ids' => [],
479
+ 'devices_returned_for_ingestion' => 7,
480
+ 'list_info_page_size' => 0,
481
+ 'list_info_requested_unpaginated' => true,
482
+ ]);
483
+
484
+ Carbon::setTestNow();
485
+ });
486
+
487
+ test('safee list info reports duplicate and missing vehicle identities', function () {
488
+ Carbon::setTestNow(Carbon::parse('2026-06-23T09:15:40Z'));
489
+ $requests = [];
490
+
491
+ Http::fake(function ($request) use (&$requests) {
492
+ $requests[] = $request;
493
+ $path = parse_url($request->url(), PHP_URL_PATH);
494
+
495
+ if ($path === '/api/v2/vehicle/list-info') {
496
+ return Http::response([
497
+ 'code' => 0,
498
+ 'result' => [
499
+ ['id' => 105, 'plateNo' => 'SAFE-105'],
500
+ ['id' => 105, 'plateNo' => 'SAFE-105-DUP'],
501
+ ['id' => 106, 'plateNo' => 'SAFE-106'],
502
+ ['plateNo' => 'SAFE-MISSING'],
503
+ ],
504
+ ], 200);
505
+ }
506
+
507
+ return Http::response(['code' => 0, 'result' => []], 200);
508
+ });
509
+
510
+ $telematic = new Telematic();
511
+ $telematic->meta = [];
512
+
513
+ $provider = new class extends SafeeProvider {
514
+ public function fetchDevicesForTest(Telematic $telematic): array
515
+ {
516
+ $this->telematic = $telematic;
517
+ $this->baseUrl = 'https://fms.example.test';
518
+ $this->headers = ['Authorization' => 'Bearer testing-token'];
519
+
520
+ return $this->fetchDevices();
521
+ }
522
+ };
523
+
524
+ $result = $provider->fetchDevicesForTest($telematic);
525
+
526
+ expect($result['devices'])->toHaveCount(4);
527
+ expect($requests)->toHaveCount(2);
528
+ expect($requests[1]->data())->toMatchArray([
529
+ 'vehicles' => [105, 106],
530
+ ]);
531
+ expect(data_get($result, 'sync_meta.safee_last_endpoint_counts'))->toMatchArray([
532
+ 'vehicles_listed' => 4,
533
+ 'unique_vehicle_ids' => 2,
534
+ 'missing_vehicle_ids' => 1,
535
+ 'duplicate_vehicle_ids' => ['105' => 2],
536
+ 'devices_returned_for_ingestion' => 4,
537
+ ]);
538
+
539
+ Carbon::setTestNow();
540
+ });
541
+
542
+ test('telematics sync job counts unique persisted devices separately from link attempts', function () {
543
+ $job = file_get_contents(__DIR__ . '/../src/Jobs/SyncTelematicDevicesJob.php');
544
+
545
+ expect($job)
546
+ ->toContain('public int $timeout = 3600')
547
+ ->toContain('public bool $failOnTimeout = true')
548
+ ->toContain('$linkedDeviceKeys')
549
+ ->toContain('resolveLinkedDeviceKey($result, $normalizedDevice)')
550
+ ->toContain('fetchDeviceTelemetrySnapshots')
551
+ ->toContain('Safee inventory sync completed')
552
+ ->toContain('last_sync_link_attempts_total')
553
+ ->toContain('last_sync_inventory_linked_total')
554
+ ->toContain('last_sync_enrichment_completed')
555
+ ->toContain('last_sync_failed_reason')
556
+ ->toContain('last_sync_linked_total');
557
+ });
558
+
559
+ test('safee vehicle id resolution tolerates missing id fields', function () {
560
+ $events = (new SafeeProvider())->normalizeEvents([
561
+ '_safee' => [
562
+ 'identity' => ['plateNo' => 'NO-ID'],
563
+ 'current_info' => ['status' => 'offline'],
564
+ ],
565
+ ]);
566
+
567
+ expect($events)->toHaveCount(1);
568
+ expect($events[0]['device_id'])->toBeNull();
569
+ });
570
+
571
+ test('safee current telemetry merges last state with last info fallback fields', function () {
572
+ $device = (new SafeeProvider())->normalizeDevice([
573
+ 'id' => 105,
574
+ 'plateNo' => 'ABC-1234',
575
+ '_safee' => [
576
+ 'vehicle_id' => 105,
577
+ 'identity' => ['id' => 105, 'plateNo' => 'ABC-1234'],
578
+ 'current_state' => [
579
+ 'id' => 105,
580
+ 'date' => 1782206120.5,
581
+ 'status' => 'offline',
582
+ 'speed' => 12,
583
+ 'heading' => 30,
584
+ 'position' => ['lat' => 25.1, 'lon' => 55.1, 'alt' => 5],
585
+ ],
586
+ 'current_info' => [
587
+ 'odometer' => 1234.5,
588
+ ],
589
+ ],
590
+ ]);
591
+
592
+ expect($device)->toMatchArray([
593
+ 'device_id' => 105,
594
+ 'status' => 'inactive',
595
+ 'online' => false,
596
+ 'last_seen_at' => Carbon::createFromTimestamp(1782206120.5)->toDateTimeString(),
597
+ 'location' => ['lat' => 25.1, 'lng' => 55.1],
598
+ 'speed' => 12,
599
+ 'heading' => 30,
600
+ 'altitude' => 5,
601
+ 'odometer' => 1234.5,
602
+ ]);
603
+ });
604
+
605
+ test('safee normalizes documented vehicle identity current telemetry positions and events', function () {
606
+ $provider = new SafeeProvider();
607
+ $payload = [
608
+ 'id' => 105,
609
+ 'uuid' => 'vehicle-uuid-105',
610
+ 'plateNo' => 'ABC-1234',
611
+ 'driver' => ['id' => 2, 'name' => 'Ahmad'],
612
+ 'device' => ['id' => 315, 'imei' => 'imei-315', 'serial' => 'serial-315'],
613
+ '_safee' => [
614
+ 'identity' => [
615
+ 'id' => 105,
616
+ 'uuid' => 'vehicle-uuid-105',
617
+ 'plateNo' => 'ABC-1234',
618
+ 'device' => ['id' => 315, 'imei' => 'imei-315', 'serial' => 'serial-315'],
619
+ ],
620
+ 'current_state' => [
621
+ 'id' => 105,
622
+ 'date' => 1782206120.5,
623
+ 'status' => 'moving',
624
+ 'speed' => 22,
625
+ 'heading' => 45,
626
+ 'position' => ['lat' => 24.9, 'lon' => 54.9, 'alt' => 7],
627
+ ],
628
+ 'current_info' => [
629
+ 'id' => 999,
630
+ 'plateNo' => 'ABC-1234',
631
+ 'date' => 1782206140.5,
632
+ 'speed' => 45,
633
+ 'heading' => 100,
634
+ 'status' => 'moving',
635
+ 'position' => ['lat' => 25.2, 'lon' => 55.2, 'alt' => 12],
636
+ 'vehicle' => ['id' => 6],
637
+ 'event' => ['id' => 3, 'code' => 'over_speed', 'name' => 'Over Speed'],
638
+ 'odometer' => 1232.4,
639
+ 'temperature' => ['Temp Sensor 1' => 36.8],
640
+ 'door' => ['Door1' => 'Open'],
641
+ 'humidity' => ['Humidity Sensor 1' => 44.2],
642
+ ],
643
+ 'positions' => [
644
+ [
645
+ 'id' => 501,
646
+ 'plateNo' => 'ABC-1234',
647
+ 'date' => 1782206100.5,
648
+ 'speed' => 35,
649
+ 'heading' => 80,
650
+ 'status' => 'moving',
651
+ 'position' => ['lat' => 25.0, 'lon' => 55.0, 'alt' => 8],
652
+ 'event' => ['id' => 2, 'code' => 'ignition_on', 'name' => 'Ignition On'],
653
+ ],
654
+ ],
655
+ 'events' => [
656
+ [
657
+ 'id' => 601,
658
+ 'plateNo' => 'ABC-1234',
659
+ 'reason' => 'External Power Disconnected',
660
+ 'date' => 1782206110.5,
661
+ 'speed' => 12,
662
+ 'heading' => 20,
663
+ 'status' => 'moving',
664
+ 'position' => ['lat' => 25.05, 'lon' => 55.05, 'alt' => 9],
665
+ 'type' => ['key' => '1212', 'value' => 'External Power Disconnected'],
666
+ 'vehicle' => ['id' => 105, 'name' => 'ABC-1234'],
667
+ 'event' => ['id' => 4, 'code' => 'external_power_disconnected', 'name' => 'External Power Disconnected'],
668
+ 'arguments' => [['name' => 'Speed', 'type' => 'double', 'value' => '12']],
669
+ ],
670
+ ],
671
+ ],
672
+ ];
673
+
674
+ $device = $provider->normalizeDevice($payload);
675
+ $events = $provider->normalizeEvents($payload);
676
+
677
+ expect($device)->toMatchArray([
678
+ 'device_id' => 105,
679
+ 'external_id' => 105,
680
+ 'name' => 'ABC-1234',
681
+ 'provider' => 'safee',
682
+ 'internal_id' => 'vehicle-uuid-105',
683
+ 'imei' => 'imei-315',
684
+ 'serial_number' => 'serial-315',
685
+ 'status' => 'active',
686
+ 'online' => true,
687
+ 'location' => ['lat' => 25.2, 'lng' => 55.2],
688
+ 'speed' => 45,
689
+ 'heading' => 100,
690
+ 'altitude' => 12,
691
+ 'odometer' => 1232.4,
692
+ ]);
693
+ expect($device['meta'])->toMatchArray([
694
+ 'plate_number' => 'ABC-1234',
695
+ 'temperature' => ['Temp Sensor 1' => 36.8],
696
+ 'door' => ['Door1' => 'Open'],
697
+ 'humidity' => ['Humidity Sensor 1' => 44.2],
698
+ ]);
699
+
700
+ expect($events)->toHaveCount(3);
701
+ expect($events[0])->toMatchArray([
702
+ 'device_id' => 105,
703
+ 'event_type' => 'over_speed',
704
+ 'code' => 'over_speed',
705
+ 'message' => 'Over Speed',
706
+ 'location' => ['lat' => 25.2, 'lng' => 55.2],
707
+ 'speed' => 45,
708
+ 'heading' => 100,
709
+ 'altitude' => 12,
710
+ 'odometer' => 1232.4,
711
+ ]);
712
+ expect($events[0]['data'])->toMatchArray([
713
+ 'temperature' => ['Temp Sensor 1' => 36.8],
714
+ 'door' => ['Door1' => 'Open'],
715
+ 'humidity' => ['Humidity Sensor 1' => 44.2],
716
+ ]);
717
+ expect($events[1])->toMatchArray([
718
+ 'event_type' => 'ignition_on',
719
+ 'location' => ['lat' => 25.0, 'lng' => 55.0],
720
+ 'altitude' => 8,
721
+ ]);
722
+ expect($events[2])->toMatchArray([
723
+ 'event_type' => 'external_power_disconnected',
724
+ 'reason' => 'External Power Disconnected',
725
+ 'location' => ['lat' => 25.05, 'lng' => 55.05],
726
+ 'altitude' => 9,
727
+ ]);
728
+ });
729
+
730
+ test('safee sensors normalize stable parent scoped identities and latest values', function () {
731
+ $provider = new SafeeProvider();
732
+
733
+ $open = $provider->normalizeSensor([
734
+ 'internal_id' => 'safee:105:door:Door1',
735
+ 'name' => 'Door1',
736
+ 'type' => 'door',
737
+ 'value' => 'Open',
738
+ 'recorded_at' => 1782206140.5,
739
+ 'vehicle_id' => 105,
740
+ 'plate_no' => 'ABC-1234',
741
+ 'source' => 'door',
742
+ ]);
743
+
744
+ $closed = $provider->normalizeSensor([
745
+ 'internal_id' => 'safee:105:door:Door1',
746
+ 'name' => 'Door1',
747
+ 'type' => 'door',
748
+ 'value' => 'Closed',
749
+ 'recorded_at' => 1782206200.5,
750
+ 'vehicle_id' => 105,
751
+ 'plate_no' => 'ABC-1234',
752
+ 'source' => 'door',
753
+ ]);
754
+
755
+ expect($open)->toMatchArray([
756
+ 'internal_id' => 'safee:105:door:Door1',
757
+ 'external_id' => 'safee:105:door:Door1',
758
+ 'name' => 'Door1',
759
+ 'type' => 'door',
760
+ 'sensor_type' => 'door',
761
+ 'value' => 'Open',
762
+ 'status' => 'active',
763
+ ]);
764
+ expect(data_get($open, 'meta.provider'))->toBe('safee');
765
+ expect(data_get($open, 'meta.vehicle_id'))->toBe(105);
766
+ expect(data_get($open, 'meta.plate_no'))->toBe('ABC-1234');
767
+ expect($closed['internal_id'])->toBe($open['internal_id']);
768
+ expect($closed['value'])->toBe('Closed');
202
769
  });
203
770
 
204
771
  test('geotab latest log record drives device and event telemetry', function () {
@@ -446,6 +1013,74 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
446
1013
  ->toContain("'altitude' => \$lastUpdate['alt'] ?? null");
447
1014
  });
448
1015
 
1016
+ test('afaqy sensors normalize stable parent scoped identities and latest values', function () {
1017
+ $provider = new AfaqyProvider();
1018
+
1019
+ $open = $provider->normalizeSensor([
1020
+ 'device_id' => 'unit-123',
1021
+ 'sensor_key' => 'Door1',
1022
+ 'name' => 'Door1',
1023
+ 'type' => 'digital',
1024
+ 'last_val' => [
1025
+ 'value' => 'Open',
1026
+ 'dtt' => '2026-06-23T09:00:00Z',
1027
+ ],
1028
+ ]);
1029
+
1030
+ $closed = $provider->normalizeSensor([
1031
+ 'device_id' => 'unit-123',
1032
+ 'sensor_key' => 'Door1',
1033
+ 'name' => 'Door1',
1034
+ 'type' => 'digital',
1035
+ 'last_val' => [
1036
+ 'value' => 'Closed',
1037
+ 'dtt' => '2026-06-23T09:05:00Z',
1038
+ ],
1039
+ ]);
1040
+
1041
+ expect($open)->toMatchArray([
1042
+ 'internal_id' => 'afaqy:unit-123:door1',
1043
+ 'external_id' => 'afaqy:unit-123:door1',
1044
+ 'name' => 'Door1',
1045
+ 'type' => 'door',
1046
+ 'sensor_type' => 'door',
1047
+ 'value' => 'Open',
1048
+ 'status' => 'active',
1049
+ ]);
1050
+ expect($open['recorded_at'])->toBe('2026-06-23 09:00:00');
1051
+ expect(data_get($open, 'meta.provider'))->toBe('afaqy');
1052
+ expect(data_get($open, 'meta.unit_id'))->toBe('unit-123');
1053
+ expect($closed['internal_id'])->toBe($open['internal_id']);
1054
+ expect($closed['value'])->toBe('Closed');
1055
+ });
1056
+
1057
+ test('afaqy sensors reject event shaped payloads without scalar sensor values', function () {
1058
+ $provider = new AfaqyProvider();
1059
+
1060
+ expect(fn () => $provider->normalizeSensor([
1061
+ 'device_id' => 'unit-123',
1062
+ 'sensor_key' => 'ignition_event',
1063
+ 'name' => 'ignition_event',
1064
+ 'last_update' => [
1065
+ 'lat' => 25.2,
1066
+ 'lng' => 55.2,
1067
+ 'speed' => 40,
1068
+ ],
1069
+ ]))->toThrow(InvalidArgumentException::class);
1070
+ });
1071
+
1072
+ test('afaqy bad sensor cleanup migration targets only afaqy telematics sensors', function () {
1073
+ $migration = file_get_contents(__DIR__ . '/../migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php');
1074
+
1075
+ expect($migration)
1076
+ ->toContain("->from('telematics')")
1077
+ ->toContain("->where('provider', 'afaqy')")
1078
+ ->toContain("->whereIn('telematic_uuid'")
1079
+ ->toContain("JSON_EXTRACT(meta, '$.provider')")
1080
+ ->toContain('Run an AFAQY sync to recreate')
1081
+ ->toContain('public function down(): void');
1082
+ });
1083
+
449
1084
  test('afaqy sync keeps default limit and uses extended data request path', function () {
450
1085
  $requests = [];
451
1086