@fleetbase/fleetops-engine 0.6.55 → 0.6.56

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 (109) hide show
  1. package/addon/components/admin/map-settings.hbs +1 -1
  2. package/addon/components/device/details.js +1 -1
  3. package/addon/components/device-event/details.hbs +192 -0
  4. package/addon/components/device-event/details.js +151 -0
  5. package/addon/components/driver/schedule.js +1 -1
  6. package/addon/components/layout/fleet-ops-sidebar.js +15 -9
  7. package/addon/components/telematic/details.hbs +1 -1
  8. package/addon/components/telematic/form.hbs +2 -2
  9. package/addon/components/telematic/settings.hbs +1 -1
  10. package/addon/controllers/connectivity/devices/index/details/events.js +1 -1
  11. package/addon/controllers/connectivity/devices/index/details/vehicle.js +2 -2
  12. package/addon/controllers/connectivity/events/details.js +68 -1
  13. package/addon/controllers/connectivity/sensors/index.js +4 -0
  14. package/addon/controllers/connectivity/telematics/details/events.js +1 -1
  15. package/addon/controllers/connectivity/telematics/details/sensors.js +4 -0
  16. package/addon/controllers/settings/map.js +15 -0
  17. package/addon/instance-initializers/register-vroom-allocation.js +1 -1
  18. package/addon/routes/connectivity/events/details.js +26 -1
  19. package/addon/routes/management/vehicles/index/details.js +3 -1
  20. package/addon/services/map-adapter/google.js +25 -0
  21. package/addon/services/map-adapter/leaflet.js +1 -1
  22. package/addon/services/map-adapter-interface.js +1 -1
  23. package/addon/services/map-manager.js +4 -1
  24. package/addon/services/map-settings.js +15 -0
  25. package/addon/styles/fleetops-engine.css +1 -1
  26. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +2 -1
  27. package/addon/templates/connectivity/events/details.hbs +40 -1
  28. package/addon/templates/settings/map.hbs +38 -9
  29. package/addon/utils/leaflet-plugin-loader.js +5 -5
  30. package/addon/utils/route-colors.js +1 -1
  31. package/app/components/device-event/details.hbs +192 -0
  32. package/app/components/device-event/details.js +1 -0
  33. package/composer.json +1 -1
  34. package/extension.json +1 -1
  35. package/package.json +2 -2
  36. package/server/migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php +34 -0
  37. package/server/src/Exports/ContactExport.php +12 -5
  38. package/server/src/Exports/DeviceExport.php +92 -0
  39. package/server/src/Exports/DriverExport.php +28 -3
  40. package/server/src/Exports/EquipmentExport.php +94 -0
  41. package/server/src/Exports/FleetExport.php +22 -7
  42. package/server/src/Exports/FuelReportExport.php +16 -1
  43. package/server/src/Exports/IssueExport.php +25 -3
  44. package/server/src/Exports/MaintenanceExport.php +106 -0
  45. package/server/src/Exports/MaintenanceScheduleExport.php +99 -0
  46. package/server/src/Exports/OrderExport.php +24 -5
  47. package/server/src/Exports/PartExport.php +99 -0
  48. package/server/src/Exports/PlaceExport.php +33 -7
  49. package/server/src/Exports/SensorExport.php +94 -0
  50. package/server/src/Exports/ServiceAreaExport.php +31 -7
  51. package/server/src/Exports/ServiceRateExport.php +41 -1
  52. package/server/src/Exports/TelematicExport.php +90 -0
  53. package/server/src/Exports/VehicleExport.php +274 -8
  54. package/server/src/Exports/VendorExport.php +15 -2
  55. package/server/src/Exports/WorkOrderExport.php +92 -0
  56. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +176 -0
  57. package/server/src/Http/Controllers/Api/v1/DeviceController.php +225 -0
  58. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +114 -0
  59. package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +185 -0
  60. package/server/src/Http/Controllers/Api/v1/PartController.php +120 -0
  61. package/server/src/Http/Controllers/Api/v1/SensorController.php +132 -0
  62. package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +161 -0
  63. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +18 -0
  64. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +17 -0
  65. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +17 -0
  66. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +17 -0
  67. package/server/src/Http/Controllers/Internal/v1/PartController.php +17 -0
  68. package/server/src/Http/Controllers/Internal/v1/SensorController.php +18 -0
  69. package/server/src/Http/Controllers/Internal/v1/SettingController.php +29 -5
  70. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +18 -0
  71. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +17 -0
  72. package/server/src/Http/Filter/DeviceFilter.php +33 -10
  73. package/server/src/Http/Filter/EquipmentFilter.php +23 -0
  74. package/server/src/Http/Filter/FuelProviderTransactionFilter.php +57 -2
  75. package/server/src/Http/Filter/PartFilter.php +47 -0
  76. package/server/src/Http/Filter/SensorFilter.php +34 -6
  77. package/server/src/Http/Filter/WorkOrderFilter.php +23 -0
  78. package/server/src/Http/Requests/CreateDeviceRequest.php +51 -0
  79. package/server/src/Http/Requests/CreateEquipmentRequest.php +35 -0
  80. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +50 -0
  81. package/server/src/Http/Requests/CreatePartRequest.php +40 -0
  82. package/server/src/Http/Requests/CreateSensorRequest.php +47 -0
  83. package/server/src/Http/Requests/CreateWorkOrderRequest.php +42 -0
  84. package/server/src/Http/Requests/UpdateDeviceRequest.php +7 -0
  85. package/server/src/Http/Requests/UpdateEquipmentRequest.php +7 -0
  86. package/server/src/Http/Requests/UpdateFuelTransactionRequest.php +7 -0
  87. package/server/src/Http/Requests/UpdatePartRequest.php +7 -0
  88. package/server/src/Http/Requests/UpdateSensorRequest.php +7 -0
  89. package/server/src/Http/Requests/UpdateWorkOrderRequest.php +7 -0
  90. package/server/src/Http/Resources/v1/Device.php +72 -0
  91. package/server/src/Http/Resources/v1/Equipment.php +57 -0
  92. package/server/src/Http/Resources/v1/FuelTransaction.php +69 -0
  93. package/server/src/Http/Resources/v1/Part.php +64 -0
  94. package/server/src/Http/Resources/v1/Sensor.php +68 -0
  95. package/server/src/Jobs/SyncTelematicDevicesJob.php +194 -38
  96. package/server/src/Models/Order.php +2 -1
  97. package/server/src/Observers/PurchaseRateObserver.php +2 -1
  98. package/server/src/Support/Analytics/RevenueTrend.php +3 -8
  99. package/server/src/Support/Metrics/ActiveRevenueQuery.php +121 -0
  100. package/server/src/Support/Metrics/AvgOrderValueMetric.php +1 -7
  101. package/server/src/Support/Metrics/EarningsMetric.php +1 -10
  102. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +104 -5
  103. package/server/src/Support/Telematics/Providers/SafeeProvider.php +550 -54
  104. package/server/src/Support/Telematics/TelematicService.php +28 -9
  105. package/server/src/routes.php +67 -14
  106. package/server/tests/ExportCoverageTest.php +79 -0
  107. package/server/tests/MetricsRegistryTest.php +45 -5
  108. package/server/tests/PublicFleetOpsRestEndpointsTest.php +191 -0
  109. package/server/tests/TelematicsHardeningTest.php +636 -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