@fleetbase/fleetops-engine 0.6.54 → 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 (220) hide show
  1. package/addon/components/admin/map-settings.hbs +1 -1
  2. package/addon/components/admin/routing-settings.hbs +2 -1
  3. package/addon/components/cell/attached-vehicle.hbs +10 -0
  4. package/addon/components/cell/attached-vehicle.js +58 -0
  5. package/addon/components/cell/device-identity.hbs +24 -0
  6. package/addon/components/cell/device-identity.js +129 -0
  7. package/addon/components/cell/driver-identity.hbs +33 -0
  8. package/addon/components/cell/driver-identity.js +143 -0
  9. package/addon/components/cell/equipment-identity.hbs +5 -0
  10. package/addon/components/cell/equipment-identity.js +48 -0
  11. package/addon/components/cell/part-identity.hbs +5 -0
  12. package/addon/components/cell/part-identity.js +63 -0
  13. package/addon/components/cell/telematic-device.hbs +38 -0
  14. package/addon/components/cell/telematic-device.js +44 -0
  15. package/addon/components/cell/telematic-provider.hbs +33 -16
  16. package/addon/components/cell/telematic-provider.js +50 -8
  17. package/addon/components/cell/vehicle-identity.hbs +33 -0
  18. package/addon/components/cell/vehicle-identity.js +138 -0
  19. package/addon/components/device/details.js +1 -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 +45 -20
  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/device-event/details.hbs +192 -0
  31. package/addon/components/device-event/details.js +151 -0
  32. package/addon/components/driver/schedule.js +1 -1
  33. package/addon/components/equipment/form.js +8 -1
  34. package/addon/components/layout/fleet-ops-sidebar.hbs +1 -1
  35. package/addon/components/layout/fleet-ops-sidebar.js +20 -6
  36. package/addon/components/map/drawer/device-event-listing.js +2 -0
  37. package/addon/components/map/drawer/driver-listing.js +2 -0
  38. package/addon/components/map/drawer/place-listing.js +2 -0
  39. package/addon/components/map/drawer/position-listing.hbs +11 -6
  40. package/addon/components/map/drawer/position-listing.js +17 -4
  41. package/addon/components/map/drawer/vehicle-listing.js +2 -0
  42. package/addon/components/sensor/panel-header.hbs +47 -15
  43. package/addon/components/sensor/panel-header.js +59 -1
  44. package/addon/components/table/cell/dropdown/action-item.hbs +31 -0
  45. package/addon/components/table/cell/dropdown/action-item.js +62 -0
  46. package/addon/components/table/cell/dropdown.hbs +28 -0
  47. package/addon/components/table/cell/dropdown.js +91 -0
  48. package/addon/components/telematic/details.hbs +1 -1
  49. package/addon/components/telematic/form.hbs +2 -2
  50. package/addon/components/telematic/settings.hbs +1 -1
  51. package/addon/components/vendor/panel-header.hbs +82 -0
  52. package/addon/components/vendor/panel-header.js +48 -0
  53. package/addon/controllers/connectivity/devices/index/details/events.js +1 -1
  54. package/addon/controllers/connectivity/devices/index/details/vehicle.js +50 -2
  55. package/addon/controllers/connectivity/devices/index.js +144 -129
  56. package/addon/controllers/connectivity/events/details.js +70 -0
  57. package/addon/controllers/connectivity/events/index.js +187 -11
  58. package/addon/controllers/connectivity/sensors/index.js +160 -14
  59. package/addon/controllers/connectivity/telematics/details/devices.js +126 -134
  60. package/addon/controllers/connectivity/telematics/details/events.js +1 -1
  61. package/addon/controllers/connectivity/telematics/details/sensors.js +4 -0
  62. package/addon/controllers/maintenance/equipment/index.js +1 -2
  63. package/addon/controllers/maintenance/parts/index.js +1 -2
  64. package/addon/controllers/management/drivers/index.js +82 -42
  65. package/addon/controllers/management/fuel-reports/index.js +63 -4
  66. package/addon/controllers/management/issues/index.js +63 -4
  67. package/addon/controllers/management/vehicles/index.js +33 -6
  68. package/addon/controllers/settings/map.js +15 -0
  69. package/addon/instance-initializers/register-vroom-allocation.js +1 -1
  70. package/addon/routes/connectivity/devices/index/details/vehicle.js +24 -2
  71. package/addon/routes/connectivity/devices/index.js +7 -0
  72. package/addon/routes/connectivity/events/details.js +28 -0
  73. package/addon/routes/connectivity/events/index.js +6 -2
  74. package/addon/routes/connectivity/sensors/index.js +7 -2
  75. package/addon/routes/connectivity/telematics/details/devices.js +2 -0
  76. package/addon/routes/management/vehicles/index/details.js +3 -1
  77. package/addon/services/device-actions.js +30 -0
  78. package/addon/services/device-event-actions.js +1 -1
  79. package/addon/services/equipment-actions.js +1 -0
  80. package/addon/services/map-adapter/google.js +25 -0
  81. package/addon/services/map-adapter/leaflet.js +1 -1
  82. package/addon/services/map-adapter-interface.js +1 -1
  83. package/addon/services/map-manager.js +4 -1
  84. package/addon/services/map-settings.js +15 -0
  85. package/addon/services/sensor-actions.js +3 -0
  86. package/addon/services/service-rate-actions.js +39 -1
  87. package/addon/services/vehicle-actions.js +24 -12
  88. package/addon/services/vendor-actions.js +3 -0
  89. package/addon/styles/fleetops-engine.css +9 -1
  90. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +66 -2
  91. package/addon/templates/connectivity/events/details.hbs +40 -0
  92. package/addon/templates/management/vendors/index/details.hbs +2 -2
  93. package/addon/templates/settings/map.hbs +38 -9
  94. package/addon/templates/settings/notifications.hbs +2 -1
  95. package/addon/templates/settings/routing.hbs +1 -0
  96. package/addon/utils/device-table-columns.js +288 -0
  97. package/addon/utils/identity-cell-resource.js +20 -0
  98. package/addon/utils/leaflet-plugin-loader.js +5 -5
  99. package/addon/utils/map-drawer-dropdown-position.js +47 -0
  100. package/addon/utils/route-colors.js +1 -1
  101. package/addon/utils/trackable-option.js +93 -0
  102. package/app/components/cell/attached-vehicle.js +1 -0
  103. package/app/components/cell/device-identity.js +1 -0
  104. package/app/components/cell/driver-identity.js +1 -0
  105. package/app/components/cell/equipment-identity.js +1 -0
  106. package/app/components/cell/part-identity.js +1 -0
  107. package/app/components/cell/telematic-device.js +1 -0
  108. package/app/components/cell/vehicle-identity.js +1 -0
  109. package/app/components/device/panel-tabs/events.hbs +30 -0
  110. package/app/components/device/panel-tabs/events.js +128 -0
  111. package/app/components/device/panel-tabs/sensors.hbs +30 -0
  112. package/app/components/device/panel-tabs/sensors.js +93 -0
  113. package/app/components/device/panel-tabs/vehicle.hbs +68 -0
  114. package/app/components/device/panel-tabs/vehicle.js +90 -0
  115. package/app/components/device-event/details.hbs +192 -0
  116. package/app/components/device-event/details.js +1 -0
  117. package/app/components/table/cell/dropdown/action-item.js +1 -0
  118. package/app/components/table/cell/dropdown.js +1 -0
  119. package/app/components/vendor/panel-header.js +1 -0
  120. package/app/controllers/connectivity/events/{index/details.js → details.js} +1 -1
  121. package/app/routes/connectivity/events/{index/details.js → details.js} +1 -1
  122. package/app/templates/connectivity/events/{index/details.js → details.js} +1 -1
  123. package/app/utils/map-drawer-dropdown-position.js +1 -0
  124. package/app/utils/trackable-option.js +1 -0
  125. package/composer.json +1 -1
  126. package/extension.json +1 -1
  127. package/package.json +4 -4
  128. package/server/migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php +34 -0
  129. package/server/src/Console/Commands/SyncTelematics.php +4 -4
  130. package/server/src/Exports/ContactExport.php +12 -5
  131. package/server/src/Exports/DeviceExport.php +92 -0
  132. package/server/src/Exports/DriverExport.php +28 -3
  133. package/server/src/Exports/EquipmentExport.php +94 -0
  134. package/server/src/Exports/FleetExport.php +22 -7
  135. package/server/src/Exports/FuelReportExport.php +16 -1
  136. package/server/src/Exports/IssueExport.php +25 -3
  137. package/server/src/Exports/MaintenanceExport.php +106 -0
  138. package/server/src/Exports/MaintenanceScheduleExport.php +99 -0
  139. package/server/src/Exports/OrderExport.php +24 -5
  140. package/server/src/Exports/PartExport.php +99 -0
  141. package/server/src/Exports/PlaceExport.php +33 -7
  142. package/server/src/Exports/SensorExport.php +94 -0
  143. package/server/src/Exports/ServiceAreaExport.php +31 -7
  144. package/server/src/Exports/ServiceRateExport.php +41 -1
  145. package/server/src/Exports/TelematicExport.php +90 -0
  146. package/server/src/Exports/VehicleExport.php +274 -8
  147. package/server/src/Exports/VendorExport.php +15 -2
  148. package/server/src/Exports/WorkOrderExport.php +92 -0
  149. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +176 -0
  150. package/server/src/Http/Controllers/Api/v1/DeviceController.php +225 -0
  151. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +114 -0
  152. package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +185 -0
  153. package/server/src/Http/Controllers/Api/v1/PartController.php +120 -0
  154. package/server/src/Http/Controllers/Api/v1/SensorController.php +132 -0
  155. package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +161 -0
  156. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +36 -1
  157. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +1 -1
  158. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +17 -0
  159. package/server/src/Http/Controllers/Internal/v1/LiveController.php +1 -1
  160. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +17 -0
  161. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +17 -0
  162. package/server/src/Http/Controllers/Internal/v1/PartController.php +17 -0
  163. package/server/src/Http/Controllers/Internal/v1/SensorController.php +18 -0
  164. package/server/src/Http/Controllers/Internal/v1/ServiceRateController.php +82 -13
  165. package/server/src/Http/Controllers/Internal/v1/SettingController.php +29 -5
  166. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +18 -0
  167. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +17 -0
  168. package/server/src/Http/Filter/DeviceEventFilter.php +21 -0
  169. package/server/src/Http/Filter/DeviceFilter.php +50 -4
  170. package/server/src/Http/Filter/EquipmentFilter.php +23 -0
  171. package/server/src/Http/Filter/FuelProviderTransactionFilter.php +57 -2
  172. package/server/src/Http/Filter/PartFilter.php +47 -0
  173. package/server/src/Http/Filter/SensorFilter.php +34 -6
  174. package/server/src/Http/Filter/WorkOrderFilter.php +23 -0
  175. package/server/src/Http/Requests/CreateDeviceRequest.php +51 -0
  176. package/server/src/Http/Requests/CreateEquipmentRequest.php +35 -0
  177. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +50 -0
  178. package/server/src/Http/Requests/CreatePartRequest.php +40 -0
  179. package/server/src/Http/Requests/CreateSensorRequest.php +47 -0
  180. package/server/src/Http/Requests/CreateWorkOrderRequest.php +42 -0
  181. package/server/src/Http/Requests/UpdateDeviceRequest.php +7 -0
  182. package/server/src/Http/Requests/UpdateEquipmentRequest.php +7 -0
  183. package/server/src/Http/Requests/UpdateFuelTransactionRequest.php +7 -0
  184. package/server/src/Http/Requests/UpdatePartRequest.php +7 -0
  185. package/server/src/Http/Requests/UpdateSensorRequest.php +7 -0
  186. package/server/src/Http/Requests/UpdateWorkOrderRequest.php +7 -0
  187. package/server/src/Http/Resources/v1/Device.php +72 -0
  188. package/server/src/Http/Resources/v1/Equipment.php +57 -0
  189. package/server/src/Http/Resources/v1/FuelTransaction.php +69 -0
  190. package/server/src/Http/Resources/v1/Index/Vehicle.php +19 -0
  191. package/server/src/Http/Resources/v1/Part.php +64 -0
  192. package/server/src/Http/Resources/v1/Sensor.php +68 -0
  193. package/server/src/Jobs/SyncTelematicDevicesJob.php +194 -38
  194. package/server/src/Models/Device.php +2 -0
  195. package/server/src/Models/DeviceEvent.php +81 -0
  196. package/server/src/Models/Equipment.php +55 -0
  197. package/server/src/Models/Order.php +2 -1
  198. package/server/src/Observers/PurchaseRateObserver.php +2 -1
  199. package/server/src/Support/Analytics/RevenueTrend.php +3 -8
  200. package/server/src/Support/Metrics/ActiveRevenueQuery.php +121 -0
  201. package/server/src/Support/Metrics/AvgOrderValueMetric.php +1 -7
  202. package/server/src/Support/Metrics/EarningsMetric.php +1 -10
  203. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +105 -5
  204. package/server/src/Support/Telematics/Providers/FlespiProvider.php +89 -15
  205. package/server/src/Support/Telematics/Providers/GeotabProvider.php +123 -35
  206. package/server/src/Support/Telematics/Providers/SafeeProvider.php +567 -47
  207. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +122 -14
  208. package/server/src/Support/Telematics/TelematicService.php +28 -9
  209. package/server/src/routes.php +67 -15
  210. package/server/tests/DeviceFilterTest.php +9 -0
  211. package/server/tests/DriverVehicleStatusDefaultTest.php +19 -2
  212. package/server/tests/EquipmentAssignmentMorphTypeTest.php +35 -0
  213. package/server/tests/ExportCoverageTest.php +79 -0
  214. package/server/tests/MetricsRegistryTest.php +45 -5
  215. package/server/tests/PublicFleetOpsRestEndpointsTest.php +191 -0
  216. package/server/tests/ServiceRateRouteValidationTest.php +101 -0
  217. package/server/tests/TelematicsHardeningTest.php +873 -7
  218. package/addon/controllers/connectivity/events/index/details.js +0 -3
  219. package/addon/routes/connectivity/events/index/details.js +0 -3
  220. package/addon/templates/connectivity/events/index/details.hbs +0 -1
