@fleetbase/fleetops-engine 0.6.54 → 0.6.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/addon/components/admin/routing-settings.hbs +2 -1
  2. package/addon/components/cell/attached-vehicle.hbs +10 -0
  3. package/addon/components/cell/attached-vehicle.js +58 -0
  4. package/addon/components/cell/device-identity.hbs +24 -0
  5. package/addon/components/cell/device-identity.js +129 -0
  6. package/addon/components/cell/driver-identity.hbs +33 -0
  7. package/addon/components/cell/driver-identity.js +143 -0
  8. package/addon/components/cell/equipment-identity.hbs +5 -0
  9. package/addon/components/cell/equipment-identity.js +48 -0
  10. package/addon/components/cell/part-identity.hbs +5 -0
  11. package/addon/components/cell/part-identity.js +63 -0
  12. package/addon/components/cell/telematic-device.hbs +38 -0
  13. package/addon/components/cell/telematic-device.js +44 -0
  14. package/addon/components/cell/telematic-provider.hbs +33 -16
  15. package/addon/components/cell/telematic-provider.js +50 -8
  16. package/addon/components/cell/vehicle-identity.hbs +33 -0
  17. package/addon/components/cell/vehicle-identity.js +138 -0
  18. package/addon/components/device/form.hbs +2 -2
  19. package/addon/components/device/form.js +7 -0
  20. package/addon/components/device/panel-header.hbs +45 -20
  21. package/addon/components/device/panel-header.js +49 -1
  22. package/addon/components/device/panel-tabs/events.hbs +30 -0
  23. package/addon/components/device/panel-tabs/events.js +128 -0
  24. package/addon/components/device/panel-tabs/sensors.hbs +30 -0
  25. package/addon/components/device/panel-tabs/sensors.js +93 -0
  26. package/addon/components/device/panel-tabs/vehicle.hbs +68 -0
  27. package/addon/components/device/panel-tabs/vehicle.js +90 -0
  28. package/addon/components/equipment/form.js +8 -1
  29. package/addon/components/layout/fleet-ops-sidebar.hbs +1 -1
  30. package/addon/components/layout/fleet-ops-sidebar.js +8 -0
  31. package/addon/components/map/drawer/device-event-listing.js +2 -0
  32. package/addon/components/map/drawer/driver-listing.js +2 -0
  33. package/addon/components/map/drawer/place-listing.js +2 -0
  34. package/addon/components/map/drawer/position-listing.hbs +11 -6
  35. package/addon/components/map/drawer/position-listing.js +17 -4
  36. package/addon/components/map/drawer/vehicle-listing.js +2 -0
  37. package/addon/components/sensor/panel-header.hbs +47 -15
  38. package/addon/components/sensor/panel-header.js +59 -1
  39. package/addon/components/table/cell/dropdown/action-item.hbs +31 -0
  40. package/addon/components/table/cell/dropdown/action-item.js +62 -0
  41. package/addon/components/table/cell/dropdown.hbs +28 -0
  42. package/addon/components/table/cell/dropdown.js +91 -0
  43. package/addon/components/vendor/panel-header.hbs +82 -0
  44. package/addon/components/vendor/panel-header.js +48 -0
  45. package/addon/controllers/connectivity/devices/index/details/vehicle.js +49 -1
  46. package/addon/controllers/connectivity/devices/index.js +144 -129
  47. package/addon/controllers/connectivity/events/details.js +3 -0
  48. package/addon/controllers/connectivity/events/index.js +187 -11
  49. package/addon/controllers/connectivity/sensors/index.js +156 -14
  50. package/addon/controllers/connectivity/telematics/details/devices.js +126 -134
  51. package/addon/controllers/maintenance/equipment/index.js +1 -2
  52. package/addon/controllers/maintenance/parts/index.js +1 -2
  53. package/addon/controllers/management/drivers/index.js +82 -42
  54. package/addon/controllers/management/fuel-reports/index.js +63 -4
  55. package/addon/controllers/management/issues/index.js +63 -4
  56. package/addon/controllers/management/vehicles/index.js +33 -6
  57. package/addon/routes/connectivity/devices/index/details/vehicle.js +24 -2
  58. package/addon/routes/connectivity/devices/index.js +7 -0
  59. package/addon/routes/connectivity/events/details.js +3 -0
  60. package/addon/routes/connectivity/events/index.js +6 -2
  61. package/addon/routes/connectivity/sensors/index.js +7 -2
  62. package/addon/routes/connectivity/telematics/details/devices.js +2 -0
  63. package/addon/services/device-actions.js +30 -0
  64. package/addon/services/device-event-actions.js +1 -1
  65. package/addon/services/equipment-actions.js +1 -0
  66. package/addon/services/sensor-actions.js +3 -0
  67. package/addon/services/service-rate-actions.js +39 -1
  68. package/addon/services/vehicle-actions.js +24 -12
  69. package/addon/services/vendor-actions.js +3 -0
  70. package/addon/styles/fleetops-engine.css +8 -0
  71. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +64 -1
  72. package/addon/templates/management/vendors/index/details.hbs +2 -2
  73. package/addon/templates/settings/notifications.hbs +2 -1
  74. package/addon/templates/settings/routing.hbs +1 -0
  75. package/addon/utils/device-table-columns.js +288 -0
  76. package/addon/utils/identity-cell-resource.js +20 -0
  77. package/addon/utils/map-drawer-dropdown-position.js +47 -0
  78. package/addon/utils/trackable-option.js +93 -0
  79. package/app/components/cell/attached-vehicle.js +1 -0
  80. package/app/components/cell/device-identity.js +1 -0
  81. package/app/components/cell/driver-identity.js +1 -0
  82. package/app/components/cell/equipment-identity.js +1 -0
  83. package/app/components/cell/part-identity.js +1 -0
  84. package/app/components/cell/telematic-device.js +1 -0
  85. package/app/components/cell/vehicle-identity.js +1 -0
  86. package/app/components/device/panel-tabs/events.hbs +30 -0
  87. package/app/components/device/panel-tabs/events.js +128 -0
  88. package/app/components/device/panel-tabs/sensors.hbs +30 -0
  89. package/app/components/device/panel-tabs/sensors.js +93 -0
  90. package/app/components/device/panel-tabs/vehicle.hbs +68 -0
  91. package/app/components/device/panel-tabs/vehicle.js +90 -0
  92. package/app/components/table/cell/dropdown/action-item.js +1 -0
  93. package/app/components/table/cell/dropdown.js +1 -0
  94. package/app/components/vendor/panel-header.js +1 -0
  95. package/app/controllers/connectivity/events/{index/details.js → details.js} +1 -1
  96. package/app/routes/connectivity/events/{index/details.js → details.js} +1 -1
  97. package/app/templates/connectivity/events/{index/details.js → details.js} +1 -1
  98. package/app/utils/map-drawer-dropdown-position.js +1 -0
  99. package/app/utils/trackable-option.js +1 -0
  100. package/composer.json +1 -1
  101. package/extension.json +1 -1
  102. package/package.json +4 -4
  103. package/server/src/Console/Commands/SyncTelematics.php +4 -4
  104. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +18 -1
  105. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +1 -1
  106. package/server/src/Http/Controllers/Internal/v1/LiveController.php +1 -1
  107. package/server/src/Http/Controllers/Internal/v1/ServiceRateController.php +82 -13
  108. package/server/src/Http/Filter/DeviceEventFilter.php +21 -0
  109. package/server/src/Http/Filter/DeviceFilter.php +24 -1
  110. package/server/src/Http/Resources/v1/Index/Vehicle.php +19 -0
  111. package/server/src/Models/Device.php +2 -0
  112. package/server/src/Models/DeviceEvent.php +81 -0
  113. package/server/src/Models/Equipment.php +55 -0
  114. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +1 -0
  115. package/server/src/Support/Telematics/Providers/FlespiProvider.php +89 -15
  116. package/server/src/Support/Telematics/Providers/GeotabProvider.php +123 -35
  117. package/server/src/Support/Telematics/Providers/SafeeProvider.php +26 -2
  118. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +122 -14
  119. package/server/src/routes.php +0 -1
  120. package/server/tests/DeviceFilterTest.php +9 -0
  121. package/server/tests/DriverVehicleStatusDefaultTest.php +19 -2
  122. package/server/tests/EquipmentAssignmentMorphTypeTest.php +35 -0
  123. package/server/tests/ServiceRateRouteValidationTest.php +101 -0
  124. package/server/tests/TelematicsHardeningTest.php +238 -7
  125. package/addon/controllers/connectivity/events/index/details.js +0 -3
  126. package/addon/routes/connectivity/events/index/details.js +0 -3
  127. /package/addon/templates/connectivity/events/{index/details.hbs → details.hbs} +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
