@fleetbase/fleetops-engine 0.6.52 → 0.6.53

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 (60) hide show
  1. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.hbs +201 -0
  2. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +589 -0
  3. package/addon/components/layout/fleet-ops-sidebar.hbs +19 -233
  4. package/addon/components/layout/fleet-ops-sidebar.js +287 -394
  5. package/addon/components/maintenance-schedule/form.js +1 -0
  6. package/addon/components/order/form/payload.js +3 -1
  7. package/addon/controllers/connectivity/devices/index.js +4 -1
  8. package/addon/controllers/maintenance/index.js +100 -0
  9. package/addon/controllers/management/fuel-transactions/index.js +1 -1
  10. package/addon/controllers/management/index.js +88 -0
  11. package/addon/controllers/settings/index.js +142 -0
  12. package/addon/extension.js +21 -0
  13. package/addon/routes/analytics/index.js +3 -0
  14. package/addon/routes/connectivity/devices/index.js +3 -0
  15. package/addon/routes/maintenance/index.js +3 -0
  16. package/addon/routes/maintenance/work-orders/index.js +2 -0
  17. package/addon/routes/settings/index.js +3 -0
  18. package/addon/styles/fleetops-engine.css +1801 -2
  19. package/addon/templates/analytics/index.hbs +14 -0
  20. package/addon/templates/analytics/reports/index.hbs +4 -4
  21. package/addon/templates/maintenance/index.hbs +113 -0
  22. package/addon/templates/management/fuel-transactions/index/details.hbs +1 -1
  23. package/addon/templates/management/index.hbs +109 -73
  24. package/addon/templates/operations/orders/index.hbs +1 -1
  25. package/addon/templates/operations/service-rates/index.hbs +1 -1
  26. package/addon/templates/settings/index.hbs +83 -0
  27. package/addon/templates/settings/routing.hbs +2 -2
  28. package/app/controllers/maintenance/index.js +1 -0
  29. package/app/controllers/management/index.js +1 -0
  30. package/app/controllers/settings/index.js +1 -0
  31. package/app/routes/analytics/index.js +1 -0
  32. package/app/routes/maintenance/index.js +1 -0
  33. package/app/routes/settings/index.js +1 -0
  34. package/app/templates/analytics/index.js +1 -0
  35. package/app/templates/maintenance/index.js +1 -0
  36. package/app/templates/management/index.js +1 -0
  37. package/app/templates/settings/index.js +1 -0
  38. package/composer.json +1 -1
  39. package/extension.json +1 -1
  40. package/package.json +3 -3
  41. package/server/migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php +30 -0
  42. package/server/src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php +1 -0
  43. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +75 -11
  44. package/server/src/Http/Controllers/Internal/v1/HubController.php +300 -0
  45. package/server/src/Http/Controllers/Internal/v1/SearchController.php +506 -0
  46. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +94 -10
  48. package/server/src/Http/Filter/DeviceFilter.php +74 -0
  49. package/server/src/Http/Filter/DriverFilter.php +6 -0
  50. package/server/src/Http/Filter/VehicleFilter.php +6 -0
  51. package/server/src/Jobs/SyncTelematicDevicesJob.php +82 -24
  52. package/server/src/Models/Device.php +1 -1
  53. package/server/src/Models/Driver.php +1 -1
  54. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  55. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +51 -13
  56. package/server/src/routes.php +9 -0
  57. package/server/tests/HubRoutesTest.php +62 -0
  58. package/server/tests/TelematicsHardeningTest.php +94 -3
  59. package/translations/ar-ae.yml +1 -1
  60. package/translations/vi-vn.yaml +1 -1