@@ -1,9 +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;
6
+ use Fleetbase\FleetOps\Support\Telematics\Providers\FlespiProvider;
7
+ use Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider;
5
8
  use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
9
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SamsaraProvider;
6
10
  use Illuminate\Http\Client\ConnectionException;
11
+ use Illuminate\Support\Carbon;
7
12
  use Illuminate\Support\Facades\Http;
8
13
 
9
14
  test('device event model and migration expose lifecycle fields used by telematics workflows', function () {
@@ -21,6 +26,11 @@ test('device event model and migration expose lifecycle fields used by telematic
21
26
  ->toContain("'occurred_at'")
22
27
  ->toContain("'processed_at'")
23
28
  ->toContain("'data'")
29
+ ->toContain("'device_imei'")
30
+ ->toContain("'device_connection_status'")
31
+ ->toContain("'provider_descriptor'")
32
+ ->toContain('public function getDeviceImeiAttribute(): ?string')
33
+ ->toContain('public function getProviderDescriptorAttribute(): array')
24
34
  ->toContain("'occurred_at' => 'datetime'")
25
35
  ->toContain("'processed_at' => 'datetime'");
26
36
  });
@@ -46,6 +56,7 @@ test('telematics service requires provider identity and stores idempotent event
46
56
  expect($service)
47
57
  ->toContain('Provider device identity is required to link a telematics device.')
48
58
  ->toContain('public function ingestDeviceSnapshot')
59
+ ->toContain('normalizeEvents')
49
60
  ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])')