4
 
5
+ use Illuminate\Support\Carbon;
5
6
  use Illuminate\Support\Facades\Http;
6
7
 
7
8
  /**
@@ -75,19 +76,21 @@ class GeotabProvider extends AbstractProvider
75
76
  {
76
77
  $limit = $options['limit'] ?? 100;
77
78
 
78
- $response = Http::post($this->baseUrl, [
79
- 'method' => 'Get',
80
- 'params' => [
81
- 'credentials' => [
82
- 'database' => $this->credentials['database'],
83
- 'sessionId' => $this->sessionId,
84
- ],
85
- 'typeName' => 'Device',
86
- 'resultsLimit' => $limit,
87
- ],
88
- ])->json();
79
+ $response = $this->apiCall('Get', [
80
+ 'typeName' => 'Device',
81
+ 'resultsLimit' => $limit,
82
+ ]);
89
83
 
90
- $devices = $response['result'] ?? [];
84
+ $devices = $response['result'] ?? [];
85
+ $logsByDevice = $this->fetchLatestLogRecords($devices, $options);
86
+ $devices = array_map(function ($device) use ($logsByDevice) {
87
+ $deviceId = $device['id'] ?? null;
88
+ if ($deviceId && isset($logsByDevice[$deviceId])) {
89
+ $device['latest_log_record'] = $logsByDevice[$deviceId];
90
+ }
91
+
92
+ return $device;
93
+ }, $devices);
91
94
 
92
95
  return [
93
96
  'devices' => $devices,
@@ -98,43 +101,76 @@ class GeotabProvider extends AbstractProvider
98
101
 
99
102
  public function fetchDeviceDetails(string $externalId): array
100
103
  {
101
- $response = Http::post($this->baseUrl, [
102
- 'method' => 'Get',
103
- 'params' => [
104
- 'credentials' => [
105
- 'database' => $this->credentials['database'],
106
- 'sessionId' => $this->sessionId,
107
- ],
108
- 'typeName' => 'Device',
109
- 'search' => ['id' => $externalId],
110
- ],
111
- ])->json();
104
+ $response = $this->apiCall('Get', [
105
+ 'typeName' => 'Device',
106
+ 'search' => ['id' => $externalId],
107
+ ]);
112
108
 
113
109
  return $response['result'][0] ?? [];
114
110
  }
115
111
 
116
112
  public function normalizeDevice(array $payload): array
117
113
  {
114
+ $latestLog = $payload['latest_log_record'] ?? [];
115
+ $hasTelemetry = isset($latestLog['dateTime']) || isset($latestLog['latitude'], $latestLog['longitude']);
116
+
118
117
  return [
119
- 'external_id' => $payload['id'],
120
- 'device_name' => $payload['name'] ?? 'Unknown Device',
121
- 'device_provider' => 'geotab',
122
- 'device_model' => $payload['deviceType'] ?? null,
123
- 'imei' => $payload['serialNumber'] ?? null,
124
- 'vin' => $payload['vehicleIdentificationNumber'] ?? null,
125
- 'status' => 'active',
126
- 'meta' => $payload,
118
+ 'device_id' => $payload['id'] ?? null,
119
+ 'external_id' => $payload['id'] ?? null,
120
+ 'name' => $payload['name'] ?? 'Unknown Device',
121
+ 'provider' => 'geotab',
122
+ 'model' => $payload['deviceType'] ?? null,
123
+ 'imei' => $payload['serialNumber'] ?? null,
124
+ 'vin' => $payload['vehicleIdentificationNumber'] ?? null,
125
+ 'serial_number' => $payload['serialNumber'] ?? null,
126
+ 'status' => 'active',
127
+ 'online' => $hasTelemetry ? true : null,
128
+ 'last_seen_at' => $latestLog ? $this->parseTimestamp($latestLog['dateTime'] ?? null) : null,
129
+ 'location' => [
130
+ 'lat' => $latestLog['latitude'] ?? null,
131
+ 'lng' => $latestLog['longitude'] ?? null,
132
+ ],
133
+ 'speed' => $latestLog['speed'] ?? null,
134
+ 'heading' => $latestLog['bearing'] ?? $latestLog['heading'] ?? null,
135
+ 'altitude' => $latestLog['altitude'] ?? null,
136
+ 'meta' => [
137
+ 'raw' => $payload,
138
+ 'provider_status' => array_filter([
139
+ 'active_from' => $payload['activeFrom'] ?? null,
140
+ 'active_to' => $payload['activeTo'] ?? null,
141
+ 'groups' => $payload['groups'] ?? null,
142
+ 'has_log' => $hasTelemetry,
143
+ ], fn ($value) => $value !== null),
144
+ ],
127
145
  ];
128
146
  }
129
147
 
130
148
  public function normalizeEvent(array $payload): array
131
149
  {
150
+ $latestLog = $payload['latest_log_record'] ?? $payload;
151
+ $deviceId = data_get($latestLog, 'device.id') ?? $payload['deviceId'] ?? $payload['id'] ?? null;
152
+ $hasTelemetry = isset($latestLog['dateTime']) || isset($latestLog['latitude'], $latestLog['longitude']);
153
+
132
154
  return [
133
- 'external_id' => $payload['id'] ?? null,
134
- 'device_id' => $payload['device']['id'] ?? $payload['deviceId'] ?? null,
155
+ 'external_id' => $latestLog['id'] ?? $payload['id'] ?? null,
156
+ 'device_id' => $deviceId,
135
157
  'event_type' => $payload['type'] ?? 'status_data',
136
- 'occurred_at' => $payload['dateTime'] ?? now(),
137
- 'meta' => $payload,
158
+ 'occurred_at' => $this->parseTimestamp($latestLog['dateTime'] ?? null) ?? now(),
159
+ 'online' => $hasTelemetry ? true : null,
160
+ 'location' => [
161
+ 'lat' => $latestLog['latitude'] ?? null,
162
+ 'lng' => $latestLog['longitude'] ?? null,
163
+ ],
164
+ 'speed' => $latestLog['speed'] ?? null,
165
+ 'heading' => $latestLog['bearing'] ?? $latestLog['heading'] ?? null,
166
+ 'altitude' => $latestLog['altitude'] ?? null,
167
+ 'meta' => [
168
+ 'raw' => $payload,
169
+ 'provider_status' => array_filter([
170
+ 'has_log' => $hasTelemetry,
171
+ 'device' => data_get($latestLog, 'device.id'),
172
+ ], fn ($value) => $value !== null),
173
+ ],
138
174
  ];
139
175
  }
140
176
 
@@ -179,4 +215,56 @@ class GeotabProvider extends AbstractProvider
179
215
  {
180
216
  return false;
181
217
  }
218
+
219
+ protected function apiCall(string $method, array $params): array
220
+ {
221
+ $params['credentials'] = [
222
+ 'database' => $this->credentials['database'],
223
+ 'sessionId' => $this->sessionId,
224
+ ];
225
+
226
+ return Http::post($this->baseUrl, [
227
+ 'method' => $method,
228
+ 'params' => $params,
229
+ ])->json() ?? [];
230
+ }
231
+
232
+ protected function fetchLatestLogRecords(array $devices, array $options = []): array
233
+ {
234
+ $deviceIds = array_values(array_filter(array_map(fn ($device) => $device['id'] ?? null, $devices)));
235
+ if (empty($deviceIds)) {
236
+ return [];
237
+ }
238
+
239
+ $fromDate = $options['from_date'] ?? Carbon::now()->subHours(24)->toIso8601String();
240
+ $response = $this->apiCall('Get', [
241
+ 'typeName' => 'LogRecord',
242
+ 'search' => ['fromDate' => $fromDate],
243
+ 'resultsLimit' => max(count($deviceIds) * 5, 100),
244
+ ]);
245
+
246
+ $logsByDevice = [];
247
+ foreach ($response['result'] ?? [] as $record) {
248
+ $deviceId = data_get($record, 'device.id');
249
+ if (!$deviceId || !in_array($deviceId, $deviceIds, true)) {
250
+ continue;
251
+ }
252
+
253
+ $current = $logsByDevice[$deviceId] ?? null;
254
+ if (!$current || Carbon::parse($record['dateTime'] ?? now())->greaterThan(Carbon::parse($current['dateTime'] ?? now()))) {
255
+ $logsByDevice[$deviceId] = $record;
256
+ }
257
+ }
258
+
259
+ return $logsByDevice;
260
+ }
261
+
262
+ protected function parseTimestamp($value): ?string
263
+ {
264
+ if (!$value) {
265
+ return null;
266
+ }
267
+
268
+ return Carbon::parse($value)->toDateTimeString();
269
+ }
182
270
  }
@@ -110,8 +110,19 @@ class SafeeProvider extends AbstractProvider
110
110
  'lat' => $position['lat'] ?? null,
111
111
  'lng' => $position['lng'] ?? null,
112
112
  ],
113
- 'meta' => [
114
- 'raw' => $payload,
113
+ 'speed' => $payload['speed'] ?? $payload['lastSpeed'] ?? null,
114
+ 'heading' => $payload['heading'] ?? $payload['angle'] ?? null,
115
+ 'altitude' => $payload['altitude'] ?? $payload['alt'] ?? null,
116
+ 'odometer' => $this->extractOdometer($payload),
117
+ 'ignition' => $this->extractIgnition($payload),
118
+ 'fuel_level' => $this->extractFuelLevel($payload),
119
+ 'meta' => [
120
+ 'raw' => $payload,
121
+ 'provider_status' => array_filter([
122
+ 'status' => $rawStatus,
123
+ 'normalized' => $status,
124
+ 'vehicleStatus' => $payload['vehicleStatus'] ?? null,
125
+ ], fn ($value) => $value !== null),
115
126
  'plate_number' => $payload['plateNumber'] ?? $payload['plate_number'] ?? null,
116
127
  'door_number' => $payload['doorNumber'] ?? null,
117
128
  'driver' => $payload['driver'] ?? null,
@@ -136,12 +147,14 @@ class SafeeProvider extends AbstractProvider
136
147
  'event_type' => data_get($payload, 'event.code') ?? data_get($payload, 'event.name') ?? 'telemetry_update',
137
148
  'message' => data_get($payload, 'event.name') ?? data_get($payload, 'event.message') ?? null,
138
149
  'occurred_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null),
150
+ 'online' => $this->resolveOnline($payload),
139
151
  'location' => [
140
152
  'lat' => $position['lat'] ?? null,
141
153
  'lng' => $position['lng'] ?? null,
142
154
  ],
143
155
  'speed' => $payload['speed'] ?? $payload['lastSpeed'] ?? null,
144
156
  'heading' => $payload['heading'] ?? $payload['angle'] ?? null,
157
+ 'altitude' => $payload['altitude'] ?? $payload['alt'] ?? null,
145
158
  'odometer' => $this->extractOdometer($payload),
146
159
  'ignition' => $this->extractIgnition($payload),
147
160
  'fuel_level' => $this->extractFuelLevel($payload),
@@ -347,6 +360,17 @@ class SafeeProvider extends AbstractProvider
347
360
  return in_array(strtolower($status), ['offline', 'inactive', 'deleted', 'expired'], true) ? 'inactive' : 'active';
348
361
  }
349
362
 
363
+ protected function resolveOnline(array $payload): ?bool
364
+ {
365
+ if (array_key_exists('online', $payload)) {
366
+ return filter_var($payload['online'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $payload['online'];
367
+ }
368
+
369
+ $status = $payload['status'] ?? $payload['vehicleStatus'] ?? null;
370
+
371
+ return $status ? $this->normalizeVehicleStatus($status) === 'active' : null;
372
+ }
373
+
350
374
  protected function extractOdometer(array $payload): mixed
351
375
  {
352
376
  return data_get($payload, 'canbus.odometer')
@@ -2,6 +2,8 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
4
 
5
+ use Illuminate\Support\Carbon;
6
+
5
7
  /**
6
8
  * Class SamsaraProvider.
7
9
  *
@@ -78,30 +80,68 @@ class SamsaraProvider extends AbstractProvider
78
80
 
79
81
  public function normalizeDevice(array $payload): array
80
82
  {
83
+ $position = $this->extractPosition($payload);
84
+
81
85
  return [
82
- 'external_id' => $payload['id'],
83
- 'device_name' => $payload['name'] ?? 'Unknown Device',
84
- 'device_provider' => 'samsara',
85
- 'device_model' => $payload['make'] ?? null,
86
- 'vin' => $payload['vin'] ?? null,
87
- 'license_plate' => $payload['licensePlate'] ?? null,
88
- 'status' => 'active',
89
- 'meta' => $payload,
86
+ 'device_id' => $payload['id'] ?? null,
87
+ 'external_id' => $payload['id'] ?? null,
88
+ 'name' => $payload['name'] ?? 'Unknown Device',
89
+ 'provider' => 'samsara',
90
+ 'model' => $payload['make'] ?? $payload['model'] ?? null,
91
+ 'vin' => $payload['vin'] ?? null,
92
+ 'serial_number' => $payload['serial'] ?? $payload['serialNumber'] ?? null,
93
+ 'license_plate' => $payload['licensePlate'] ?? null,
94
+ 'status' => $this->normalizeStatus($payload),
95
+ 'online' => $this->resolveOnline($payload),
96
+ 'last_seen_at' => $this->parseTimestamp($payload['time'] ?? data_get($payload, 'location.time') ?? data_get($payload, 'gps.time') ?? null),
97
+ 'location' => [
98
+ 'lat' => $position['lat'] ?? null,
99
+ 'lng' => $position['lng'] ?? null,
100
+ ],
101
+ 'speed' => $this->extractSpeed($payload),
102
+ 'heading' => $this->extractHeading($payload),
103
+ 'altitude' => $this->extractAltitude($payload),
104
+ 'odometer' => data_get($payload, 'odometerMeters') ?? data_get($payload, 'obdOdometerMeters.value'),
105
+ 'fuel_level' => data_get($payload, 'fuelPercent.value') ?? data_get($payload, 'fuelPercent'),
106
+ 'meta' => [
107
+ 'raw' => $payload,
108
+ 'provider_status' => array_filter([
109
+ 'status' => $payload['status'] ?? null,
110
+ 'gateway_status' => data_get($payload, 'gateway.status'),
111
+ 'online' => $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online'),
112
+ ], fn ($value) => $value !== null),
113
+ ],
90
114
  ];
91
115
  }
92
116
 
93
117
  public function normalizeEvent(array $payload): array
94
118
  {
119
+ $position = $this->extractPosition($payload);
120
+ $deviceId = data_get($payload, 'vehicle.id') ?? $payload['vehicleId'] ?? $payload['id'] ?? null;
121
+
95
122
  return [
96
- 'external_id' => $payload['id'] ?? null,
97
- 'device_id' => $payload['vehicle']['id'] ?? $payload['vehicleId'] ?? null,
123
+ 'external_id' => $payload['id'] ?? $deviceId,
124
+ 'device_id' => $deviceId,
98
125
  'event_type' => $payload['eventType'] ?? 'vehicle_update',
99
- 'occurred_at' => $payload['time'] ?? now(),
126
+ 'occurred_at' => $this->parseTimestamp($payload['time'] ?? data_get($payload, 'location.time') ?? data_get($payload, 'gps.time') ?? null) ?? now(),
127
+ 'online' => $this->resolveOnline($payload),
100
128
  'location' => [
101
- 'lat' => $payload['location']['latitude'] ?? null,
102
- 'lng' => $payload['location']['longitude'] ?? null,
129
+ 'lat' => $position['lat'] ?? null,
130
+ 'lng' => $position['lng'] ?? null,
131
+ ],
132
+ 'speed' => $this->extractSpeed($payload),
133
+ 'heading' => $this->extractHeading($payload),
134
+ 'altitude' => $this->extractAltitude($payload),
135
+ 'odometer' => data_get($payload, 'odometerMeters') ?? data_get($payload, 'obdOdometerMeters.value'),
136
+ 'fuel_level' => data_get($payload, 'fuelPercent.value') ?? data_get($payload, 'fuelPercent'),
137
+ 'meta' => [
138
+ 'raw' => $payload,
139
+ 'provider_status' => array_filter([
140
+ 'status' => $payload['status'] ?? null,
141
+ 'gateway_status' => data_get($payload, 'gateway.status'),
142
+ 'online' => $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online'),
143
+ ], fn ($value) => $value !== null),
103
144
  ],
104
- 'meta' => $payload,
105
145
  ];
106
146
  }
107
147
 
@@ -176,4 +216,72 @@ class SamsaraProvider extends AbstractProvider
176
216
  {
177
217
  return true;
178
218
  }
219
+
220
+ protected function extractPosition(array $payload): array
221
+ {
222
+ $position = $payload['location']
223
+ ?? $payload['gps']
224
+ ?? $payload['currentLocation']
225
+ ?? $payload['lastKnownLocation']
226
+ ?? data_get($payload, 'vehicle.location')
227
+ ?? [];
228
+
229
+ return [
230
+ 'lat' => $position['latitude'] ?? $position['lat'] ?? null,
231
+ 'lng' => $position['longitude'] ?? $position['lng'] ?? null,
232
+ ];
233
+ }
234
+
235
+ protected function extractSpeed(array $payload): mixed
236
+ {
237
+ return data_get($payload, 'location.speedMilesPerHour')
238
+ ?? data_get($payload, 'location.speed')
239
+ ?? data_get($payload, 'gps.speedMilesPerHour')
240
+ ?? data_get($payload, 'speedMilesPerHour')
241
+ ?? data_get($payload, 'speed');
242
+ }
243
+
244
+ protected function extractHeading(array $payload): mixed
245
+ {
246
+ return data_get($payload, 'location.headingDegrees')
247
+ ?? data_get($payload, 'location.heading')
248
+ ?? data_get($payload, 'gps.headingDegrees')
249
+ ?? data_get($payload, 'headingDegrees')
250
+ ?? data_get($payload, 'heading');
251
+ }
252
+
253
+ protected function extractAltitude(array $payload): mixed
254
+ {
255
+ return data_get($payload, 'location.altitudeMeters')
256
+ ?? data_get($payload, 'gps.altitudeMeters')
257
+ ?? data_get($payload, 'altitudeMeters')
258
+ ?? data_get($payload, 'altitude');
259
+ }
260
+
261
+ protected function parseTimestamp($value): ?string
262
+ {
263
+ if (!$value) {
264
+ return null;
265
+ }
266
+
267
+ return Carbon::parse($value)->toDateTimeString();
268
+ }
269
+
270
+ protected function resolveOnline(array $payload): ?bool
271
+ {
272
+ $value = $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online') ?? null;
273
+
274
+ if ($value === null) {
275
+ return $this->extractPosition($payload)['lat'] !== null;
276
+ }
277
+
278
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value;
279
+ }
280
+
281
+ protected function normalizeStatus(array $payload): string
282
+ {
283
+ $status = strtolower((string) ($payload['status'] ?? data_get($payload, 'gateway.status') ?? 'active'));
284
+
285
+ return in_array($status, ['inactive', 'offline', 'deactivated'], true) ? 'inactive' : 'active';
286
+ }
179
287
  }
@@ -476,7 +476,6 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
476
476
  $router->delete('bulk-delete', $controller('bulkDelete'));
477
477
  $router->get('for-route', $controller('getServicesForRoute'));
478
478
  $router->match(['get', 'post'], 'export', $controller('export'));
479
- $router->get('for-route', $controller('getServicesForRoute'));
480
479
  }
481
480
  );
482
481
  $router->fleetbaseRoutes('tracking-numbers');
@@ -9,6 +9,8 @@ test('device filter exposes telematics detail filter contract', function () {
9
9
  ->toContain("'vehicle'")
10
10
  ->toContain("'connection_status'")
11
11
  ->toContain("'device_id'")
12
+ ->toContain("'type'")
13
+ ->toContain("'serial_number'")
12
14
  ->toContain("'last_online_at'")
13
15
  ->toContain("'updated_at'");
14
16
 
@@ -16,8 +18,13 @@ test('device filter exposes telematics detail filter contract', function () {
16
18
  ->toContain('public function query(?string $searchQuery)')
17
19
  ->toContain('public function deviceId(?string $deviceId)')
18
20
  ->toContain("where('device_id', 'like'")
21
+ ->toContain('public function type(string|array|null $type)')
22
+ ->toContain("whereIn('type', \$type)")
23
+ ->toContain('public function serialNumber(?string $serialNumber)')
24
+ ->toContain("where('serial_number', 'like'")
19
25
  ->toContain('public function vehicle(?string $vehicle)')
20
26
  ->toContain("where('attachable_uuid', \$vehicle)")
27
+ ->toContain("where('public_id', \$vehicle)")
21
28
  ->toContain('public function connectionStatus')
22
29
  ->toContain("'online'")
23
30
  ->toContain("'recently_offline'")
@@ -30,7 +37,9 @@ test('device filter exposes telematics detail filter contract', function () {
30
37
  ->toContain('protected function filterDate');
31
38
 
32
39
  expect($controller)
40
+ ->toContain("withCount('sensors')")
33
41
  ->toContain("filled('connection_status')")
42
+ ->toContain("filled('serial_number')")
34
43
  ->toContain("filled('last_online_at')")
35
44
  ->toContain("filled('updated_at')");
36
45
  });
@@ -1,16 +1,19 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Equipment;
4
5
  use Fleetbase\FleetOps\Models\Vehicle;
5
6
 
6
- test('driver and vehicle statuses default to available', function () {
7
+ test('driver vehicle and equipment statuses default to available', function () {
7
8
  expect((new Driver())->status)
8
9
  ->toBe('available')
9
10
  ->and((new Vehicle())->status)
11
+ ->toBe('available')
12
+ ->and((new Equipment())->status)
10
13
  ->toBe('available');
11
14
  });
12
15
 
13
- test('legacy active and null driver and vehicle statuses normalize to available', function () {
16
+ test('legacy active and null driver vehicle and equipment statuses normalize to available', function () {
14
17
  $driver = new Driver();
15
18
  $driver->status = 'active';
16
19
  expect($driver->status)->toBe('available');
@@ -24,6 +27,20 @@ test('legacy active and null driver and vehicle statuses normalize to available'
24
27
 
25
28
  $vehicle->status = null;
26
29
  expect($vehicle->status)->toBe('available');
30
+
31
+ $equipment = new Equipment();
32
+ $equipment->status = 'active';
33
+ expect($equipment->status)->toBe('available');
34
+
35
+ $equipment->status = null;
36
+ expect($equipment->status)->toBe('available');
37
+ });
38
+
39
+ test('equipment preserves explicit non default status values', function () {
40
+ $equipment = new Equipment();
41
+ $equipment->status = 'maintenance';
42
+
43
+ expect($equipment->status)->toBe('maintenance');
27
44
  });
28
45
 
29
46
  test('dispatch driver availability queries use available status', function () {
@@ -0,0 +1,35 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Equipment;
5
+ use Fleetbase\FleetOps\Models\Vehicle;
6
+ use Illuminate\Database\Eloquent\Relations\Relation;
7
+
8
+ test('equipment assignment type aliases normalize to model classes', function () {
9
+ $equipment = new Equipment();
10
+
11
+ $equipment->equipable_type = 'fleet-ops:vehicle';
12
+ expect($equipment->equipable_type)->toBe(Vehicle::class);
13
+
14
+ $equipment->equipable_type = 'fleet-ops:driver';
15
+ expect($equipment->equipable_type)->toBe(Driver::class);
16
+ });
17
+
18
+ test('equipment assignment type preserves null and existing model classes', function () {
19
+ $equipment = new Equipment();
20
+
21
+ $equipment->equipable_type = null;
22
+ expect($equipment->equipable_type)->toBeNull();
23
+
24
+ $equipment->equipable_type = Vehicle::class;
25
+ expect($equipment->equipable_type)->toBe(Vehicle::class);
26
+ });
27
+
28
+ test('legacy equipment assignment aliases are registered for morph reads', function () {
29
+ new Equipment();
30
+
31
+ expect(Relation::getMorphedModel('fleet-ops:vehicle'))
32
+ ->toBe(Vehicle::class)
33
+ ->and(Relation::getMorphedModel('fleet-ops:driver'))
34
+ ->toBe(Driver::class);
35
+ });
@@ -0,0 +1,101 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\ServiceRateController;
4
+ use Illuminate\Http\Request;
5
+ use Illuminate\Support\Collection;
6
+
7
+ function fleetopsServiceRateRouteRequest(array $query = []): Request
8
+ {
9
+ $request = Request::create('/int/v1/service-rates/for-route', 'GET', $query);
10
+ $session = app('session.store');
11
+ $session->put('company', 'company_test');
12
+ $request->setLaravelSession($session);
13
+
14
+ return $request;
15
+ }
16
+
17
+ function fleetopsServiceRateRouteController()
18
+ {
19
+ return new class extends ServiceRateController {
20
+ public ?Collection $waypoints = null;
21
+ public ?Closure $queryCallback = null;
22
+
23
+ protected function getServicableForWaypoints(Collection $waypoints, Closure $queryCallback): array
24
+ {
25
+ $this->waypoints = $waypoints;
26
+ $this->queryCallback = $queryCallback;
27
+
28
+ return [['id' => 'service_rate_test']];
29
+ }
30
+ };
31
+ }
32
+
33
+ test('service rates for route requires coordinates', function ($query) {
34
+ $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest($query));
35
+
36
+ expect($response->getStatusCode())->toBe(422);
37
+ })->with([
38
+ 'missing coordinates' => [[]],
39
+ 'empty coordinates' => [['coordinates' => '']],
40
+ ]);
41
+
42
+ test('service rates for route requires at least two coordinates', function () {
43
+ $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest([
44
+ 'coordinates' => '1.3621663,103.8845049',
45
+ ]));
46
+
47
+ expect($response->getStatusCode())->toBe(422);
48
+ });
49
+
50
+ test('service rates for route rejects malformed coordinates', function ($coordinates) {
51
+ $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest([
52
+ 'coordinates' => $coordinates,
53
+ ]));
54
+
55
+ expect($response->getStatusCode())->toBe(422);
56
+ })->with([
57
+ 'missing longitude' => ['1.3621663;1.353151,103.86458'],
58
+ 'missing latitude' => [',103.8845049;1.353151,103.86458'],
59
+ 'blank segment' => ['1.3621663,103.8845049;;1.353151,103.86458'],
60
+ 'non numeric' => ['north,103.8845049;1.353151,103.86458'],
61
+ ]);
62
+
63
+ test('service rates for route converts coordinate pairs to waypoints', function () {
64
+ $controller = fleetopsServiceRateRouteController();
65
+ $response = $controller->getServicesForRoute(fleetopsServiceRateRouteRequest([
66
+ 'coordinates' => '1.3621663,103.8845049;1.353151,103.86458',
67
+ 'service_type' => 'delivery',
68
+ ]));
69
+
70
+ expect($response->getStatusCode())->toBe(200)
71
+ ->and($controller->waypoints)->toHaveCount(2)
72
+ ->and($controller->waypoints->first()->x())->toBe(103.8845049)
73
+ ->and($controller->waypoints->first()->y())->toBe(1.3621663);
74
+ });
75
+
76
+ test('service rates for route ignores placeholder service type values', function ($serviceType) {
77
+ $controller = fleetopsServiceRateRouteController();
78
+ $controller->getServicesForRoute(fleetopsServiceRateRouteRequest([
79
+ 'coordinates' => '1.3621663,103.8845049;1.353151,103.86458',
80
+ 'service_type' => $serviceType,
81
+ ]));
82
+
83
+ $query = new class {
84
+ public array $wheres = [];
85
+
86
+ public function where($column, $value)
87
+ {
88
+ $this->wheres[] = [$column, $value];
89
+
90
+ return $this;
91
+ }
92
+ };
93
+
94
+ ($controller->queryCallback)($query);
95
+
96
+ expect($query->wheres)->toBe([['company_uuid', 'company_test']]);
97
+ })->with([
98
+ 'undefined' => ['undefined'],
99
+ 'null' => ['null'],
100
+ 'empty' => [''],
101
+ ]);