@@ -0,0 +1,74 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Filter;
4
+
5
+ use Fleetbase\FleetOps\Support\Utils;
6
+ use Fleetbase\Http\Filter\Filter;
7
+
8
+ class DeviceFilter extends Filter
9
+ {
10
+ public function queryForInternal()
11
+ {
12
+ $this->builder->where('company_uuid', $this->session->get('company'));
13
+ }
14
+
15
+ public function queryForPublic()
16
+ {
17
+ $this->queryForInternal();
18
+ }
19
+
20
+ public function query(?string $searchQuery)
21
+ {
22
+ $this->builder->search($searchQuery);
23
+ }
24
+
25
+ public function status(string|array $status)
26
+ {
27
+ $status = Utils::arrayFrom($status);
28
+
29
+ if ($status) {
30
+ $this->builder->whereIn('status', $status);
31
+ }
32
+ }
33
+
34
+ public function telematic(?string $telematic)
35
+ {
36
+ $this->builder->where('telematic_uuid', $telematic);
37
+ }
38
+
39
+ public function telematicUuid(?string $telematic)
40
+ {
41
+ $this->telematic($telematic);
42
+ }
43
+
44
+ public function provider(?string $provider)
45
+ {
46
+ $this->builder->where('provider', $provider);
47
+ }
48
+
49
+ public function warrantyUuid(?string $warranty)
50
+ {
51
+ $this->builder->where('warranty_uuid', $warranty);
52
+ }
53
+
54
+ public function attachableType(?string $attachableType)
55
+ {
56
+ $this->builder->where('attachable_type', $attachableType);
57
+ }
58
+
59
+ public function attachableUuid(?string $attachable)
60
+ {
61
+ $this->builder->where('attachable_uuid', $attachable);
62
+ }
63
+
64
+ public function attachmentState(?string $attachmentState)
65
+ {
66
+ if ($attachmentState === 'attached') {
67
+ $this->builder->whereNotNull('attachable_uuid');
68
+ }
69
+
70
+ if ($attachmentState === 'unattached') {
71
+ $this->builder->whereNull('attachable_uuid');
72
+ }
73
+ }
74
+ }
@@ -70,6 +70,12 @@ class DriverFilter extends Filter
70
70
 
71
71
  public function vehicle(string $vehicle)