50
61
  ->toContain('reconcileDeviceTelemetry')
51
62
  ->toContain('PROTECTED_DEVICE_STATUSES')
@@ -55,9 +66,19 @@ test('telematics service requires provider identity and stores idempotent event
55
66
  ->toContain('applyDeviceEventTelemetry')
56
67
  ->toContain('$event->createPosition($positionData)')
57
68
  ->toContain('updateVehicleTelemetry')
69
+ ->toContain('$vehicle->odometer = $eventData[\'odometer\'];')
58
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")
59
75
  ->toContain('storeSnapshotSensors')
60
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")
61
82
  ->toContain('protected function makeEventKey')
62
83
  ->toContain('$telematic->public_id ?? $telematic->uuid')
63
84
  ->toContain('resolveWebhookTelematic')
@@ -79,20 +100,794 @@ test('device details render consistent telematics connection state and timestamp
79
100
  });
80
101
 
81
102
  test('native providers normalize device payloads to canonical FleetOps keys', function () {
82
- $afaqy = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
83
- $safee = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php');
103
+ $providers = [
104
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'),
105
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/FlespiProvider.php'),
106
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/GeotabProvider.php'),
107
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php'),
108
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SamsaraProvider.php'),
109
+ ];
84
110
 
85
- foreach ([$afaqy, $safee] as $provider) {
111
+ foreach ($providers as $provider) {
86
112
  expect($provider)
87
113
  ->toContain("'device_id'")
114
+ ->toContain("'external_id'")
88
115
  ->toContain("'name'")
89
116
  ->toContain("'provider'")
90
117
  ->toContain("'model'")
91
118
  ->toContain("'online'")
92
- ->toContain("'last_seen_at'");
119
+ ->toContain("'last_seen_at'")
120
+ ->toContain("'location'")
121
+ ->toContain("'speed'")
122
+ ->toContain("'heading'")
123
+ ->toContain("'altitude'");
93
124
  }
94
125
  });
95
126
 
127
+ test('flespi telemetry normalizes positional event fields', function () {
128
+ $event = (new FlespiProvider())->normalizeEvent([
129
+ 'id' => 'message-1',
130
+ 'device.id' => 'device-1',
131
+ 'timestamp' => 1781769600,
132
+ 'position.latitude' => 25.2048,
133
+ 'position.longitude' => 55.2708,
134
+ 'position.speed' => 42,
135
+ 'position.direction' => 91,
136
+ 'position.altitude' => 12,
137
+ 'vehicle.mileage' => 12345,
138
+ 'engine.ignition.status' => true,
139
+ 'fuel.level' => 67,
140
+ ]);
141
+
142
+ expect($event)->toMatchArray([
143
+ 'external_id' => 'message-1',
144
+ 'device_id' => 'device-1',
145
+ 'event_type' => 'telemetry_update',
146
+ 'online' => true,
147
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
148
+ 'speed' => 42,
149
+ 'heading' => 91,
150
+ 'altitude' => 12,
151
+ 'odometer' => 12345,
152
+ 'ignition' => true,
153
+ 'fuel_level' => 67,
154
+ ]);
155
+ expect($event['meta'])->toHaveKeys(['raw', 'provider_status']);
156
+ });
157
+
158
+ test('samsara telemetry variants normalize positional event fields', function () {
159
+ $event = (new SamsaraProvider())->normalizeEvent([
160
+ 'id' => 'event-1',
161
+ 'vehicle' => ['id' => 'vehicle-1'],
162
+ 'time' => '2026-06-18T08:00:00Z',
163
+ 'location' => [
164
+ 'latitude' => 25.2048,
165
+ 'longitude' => 55.2708,
166
+ 'speedMilesPerHour' => 30,
167
+ 'headingDegrees' => 180,
168
+ 'altitudeMeters' => 16,
169
+ ],
170
+ 'odometerMeters' => 1000,
171
+ 'fuelPercent' => 50,
172
+ 'gateway' => ['status' => 'connected', 'online' => true],
173
+ ]);
174
+
175
+ expect($event)->toMatchArray([
176
+ 'external_id' => 'event-1',
177
+ 'device_id' => 'vehicle-1',
178
+ 'event_type' => 'vehicle_update',
179
+ 'online' => true,
180
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
181
+ 'speed' => 30,
182
+ 'heading' => 180,
183
+ 'altitude' => 16,
184
+ 'odometer' => 1000,
185
+ 'fuel_level' => 50,
186
+ ]);
187
+ expect($event['meta']['provider_status'])->toMatchArray([
188
+ 'gateway_status' => 'connected',
189
+ 'online' => true,
190
+ ]);
191
+ });
192
+
193
+ test('safee telemetry includes online and altitude event fields', function () {
194
+ $event = (new SafeeProvider())->normalizeEvent([
195
+ 'id' => 'event-1',
196
+ 'deviceId' => 'device-1',
197
+ 'status' => 'online',
198
+ 'date' => '2026-06-18T08:00:00Z',
199
+ 'lat' => 25.2048,
200
+ 'lon' => 55.2708,
201
+ 'speed' => 30,
202
+ 'heading' => 100,
203
+ 'altitude' => 15,
204
+ ]);
205
+
206
+ expect($event)->toMatchArray([
207
+ 'device_id' => 'device-1',
208
+ 'online' => true,
209
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
210
+ 'speed' => 30,
211
+ 'heading' => 100,
212
+ 'altitude' => 15,
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');
769
+ });
770
+
771
+ test('geotab latest log record drives device and event telemetry', function () {
772
+ $payload = [
773
+ 'id' => 'device-1',
774
+ 'name' => 'Truck 1',
775
+ 'deviceType' => 'GO9',
776
+ 'serialNumber' => 'serial-1',
777
+ 'vehicleIdentificationNumber' => 'VIN123',
778
+ 'latest_log_record' => [
779
+ 'id' => 'log-1',
780
+ 'dateTime' => '2026-06-18T08:00:00Z',
781
+ 'latitude' => 25.2048,
782
+ 'longitude' => 55.2708,
783
+ 'speed' => 55,
784
+ 'bearing' => 90,
785
+ 'altitude' => 20,
786
+ 'device' => ['id' => 'device-1'],
787
+ ],
788
+ ];
789
+
790
+ $provider = new GeotabProvider();
791
+ $device = $provider->normalizeDevice($payload);
792
+ $event = $provider->normalizeEvent($payload);
793
+
794
+ expect($device)->toMatchArray([
795
+ 'device_id' => 'device-1',
796
+ 'external_id' => 'device-1',
797
+ 'name' => 'Truck 1',
798
+ 'provider' => 'geotab',
799
+ 'model' => 'GO9',
800
+ 'imei' => 'serial-1',
801
+ 'vin' => 'VIN123',
802
+ 'serial_number' => 'serial-1',
803
+ 'online' => true,
804
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
805
+ 'speed' => 55,
806
+ 'heading' => 90,
807
+ 'altitude' => 20,
808
+ ]);
809
+
810
+ expect($event)->toMatchArray([
811
+ 'external_id' => 'log-1',
812
+ 'device_id' => 'device-1',
813
+ 'event_type' => 'status_data',
814
+ 'online' => true,
815
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
816
+ 'speed' => 55,
817
+ 'heading' => 90,
818
+ 'altitude' => 20,
819
+ ]);
820
+ });
821
+
822
+ test('geotab polling fetches recent log records and merges latest record into device snapshots', function () {
823
+ $requests = [];
824
+
825
+ Http::fake(function ($request) use (&$requests) {
826
+ $requests[] = $request;
827
+ $body = json_decode($request->body(), true);
828
+ $typeName = data_get($body, 'params.typeName');
829
+
830
+ if ($typeName === 'Device') {
831
+ return Http::response([
832
+ 'result' => [
833
+ ['id' => 'device-1', 'name' => 'Truck 1'],
834
+ ['id' => 'device-2', 'name' => 'Truck 2'],
835
+ ],
836
+ ], 200);
837
+ }
838
+
839
+ if ($typeName === 'LogRecord') {
840
+ return Http::response([
841
+ 'result' => [
842
+ ['id' => 'old-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T07:00:00Z', 'latitude' => 1, 'longitude' => 2],
843
+ ['id' => 'new-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 3, 'longitude' => 4],
844
+ ['id' => 'other-log', 'device' => ['id' => 'other-device'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 5, 'longitude' => 6],
845
+ ],
846
+ ], 200);
847
+ }
848
+
849
+ return Http::response(['result' => []], 200);
850
+ });
851
+
852
+ $provider = new class extends GeotabProvider {
853
+ public function fetchDevicesForTest(): array
854
+ {
855
+ $this->credentials = [
856
+ 'database' => 'testing-db',
857
+ ];
858
+ $this->sessionId = 'testing-session';
859
+
860
+ return $this->fetchDevices(['limit' => 2, 'from_date' => '2026-06-18T00:00:00Z']);
861
+ }
862
+ };
863
+
864
+ $result = $provider->fetchDevicesForTest();
865
+
866
+ expect($result['devices'])->toHaveCount(2);
867
+ expect($result['devices'][0]['latest_log_record'])->toMatchArray([
868
+ 'id' => 'new-log',
869
+ 'latitude' => 3,
870
+ 'longitude' => 4,
871
+ ]);
872
+ expect($result['devices'][1])->not->toHaveKey('latest_log_record');
873
+ expect($requests)->toHaveCount(2);
874
+ expect($requests[0]->data())->toMatchArray([
875
+ 'method' => 'Get',
876
+ 'params' => [
877
+ 'typeName' => 'Device',
878
+ 'resultsLimit' => 2,
879
+ ],
880
+ ]);
881
+ expect($requests[1]->data())->toMatchArray([
882
+ 'method' => 'Get',
883
+ 'params' => [
884
+ 'typeName' => 'LogRecord',
885
+ 'search' => ['fromDate' => '2026-06-18T00:00:00Z'],
886
+ 'resultsLimit' => 100,
887
+ ],
888
+ ]);
889
+ });
890
+
96
891
  test('telematics details use public id for consumer webhook URLs and do not read ember uuid', function () {
97
892
  $component = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.js');
98
893
  $template = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs');
@@ -218,6 +1013,74 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
218
1013
  ->toContain("'altitude' => \$lastUpdate['alt'] ?? null");
219
1014
  });
220
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
+
221
1084
  test('afaqy sync keeps default limit and uses extended data request path', function () {
222
1085
  $requests = [];
223
1086
 
@@ -444,17 +1307,20 @@ test('telematics device sync records provider pagination and skipped device coun
444
1307
  ->toContain("method_exists(\$e, 'context') ? \$e->context() : []");
445
1308
  });
446
1309
 
447
- test('telematics polling command is registered and scheduled for no webhook providers', function () {
1310
+ test('telematics polling command is registered and scheduled for discovery providers by default', function () {
448
1311
  $command = file_get_contents(__DIR__ . '/../src/Console/Commands/SyncTelematics.php');
449
1312
  $provider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
450
1313
  $details = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs');
451
1314
 
452
1315
  expect($command)
453
1316
  ->toContain("protected \$signature = 'fleetops:sync-telematics")
1317
+ ->toContain('{--exclude-webhook-providers : Skip providers that support webhooks}')
454
1318
  ->toContain('SyncTelematicDevicesJob::dispatch($telematic')
455
- ->toContain('!$descriptor->supportsWebhooks')
1319
+ ->toContain('$excludeWebhookProviders = (bool) $this->option(\'exclude-webhook-providers\')')
1320
+ ->toContain('!$excludeWebhookProviders || !$descriptor->supportsWebhooks')
456
1321
  ->toContain('$descriptor->supportsDiscovery')
457
- ->toContain("whereIn('status', ['active', 'connected'])");
1322
+ ->toContain("whereIn('status', ['active', 'connected'])")
1323
+ ->not->toContain('sync-webhook-providers');
458
1324
 
459
1325
  expect($provider)
460
1326
  ->toContain('Console\\Commands\\SyncTelematics::class')