72
72
  {
73
+ if ($vehicle === 'unassigned') {
74
+ $this->builder->whereNull('vehicle_uuid');
75
+
76
+ return;
77
+ }
78
+
73
79
  if (Str::isUuid($vehicle)) {
74
80
  $this->builder->where('vehicle_uuid', $vehicle);
75
81
  } else {
@@ -59,6 +59,12 @@ class VehicleFilter extends Filter
59
59
 
60
60
  public function driver(?string $driverId)
61
61
  {
62
+ if ($driverId === 'unassigned') {
63
+ $this->builder->whereDoesntHave('driver');
64
+
65
+ return;
66
+ }
67
+
62
68
  $this->builder->whereHas(
63
69
  'driver',
64
70
  function ($query) use ($driverId) {
@@ -53,56 +53,107 @@ class SyncTelematicDevicesJob implements ShouldQueue
53
53
  'provider' => $this->telematic->provider,
54
54
  ]);
55
55
 
56
+ $cursor = null;
57
+ $totalFetched = 0;
58
+ $totalLinked = 0;
59
+ $totalSkipped = 0;
60
+ $pageCount = 0;
61
+ $lastProviderAllCount = null;
62
+ $lastProviderFiltersCount = null;
63
+
56
64
  try {
57
65
  $provider = $registry->resolve($this->telematic->provider);
58
66
  $provider->connect($this->telematic);
59
67
 
60
- $cursor = null;
61
- $totalSynced = 0;
62
-
63
68
  do {
64
69
  $response = $provider->fetchDevices([
65
- 'limit' => $this->options['limit'] ?? 100,
70
+ 'limit' => $this->options['limit'] ?? null,
66
71
  'cursor' => $cursor,
67
72
  'filters' => $this->options['filters'] ?? [],
68
73
  ]);
69
74
 
70
- foreach ($response['devices'] as $devicePayload) {
75
+ $devices = $response['devices'] ?? [];
76
+ $pagination = $response['pagination'] ?? [];
77
+ $pageCount++;
78
+ $totalFetched += count($devices);
79
+
80
+ if (isset($pagination['allCount'])) {
81
+ $lastProviderAllCount = $pagination['allCount'];
82
+ }
83
+
84
+ if (isset($pagination['filtersCount'])) {
85
+ $lastProviderFiltersCount = $pagination['filtersCount'];
86
+ }
87
+
88
+ Log::info('Device discovery page fetched', [
89
+ 'correlation_id' => $correlationId,
90
+ 'telematic_uuid' => $this->telematic->uuid,
91
+ 'provider' => $this->telematic->provider,
92
+ 'page' => $pageCount,
93
+ 'cursor' => $cursor,
94
+ 'limit' => $pagination['limit'] ?? ($this->options['limit'] ?? null),
95
+ 'result_count' => count($devices),
96
+ 'all_count' => $lastProviderAllCount,
97
+ 'filters_count' => $lastProviderFiltersCount,
98
+ 'next_cursor' => $response['next_cursor'] ?? null,
99
+ 'has_more' => $response['has_more'] ?? false,
100
+ ]);
101
+
102
+ foreach ($devices as $devicePayload) {
71
103
  $normalizedDevice = $provider->normalizeDevice($devicePayload);
72
104
  try {
73
105
  $service->linkDevice($this->telematic, $normalizedDevice);
74
- $totalSynced++;
106
+ $totalLinked++;
75
107
  } catch (\Illuminate\Validation\ValidationException $e) {
108
+ $totalSkipped++;
76
109
  Log::warning('Skipping telematics device without provider identity', [
77
- 'correlation_id' => $correlationId,
78
- 'provider' => $this->telematic->provider,
110
+ 'correlation_id' => $correlationId,
111
+ 'provider' => $this->telematic->provider,
112
+ 'provider_unit_id' => $devicePayload['_id'] ?? $devicePayload['id'] ?? null,
113
+ 'device_id' => $normalizedDevice['device_id'] ?? null,
114
+ 'name' => $normalizedDevice['name'] ?? $devicePayload['name'] ?? null,
115
+ 'imei' => $devicePayload['imei'] ?? null,
79
116
  ]);
80
117
  }
81
118
  }
82
119
 
83
- $cursor = $response['next_cursor'];
120
+ $cursor = $response['next_cursor'] ?? null;
84
121
 
85
122
  Log::info('Device discovery progress', [
86
123
  'correlation_id' => $correlationId,
87
- 'synced' => $totalSynced,
88
- 'has_more' => $response['has_more'],
124
+ 'fetched' => $totalFetched,
125
+ 'linked' => $totalLinked,
126
+ 'skipped' => $totalSkipped,
127
+ 'has_more' => $response['has_more'] ?? false,
89
128
  ]);
90
129
 
91
130
  // Broadcast progress (TODO: implement WebSocket broadcasting)
92
- } while ($response['has_more'] && $cursor);
131
+ } while (($response['has_more'] ?? false) && $cursor);
93
132
 
94
133
  Log::info('Device discovery completed', [
95
- 'correlation_id' => $correlationId,
96
- 'total_synced' => $totalSynced,
134
+ 'correlation_id' => $correlationId,
135
+ 'total_fetched' => $totalFetched,
136
+ 'total_linked' => $totalLinked,
137
+ 'total_skipped' => $totalSkipped,
138
+ 'page_count' => $pageCount,
139
+ 'provider_all_count' => $lastProviderAllCount,
140
+ 'provider_filter_count' => $lastProviderFiltersCount,
97
141
  ]);
98
142
 
99
143
  $this->telematic->status = 'active';
100
144
  $this->telematic->meta = array_merge($this->telematic->meta ?? [], [
101
- 'last_sync_job_id' => $this->jobId,
102
- 'last_sync_completed_at' => now()->toDateTimeString(),
103
- 'last_sync_result' => 'success',
104
- 'last_sync_total' => $totalSynced,
105
- 'last_sync_error' => null,
145
+ 'last_sync_job_id' => $this->jobId,
146
+ 'last_sync_completed_at' => now()->toDateTimeString(),
147
+ 'last_sync_result' => 'success',
148
+ 'last_sync_total' => $totalLinked,
149
+ 'last_sync_fetched_total' => $totalFetched,
150
+ 'last_sync_linked_total' => $totalLinked,
151
+ 'last_sync_skipped_total' => $totalSkipped,
152
+ 'last_sync_page_count' => $pageCount,
153
+ 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
154
+ 'last_sync_provider_all_count' => $lastProviderAllCount,
155
+ 'last_sync_provider_filters_count' => $lastProviderFiltersCount,
156
+ 'last_sync_error' => null,
106
157
  ]);
107
158
  $this->telematic->save();
108
159
  } catch (\Exception $e) {
@@ -114,11 +165,18 @@ class SyncTelematicDevicesJob implements ShouldQueue
114
165
 
115
166
  $this->telematic->status = 'error';
116
167
  $this->telematic->meta = array_merge($this->telematic->meta ?? [], [
117
- 'last_sync_job_id' => $this->jobId,
118
- 'last_sync_result' => 'failed',
119
- 'last_sync_error' => 'Device sync failed. Review the provider connection and server logs, then try again.',
120
- 'last_sync_error_type' => class_basename($e),
121
- 'last_sync_failed_at' => now()->toDateTimeString(),
168
+ 'last_sync_job_id' => $this->jobId,
169
+ 'last_sync_result' => 'failed',
170
+ 'last_sync_fetched_total' => $totalFetched,
171
+ 'last_sync_linked_total' => $totalLinked,
172
+ 'last_sync_skipped_total' => $totalSkipped,
173
+ 'last_sync_page_count' => $pageCount,
174
+ 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
175
+ 'last_sync_provider_all_count' => $lastProviderAllCount,
176
+ 'last_sync_provider_filters_count' => $lastProviderFiltersCount,
177
+ 'last_sync_error' => 'Device sync failed. Review the provider connection and server logs, then try again.',
178
+ 'last_sync_error_type' => class_basename($e),
179
+ 'last_sync_failed_at' => now()->toDateTimeString(),
122
180
  ]);
123
181
  $this->telematic->save();
124
182
 
@@ -74,7 +74,7 @@ class Device extends Model
74
74
  *
75
75
  * @var array
76
76
  */
77
- protected $filterParams = ['status', 'warranty_uuid', 'attachable_type', 'attachable_uuid', 'telematic_uuid', 'provider'];
77
+ protected $filterParams = ['status', 'attachment_state', 'warranty_uuid', 'attachable_type', 'attachable_uuid', 'telematic', 'telematic_uuid', 'provider'];
78
78
 
79
79
  /**
80
80
  * The attributes that are mass assignable.
@@ -196,7 +196,7 @@ class Driver extends Model
196
196
  *
197
197
  * @var array
198
198
  */
199
- protected $filterParams = ['vendor', 'facilitator', 'customer', 'fleet', 'photo_uuid', 'avatar_uuid', 'avatar_value'];
199
+ protected $filterParams = ['vendor', 'facilitator', 'customer', 'fleet', 'vehicle', 'photo_uuid', 'avatar_uuid', 'avatar_value'];
200
200
 
201
201
  /**
202
202
  * The session-agnostic columns for the model.
@@ -7,6 +7,7 @@ use Brick\Geo\Engine\GEOSEngine;
7
7
  use Fleetbase\Providers\CoreServiceProvider;
8
8
  use Fleetbase\Support\NotificationRegistry;
9
9
  use Fleetbase\Support\Utils;
10
+ use Illuminate\Database\Eloquent\Relations\Relation;
10
11
 
11
12
  if (!Utils::classExists(CoreServiceProvider::class)) {
12
13
  throw new \Exception('FleetOps cannot be loaded without `fleetbase/core-api` installed!');
@@ -129,6 +130,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
129
130
  */
130
131
  public function boot()
131
132
  {
133
+ $this->registerMorphMap();
132
134
  $this->registerObservers();
133
135
  $this->registerCommands();
134
136
  $this->scheduleCommands(function ($schedule) {
@@ -194,6 +196,14 @@ class FleetOpsServiceProvider extends CoreServiceProvider
194
196
  }
195
197
  }
196
198
 
199
+ public function registerMorphMap(): void
200
+ {
201
+ Relation::morphMap([
202
+ 'Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
203
+ '\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
204
+ ]);
205
+ }
206
+
197
207
  public function registerNotifications()
198
208
  {
199
209
  // Register Notifications
@@ -4,6 +4,7 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
4
 
5
5
  use Illuminate\Support\Carbon;
6
6
  use Illuminate\Support\Facades\Http;
7
+ use Illuminate\Support\Facades\Log;
7
8
 
8
9
  /**
9
10
  * AFAQY telematics provider implementation.
@@ -40,7 +41,7 @@ class AfaqyProvider extends AbstractProvider
40
41
  'projection' => ['_id', 'name', 'imei', 'last_update'],
41
42
  'filters' => new \stdClass(),
42
43
  ],
43
- ]);
44
+ ], true);
44
45
 
45
46
  return [
46
47
  'success' => true,
@@ -61,13 +62,20 @@ class AfaqyProvider extends AbstractProvider
61
62
 
62
63
  public function fetchDevices(array $options = []): array
63
64
  {
64
- $limit = (int) ($options['limit'] ?? 100);
65
- $offset = (int) ($options['cursor'] ?? 0);
65
+ $limit = max(1, min((int) ($options['limit'] ?? 500), 500));
66
+ $offset = (int) ($options['cursor'] ?? 0);
67
+ $filters = $options['filters'] ?? new \stdClass();
68
+
69
+ if (is_array($filters) && empty($filters)) {
70
+ $filters = new \stdClass();
71
+ }
66
72
 
67
73
  $response = $this->afaqyPost('/units/lists', [
68
74
  'data' => [
69
75
  'address' => $options['address'] ?? false,
70
- 'filters' => $options['filters'] ?? new \stdClass(),
76
+ 'filters' => $filters,
77
+ 'limit' => $limit,
78
+ 'offset' => $offset,
71
79
  'projection' => $options['projection'] ?? [
72
80
  '_id',
73
81
  'name',
@@ -82,19 +90,42 @@ class AfaqyProvider extends AbstractProvider
82
90
  'sensors_last_val',
83
91
  ],
84
92
  ],
85
- 'limit' => $limit,
86
- 'offset' => $offset,
93
+ ], true);
94
+
95
+ $devices = $response['data'] ?? [];
96
+ $pagination = $response['pagination'] ?? [];
97
+ $pageOffset = (int) ($pagination['offset'] ?? $offset);
98
+ $pageLimit = (int) ($pagination['limit'] ?? $limit);
99
+ $resultCount = (int) ($pagination['resultCount'] ?? count($devices));
100
+ $total = (int) ($pagination['filtersCount'] ?? $pagination['allCount'] ?? count($devices));
101
+ $advanceBy = $resultCount > 0 ? $resultCount : max($pageLimit, count($devices), 1);
102
+ $nextCursor = ($pageOffset + $advanceBy) < $total ? $pageOffset + $advanceBy : null;
103
+
104
+ Log::info('AFAQY units page fetched', [
105
+ 'telematic_uuid' => $this->telematic?->uuid,
106
+ 'requested_offset' => $offset,
107
+ 'requested_limit' => $limit,
108
+ 'provider_offset' => $pageOffset,
109
+ 'provider_limit' => $pageLimit,
110
+ 'provider_result_count' => $resultCount,
111
+ 'result_count' => count($devices),
112
+ 'all_count' => $pagination['allCount'] ?? null,
113
+ 'filters_count' => $pagination['filtersCount'] ?? null,
114
+ 'next_cursor' => $nextCursor,
115
+ 'has_more' => $nextCursor !== null,
87
116
  ]);
88
117
 
89
- $devices = $response['data'] ?? [];
90
- $pagination = $response['pagination'] ?? [];
91
- $total = (int) ($pagination['filtersCount'] ?? $pagination['allCount'] ?? count($devices));
92
- $nextCursor = ($offset + $limit) < $total ? $offset + $limit : null;
93
-
94
118
  return [
95
119
  'devices' => $devices,
96
120
  'next_cursor' => $nextCursor,
97
121
  'has_more' => $nextCursor !== null,
122
+ 'pagination' => [
123
+ 'allCount' => $pagination['allCount'] ?? null,
124
+ 'filtersCount' => $pagination['filtersCount'] ?? null,
125
+ 'resultCount' => $resultCount,
126
+ 'offset' => $pageOffset,
127
+ 'limit' => $pageLimit,
128
+ ],
98
129
  ];
99
130
  }
100
131
 
@@ -252,11 +283,18 @@ class AfaqyProvider extends AbstractProvider
252
283
  return $token;
253
284
  }
254
285
 
255
- protected function afaqyPost(string $endpoint, array $payload = []): array
286
+ protected function afaqyPost(string $endpoint, array $payload = [], bool $tokenInQuery = false): array
256
287
  {
288
+ $url = $this->baseUrl . $endpoint;
289
+ $body = $tokenInQuery ? $payload : array_merge(['token' => $this->credentials['token']], $payload);
290
+
291
+ if ($tokenInQuery) {
292
+ $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query(['token' => $this->credentials['token']]);
293
+ }
294
+
257
295
  $response = Http::withHeaders($this->headers)
258
296
  ->timeout(30)
259
- ->post($this->baseUrl . $endpoint, array_merge(['token' => $this->credentials['token']], $payload));
297
+ ->post($url, $body);
260
298
 
261
299
  if ($response->failed()) {
262
300
  throw new \RuntimeException('AFAQY API request failed with status ' . $response->status());
@@ -305,6 +305,8 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
305
305
  ],
306
306
  ],
307
307
  function ($router) {
308
+ $router->get('search', 'SearchController@search');
309
+
308
310
  $router->fleetbaseRoutes(
309
311
  'contacts',
310
312
  function ($router, $controller) {
@@ -671,6 +673,13 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
671
673
  $router->get('live-fleet', 'AnalyticsController@liveFleet');
672
674
  }
673
675
  );
676
+ $router->group(
677
+ ['prefix' => 'hubs'],
678
+ function ($router) {
679
+ $router->get('resources', 'HubController@resources');
680
+ $router->get('maintenance', 'HubController@maintenance');
681
+ }
682
+ );
674
683
  $router->group(
675
684
  ['prefix' => 'getting-started'],
676
685
  function ($router) {
@@ -0,0 +1,62 @@
1
+ <?php
2
+
3
+ test('hub routes are registered', function () {
4
+ $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
5
+
6
+ expect($routes)
7
+ ->toContain("['prefix' => 'hubs']")
8
+ ->toContain("\$router->get('resources', 'HubController@resources');")
9
+ ->toContain("\$router->get('maintenance', 'HubController@maintenance');");
10
+ });
11
+
12
+ test('hub controller exposes resources and maintenance contracts', function () {
13
+ $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/HubController.php');
14
+
15
+ expect($controller)
16
+ ->toContain('public function resources(Request $request)')
17
+ ->toContain('public function maintenance(Request $request)')
18
+ ->toContain("'kpis'")
19
+ ->toContain("'actions'")
20
+ ->toContain("'sections'")
21
+ ->toContain("'docs'");
22
+ });
23
+
24
+ test('resources hub action queue includes cross workflow resource actions', function () {
25
+ $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/HubController.php');
26
+
27
+ expect($controller)
28
+ ->toContain("'drivers_without_vehicles'")
29
+ ->toContain("'vehicles_without_drivers'")
30
+ ->toContain("'vehicles_without_devices'")
31
+ ->toContain("'unattached_devices'")
32
+ ->toContain("'resource_issues'")
33
+ ->toContain("'overdue_vehicle_schedules'")
34
+ ->toContain("'open_resource_work_orders'")
35
+ ->toContain("'low_stock_parts'")
36
+ ->toContain("'unmatched_fuel_transactions'")
37
+ ->toContain("'assign_vehicles_to_drivers', 'Assign vehicles to drivers'")
38
+ ->toContain("'assign_drivers_to_vehicles', 'Assign drivers to vehicles'")
39
+ ->toContain("'attach_devices_to_vehicles', 'Attach devices to vehicles'")
40
+ ->toContain("'prepare_vehicle_maintenance', 'Prepare upcoming maintenance'")
41
+ ->toContain("'close_overdue_work_orders', 'Close overdue work orders'")
42
+ ->toContain("'replenish_low_stock_parts', 'Replenish low-stock parts'")
43
+ ->toContain("'review_unmatched_fuel_transactions', 'Review unmatched fuel transactions'")
44
+ ->toContain("'add_operating_places', 'Add operating places'")
45
+ ->toContain("'add_service_vendors', 'Add vendors for service work'")
46
+ ->toContain("'Core resources look ready'");
47
+ });
48
+
49
+ test('resource action target filters are supported', function () {
50
+ $deviceFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/DeviceFilter.php');
51
+ $driverFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/DriverFilter.php');
52
+ $vehicleFilter = file_get_contents(dirname(__DIR__) . '/src/Http/Filter/VehicleFilter.php');
53
+
54
+ expect($deviceFilter)
55
+ ->toContain('public function attachmentState')
56
+ ->toContain("attachmentState === 'unattached'")
57
+ ->toContain('public function status')
58
+ ->toContain('public function telematic');
59
+
60
+ expect($driverFilter)->toContain("\$vehicle === 'unassigned'");
61
+ expect($vehicleFilter)->toContain("\$driverId === 'unassigned'");
62
+ });
@@ -129,7 +129,7 @@ test('device event mark processed action is routed and company scoped', function
129
129
  ->toContain("'Event was already processed.'");
130
130
  });
131
131
 
132
- test('afaqy sync stores compact device diagnostics and does not leak token URLs', function () {
132
+ test('afaqy sync stores compact device diagnostics and paginates complete units lists', function () {
133
133
  $provider = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
134
134
 
135
135
  expect($provider)
@@ -139,9 +139,53 @@ test('afaqy sync stores compact device diagnostics and does not leak token URLs'
139
139
  ->toContain("'capabilities'")
140
140
  ->not->toContain("'raw' => \$payload")
141
141
  ->not->toContain("'sensors',")
142
- ->not->toContain("?token=' . urlencode")
143
142
  ->toContain("'Authorization' => 'Bearer ' . \$token")
144
- ->toContain("array_merge(['token' => \$this->credentials['token']], \$payload)");
143
+ ->toContain('?? 500), 500')
144
+ ->toContain('if (is_array($filters) && empty($filters))')
145
+ ->toContain('$filters = new \stdClass();')
146
+ ->toContain("'limit' => \$limit")
147
+ ->toContain("'offset' => \$offset")
148
+ ->not->toContain("\n 'limit' => \$limit,\n 'offset' => \$offset,")
149
+ ->toContain("\$this->afaqyPost('/units/lists', [")
150
+ ->toContain('], true);')
151
+ ->toContain("http_build_query(['token' => \$this->credentials['token']])")
152
+ ->toContain("\$body = \$tokenInQuery ? \$payload : array_merge(['token' => \$this->credentials['token']], \$payload)")
153
+ ->toContain('$pageOffset = (int) ($pagination[\'offset\'] ?? $offset);')
154
+ ->toContain('$pageLimit = (int) ($pagination[\'limit\'] ?? $limit);')
155
+ ->toContain('$advanceBy = $resultCount > 0 ? $resultCount : max($pageLimit, count($devices), 1);')
156
+ ->toContain('$nextCursor = ($pageOffset + $advanceBy) < $total ? $pageOffset + $advanceBy : null;')
157
+ ->toContain("'requested_limit'")
158
+ ->toContain("'provider_limit'")
159
+ ->toContain("'provider_result_count'")
160
+ ->toContain("'pagination' => [")
161
+ ->toContain("'allCount'")
162
+ ->toContain("'filtersCount'")
163
+ ->toContain("'resultCount'");
164
+ });
165
+
166
+ test('telematics device sync records provider pagination and skipped device counts', function () {
167
+ $job = file_get_contents(__DIR__ . '/../src/Jobs/SyncTelematicDevicesJob.php');
168
+ $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/TelematicController.php');
169
+
170
+ expect($controller)
171
+ ->toContain("'limit' => \$request->input('limit')")
172
+ ->not->toContain("'limit' => \$request->input('limit', 100)");
173
+
174
+ expect($job)
175
+ ->toContain("'limit' => \$this->options['limit'] ?? null")
176
+ ->toContain('$totalFetched')
177
+ ->toContain('$totalLinked')
178
+ ->toContain('$totalSkipped')
179
+ ->toContain('$pageCount')
180
+ ->toContain('Device discovery page fetched')
181
+ ->toContain("'provider_unit_id'")
182
+ ->toContain("'last_sync_fetched_total'")
183
+ ->toContain("'last_sync_linked_total'")
184
+ ->toContain("'last_sync_skipped_total'")
185
+ ->toContain("'last_sync_page_count'")
186
+ ->toContain("'last_sync_provider_total'")
187
+ ->toContain("'last_sync_provider_all_count'")
188
+ ->toContain("'last_sync_provider_filters_count'");
145
189
  });
146
190
 
147
191
  test('native endpoint fields are advanced optional overrides with provider defaults', function () {
@@ -260,6 +304,53 @@ test('telematics overview attention items render as full width stacked alerts',
260
304
  ->not->toContain('lg:grid-cols-2');
261
305
  });
262
306
 
307
+ test('device attachment morph types are normalized and legacy aliases are tolerated', function () {
308
+ $migration = file_get_contents(__DIR__ . '/../migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php');
309
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php');
310
+ $serviceProvider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
311
+
312
+ expect($migration)
313
+ ->toContain("whereNotNull('attachable_uuid')")
314
+ ->toContain("'Fleetbase\\\\Models\\\\Vehicle'")
315
+ ->toContain("'\\\\Fleetbase\\\\Models\\\\Vehicle'")
316
+ ->toContain("'attachable_type' => Vehicle::class")
317
+ ->toContain('Intentionally do not restore invalid legacy morph class names.');
318
+
319
+ expect($command)
320
+ ->toContain('\\Fleetbase\\FleetOps\\Models\\Device::class')
321
+ ->toContain("'columns' => ['attachable_type']");
322
+
323
+ expect($serviceProvider)
324
+ ->toContain('use Illuminate\Database\Eloquent\Relations\Relation;')
325
+ ->toContain('$this->registerMorphMap();')
326
+ ->toContain("'Fleetbase\\\\Models\\\\Vehicle' => \\Fleetbase\\FleetOps\\Models\\Vehicle::class")
327
+ ->toContain("'\\\\Fleetbase\\\\Models\\\\Vehicle' => \\Fleetbase\\FleetOps\\Models\\Vehicle::class");
328
+ });
329
+
330
+ test('internal device attachment endpoints return specific api errors instead of raw model misses', function () {
331
+ $vehicleController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/VehicleController.php');
332
+ $deviceController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/DeviceController.php');
333
+
334
+ expect($vehicleController)
335
+ ->toContain('$vehicle = $this->resolveVehicle($id);')
336
+ ->toContain('$device = $this->resolveDevice($deviceId);')
337
+ ->toContain('Vehicle not found or not available for this organization.')
338
+ ->toContain('Device not found or not available for this organization.')
339
+ ->toContain('Unable to attach device to vehicle. Please try again or contact support.')
340
+ ->toContain('Unable to detach device from vehicle. Please try again or contact support.')
341
+ ->toContain('logDeviceAttachmentLookupFailure')
342
+ ->toContain('logDeviceAttachmentFailure');
343
+
344
+ expect($deviceController)
345
+ ->toContain('$device = $this->resolveDevice($id);')
346
+ ->toContain('$vehicle = $this->resolveVehicle($vehicleId);')
347
+ ->toContain('Vehicle not found or not available for this organization.')
348
+ ->toContain('Device not found or not available for this organization.')
349
+ ->toContain('Unable to attach device to vehicle. Please try again or contact support.')
350
+ ->toContain('Unable to detach device from vehicle. Please try again or contact support.')
351
+ ->not->toContain('firstOrFail();');
352
+ });
353
+
263
354
  function telematics_activity_log_method(string $model): string
264
355
  {
265
356
  preg_match('/public function getActivitylogOptions\(\): LogOptions\s+\{(?P<body>.*?)\n \}/s', $model, $matches);
@@ -1051,7 +1051,7 @@ fleet-ops:
1051
1051
  more-waypoints: المزيد من نقاط الطريق
1052
1052
  settings-window:
1053
1053
  success-message: تم حفظ الإعدادات بنجاح.
1054
- title: إعدادات FleetOps
1054
+ title: إعدادات Fleet-Ops
1055
1055
  save: حفظ التغييرات
1056
1056
  name: مسافة التنبيه الافتراضية للطلبات الفورية
1057
1057
  name-text: يحدد هذا الإعداد المسافة الافتراضية بالأمتار التي يجب أن تنبه الطلبات الفورية السائقين ضمن محيط نقطة استلام الطلب.
@@ -1054,7 +1054,7 @@ fleet-ops:
1054
1054
  more-waypoints: thêm điểm dừng
1055
1055
  settings-window:
1056
1056
  success-message: Cài đặt đã được lưu thành công.
1057
- title: Cài đặt FleetOps
1057
+ title: Cài đặt Fleet-Ops
1058
1058
  save: Lưu Thay đổi
1059
1059
  name: Khoảng cách Ping Adhoc Mặc định
1060
1060
  name-text: Cài đặt này xác định khoảng cách mặc định tính bằng mét mà các đơn hàng adhoc nên ping tài xế trong khu vực của điểm đón đơn hàng.