@fleetbase/fleetops-engine 0.6.69 → 0.6.70

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 (209) hide show
  1. package/RELEASE.md +41 -8
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/fuel-integration/hub.hbs +1 -1
  5. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  6. package/addon/components/map/leaflet-live-map.hbs +4 -242
  7. package/addon/components/map/leaflet-live-map.js +2 -1
  8. package/addon/components/map/marker-card/driver.hbs +57 -0
  9. package/addon/components/map/marker-card/driver.js +3 -0
  10. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  11. package/addon/components/map/marker-card/vehicle.js +3 -0
  12. package/addon/components/telematic/hub.hbs +1 -1
  13. package/addon/components/widget/live-fleet.hbs +8 -40
  14. package/addon/controllers/analytics/reports/index.js +5 -0
  15. package/addon/controllers/connectivity/devices/index.js +4 -0
  16. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  17. package/addon/controllers/connectivity/sensors/index.js +4 -0
  18. package/addon/controllers/connectivity/telematics/index.js +4 -0
  19. package/addon/controllers/maintenance/equipment/index.js +11 -4
  20. package/addon/controllers/maintenance/inspection-forms/index.js +2 -2
  21. package/addon/controllers/maintenance/inspection-submissions/index.js +8 -2
  22. package/addon/controllers/maintenance/maintenances/index.js +4 -0
  23. package/addon/controllers/maintenance/parts/index.js +11 -4
  24. package/addon/controllers/maintenance/schedules/index.js +17 -4
  25. package/addon/controllers/maintenance/work-orders/index.js +11 -4
  26. package/addon/controllers/management/contacts/index.js +4 -0
  27. package/addon/controllers/management/drivers/index.js +4 -0
  28. package/addon/controllers/management/fleets/index.js +4 -0
  29. package/addon/controllers/management/fuel-reports/index.js +4 -0
  30. package/addon/controllers/management/fuel-transactions/index.js +9 -7
  31. package/addon/controllers/management/issues/index.js +4 -0
  32. package/addon/controllers/management/places/index.js +4 -0
  33. package/addon/controllers/management/vehicles/index.js +4 -0
  34. package/addon/controllers/management/vendors/index.js +4 -0
  35. package/addon/controllers/operations/orders/index/details.js +30 -1
  36. package/addon/controllers/operations/orders/index.js +7 -0
  37. package/addon/controllers/operations/service-rates/index.js +3 -0
  38. package/addon/controllers/settings/index.js +13 -0
  39. package/addon/controllers/settings/telematics.js +113 -0
  40. package/addon/extension.js +38 -6
  41. package/addon/routes/analytics/index.js +14 -1
  42. package/addon/routes/analytics/reports/index/details.js +1 -1
  43. package/addon/routes/analytics/reports/index/edit.js +1 -1
  44. package/addon/routes/analytics/reports/index/new.js +14 -1
  45. package/addon/routes/analytics/reports.js +14 -1
  46. package/addon/routes/connectivity/devices/index/new.js +14 -1
  47. package/addon/routes/connectivity/devices.js +14 -1
  48. package/addon/routes/connectivity/events.js +14 -1
  49. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  50. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  51. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  52. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  53. package/addon/routes/connectivity/fuel-providers.js +14 -1
  54. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  55. package/addon/routes/connectivity/sensors.js +14 -1
  56. package/addon/routes/connectivity/telematics/new.js +14 -1
  57. package/addon/routes/connectivity/telematics.js +14 -1
  58. package/addon/routes/connectivity/tracking.js +14 -1
  59. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  60. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  61. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  62. package/addon/routes/maintenance/equipment.js +14 -1
  63. package/addon/routes/maintenance/index.js +14 -1
  64. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  65. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  66. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  67. package/addon/routes/maintenance/inspection-forms.js +14 -1
  68. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  69. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  70. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  71. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  72. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  73. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  74. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  75. package/addon/routes/maintenance/maintenances.js +14 -1
  76. package/addon/routes/maintenance/parts/index/details.js +2 -2
  77. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  78. package/addon/routes/maintenance/parts/index/new.js +14 -1
  79. package/addon/routes/maintenance/parts.js +14 -1
  80. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  81. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  82. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  83. package/addon/routes/maintenance/schedules.js +15 -1
  84. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  85. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  86. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  87. package/addon/routes/maintenance/work-orders.js +14 -1
  88. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  89. package/addon/routes/management/fuel-transactions.js +14 -1
  90. package/addon/routes/management/index.js +13 -0
  91. package/addon/routes/management/trailers.js +16 -0
  92. package/addon/routes/management/vendors/integrated/details.js +14 -1
  93. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  94. package/addon/routes/management/vendors/integrated/new.js +14 -1
  95. package/addon/routes/management/vendors/integrated.js +14 -1
  96. package/addon/routes/operations/orders.js +40 -1
  97. package/addon/routes/operations/routes/index/details.js +14 -1
  98. package/addon/routes/operations/routes/index/new.js +13 -0
  99. package/addon/routes/operations/routes.js +14 -1
  100. package/addon/routes/operations/service-rates/index/new.js +11 -0
  101. package/addon/routes/operations/service-rates.js +14 -1
  102. package/addon/routes/settings/avatars.js +14 -1
  103. package/addon/routes/settings/custom-fields.js +14 -1
  104. package/addon/routes/settings/map.js +14 -1
  105. package/addon/routes/settings/notifications.js +11 -0
  106. package/addon/routes/settings/orchestrator.js +1 -1
  107. package/addon/routes/settings/payments/index.js +1 -1
  108. package/addon/routes/settings/routing.js +14 -1
  109. package/addon/routes/settings/scheduling.js +14 -1
  110. package/addon/routes/settings/telematics.js +21 -0
  111. package/addon/routes/virtual.js +11 -0
  112. package/addon/routes.js +1 -0
  113. package/addon/templates/settings/telematics.hbs +70 -0
  114. package/app/components/admin/telematics-settings.js +1 -0
  115. package/app/components/map/marker-card/driver.js +1 -0
  116. package/app/components/map/marker-card/vehicle.js +1 -0
  117. package/app/controllers/settings/telematics.js +1 -0
  118. package/app/routes/settings/telematics.js +1 -0
  119. package/app/templates/settings/telematics.js +1 -0
  120. package/composer.json +2 -2
  121. package/docs/AFAQY.md +1 -1
  122. package/docs/TELEMATICS_QUEUES.md +1 -1
  123. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  124. package/extension.json +1 -1
  125. package/package.json +1 -1
  126. package/server/config/telemetry.php +12 -0
  127. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  128. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  129. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  130. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  131. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  132. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  133. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  134. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  135. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  136. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  137. package/server/src/Http/Controllers/Internal/v1/ContactController.php +11 -0
  138. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +14 -0
  139. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  140. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  141. package/server/src/Http/Controllers/Internal/v1/DriverController.php +22 -0
  142. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  143. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  144. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  145. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  146. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  147. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  148. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  149. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  150. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  151. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  152. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  153. package/server/src/Http/Controllers/Internal/v1/OrderController.php +22 -0
  154. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  155. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  156. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  157. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  158. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  159. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  160. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  161. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  162. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  163. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  164. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  165. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  166. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  167. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  168. package/server/src/Providers/FleetOpsServiceProvider.php +3 -0
  169. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  170. package/server/src/Support/Authorization.php +103 -0
  171. package/server/src/Support/Database/TableIndexes.php +66 -0
  172. package/server/src/Support/OrderTracker.php +2 -2
  173. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  174. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  175. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  176. package/server/src/Support/Telematics/TelematicService.php +12 -2
  177. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  178. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  179. package/server/src/Tracking/TrackingOptions.php +2 -2
  180. package/server/src/Traits/AuthorizesMethods.php +37 -0
  181. package/server/src/routes.php +9 -1
  182. package/server/tests/AnalyticsRoutesTest.php +29 -2
  183. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +16 -1
  184. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  185. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  186. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  187. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  188. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  189. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  190. package/server/tests/ObserverContractsTest.php +1 -1
  191. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  192. package/server/tests/ProviderContractsTest.php +10 -0
  193. package/server/tests/ReportSchemaContractsTest.php +120 -6
  194. package/server/tests/SettingControllerContractsTest.php +429 -18
  195. package/server/tests/SmallControllerContractsTest.php +8 -5
  196. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  197. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  198. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  199. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  200. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  201. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  202. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  203. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  204. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  205. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  206. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  207. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  208. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  209. package/translations/en-us.yaml +48 -0
@@ -5,17 +5,19 @@ use Illuminate\Http\Request;
5
5
 
6
6
  class FleetOpsSettingControllerProbe extends SettingController
7
7
  {
8
- public array $configured = [];
9
- public array $configuredCompany = [];
10
- public array $settingValues = [];
11
- public array $settings = [];
12
- public array $companySettings = [];
13
- public array $lookupCompanySettings = [];
14
- public mixed $company = null;
15
- public array $notifiables = [];
16
- public array $notifications = [];
17
- public array $providers = [];
18
- public string $googleMapsApiKey = '';
8
+ public array $configured = [];
9
+ public array $configuredCompany = [];
10
+ public array $settingValues = [];
11
+ public array $orderConfigKeys = [];
12
+ public array $settings = [];
13
+ public array $companySettings = [];
14
+ public array $lookupCompanySettings = [];
15
+ public mixed $company = null;
16
+ public array $notifiables = [];
17
+ public array $notifications = [];
18
+ public array $providers = [];
19
+ public string $googleMapsApiKey = '';
20
+ public array $reconciledTelematicsDefaults = [];
19
21
 
20
22
  protected function configureSetting(string $key, mixed $value): mixed
21
23
  {
@@ -34,6 +36,11 @@ class FleetOpsSettingControllerProbe extends SettingController
34
36
  return null;
35
37
  }
36
38
 
39
+ protected function companyOrderConfigKeys(): array
40
+ {
41
+ return $this->orderConfigKeys;
42
+ }
43
+
37
44
  protected function settingValue(string $key): mixed
38
45
  {
39
46
  return $this->settingValues[$key] ?? null;
@@ -59,6 +66,16 @@ class FleetOpsSettingControllerProbe extends SettingController
59
66
  return $this->company;
60
67
  }
61
68
 
69
+ protected function reconcileTelematicsCompanyPreferences(array $defaults): void
70
+ {
71
+ $this->reconciledTelematicsDefaults = $defaults;
72
+ }
73
+
74
+ protected function withTelematicsPolicyLock(Closure $callback): mixed
75
+ {
76
+ return $callback();
77
+ }
78
+
62
79
  protected function notificationNotifiables(): array
63
80
  {
64
81
  return $this->notifiables;
@@ -78,6 +95,45 @@ class FleetOpsSettingControllerProbe extends SettingController
78
95
  {
79
96
  return $this->googleMapsApiKey;
80
97
  }
98
+
99
+ public array $counts = [];
100
+ public array $oldest = [];
101
+ public array $tableStatistics = [];
102
+ public array $countScopes = [];
103
+ public array $pruned = [];
104
+
105
+ protected function tableUsage(string $table, Closure $scope, string $ageColumn): array
106
+ {
107
+ // System storage must not add company or connection filters.
108
+ $scope(new stdClass());
109
+
110
+ return [
111
+ 'rows' => $this->tableCount($table, $scope),
112
+ 'oldest' => $this->tableOldest($table, $scope, $ageColumn),
113
+ ];
114
+ }
115
+
116
+ protected function tableCount(string $table, Closure $scope): int
117
+ {
118
+ $this->countScopes[] = $table;
119
+
120
+ return $this->counts[$table] ?? 0;
121
+ }
122
+
123
+ protected function tableOldest(string $table, Closure $scope, string $column): ?string
124
+ {
125
+ return $this->oldest[$table] ?? null;
126
+ }
127
+
128
+ protected function storageTableStatistics(array $tables): array
129
+ {
130
+ return $this->tableStatistics;
131
+ }
132
+
133
+ protected function dispatchTelematicsPrune(): void
134
+ {
135
+ $this->pruned[] = 'system';
136
+ }
81
137
  }
82
138
 
83
139
  class FleetOpsTrackingProviderOptionFake
@@ -107,16 +163,22 @@ function fleetopsJsonPayload(mixed $response): array
107
163
  }
108
164
 
109
165
  test('setting controller persists and returns basic company settings through configured keys', function () {
110
- $controller = new FleetOpsSettingControllerProbe();
111
-
112
- $entityPayload = fleetopsJsonPayload($controller->saveEntityEditingSettings(new Request([
113
- 'entityEditingSettings' => ['orders' => ['editable' => true]],
166
+ session(['company' => 'company-1']);
167
+ $controller = new FleetOpsSettingControllerProbe();
168
+ $controller->orderConfigKeys = ['orders'];
169
+
170
+ // The setting is one platform-wide map keyed by order config: another company's entry must survive,
171
+ // and this company cannot write keys for order configs it does not own.
172
+ $controller->settingValues['fleet-ops.entity-editing-settings'] = ['foreign-config' => ['editable' => true]];
173
+ $entityPayload = fleetopsJsonPayload($controller->saveEntityEditingSettings(new Request([
174
+ 'entityEditingSettings' => ['orders' => ['editable' => true], 'foreign-config' => ['editable' => false]],
114
175
  ])));
115
- $controller->settingValues['fleet-ops.entity-editing-settings'] = ['orders' => ['editable' => false]];
176
+ $controller->settingValues['fleet-ops.entity-editing-settings'] = ['orders' => ['editable' => false], 'foreign-config' => ['editable' => true]];
116
177
 
178
+ // A request-supplied company id is ignored in favour of the session company.
117
179
  $disabledDriverPayload = fleetopsJsonPayload($controller->savedDriverOnboardSettings(new Request([
118
180
  'driverOnboardSettings' => [
119
- 'companyId' => 'company-1',
181
+ 'companyId' => 'company-2',
120
182
  'enableDriverOnboardFromApp' => false,
121
183
  'driverMustProvideOnboardDoucments' => true,
122
184
  'requiredOnboardDocuments' => ['license'],
@@ -137,7 +199,7 @@ test('setting controller persists and returns basic company settings through con
137
199
  $controller->company = (object) ['stripe_connect_id' => 'acct_123'];
138
200
 
139
201
  expect($entityPayload)->toBe(['entityEditingSettings' => ['orders' => ['editable' => true]]])
140
- ->and($controller->configured['fleet-ops.entity-editing-settings'])->toBe(['orders' => ['editable' => true]])
202
+ ->and($controller->configured['fleet-ops.entity-editing-settings'])->toBe(['foreign-config' => ['editable' => true], 'orders' => ['editable' => true]])
141
203
  ->and(fleetopsJsonPayload($controller->getEntityEditingSettings()))->toBe(['entityEditingSettings' => ['orders' => ['editable' => false]]])
142
204
  ->and(fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getEntityEditingSettings()))->toBe(['entityEditingSettings' => []])
143
205
  ->and($disabledDriverPayload['driverOnboardSettings'])->toMatchArray([
@@ -148,6 +210,7 @@ test('setting controller persists and returns basic company settings through con
148
210
  'driverOnboardAppMethod' => '',
149
211
  ])
150
212
  ->and($controller->configured['fleet-ops.driver-onboard-settings.company-1'])->toBe($disabledDriverPayload['driverOnboardSettings'])
213
+ ->and($controller->configured)->not->toHaveKey('fleet-ops.driver-onboard-settings.company-2')
151
214
  ->and(fleetopsJsonPayload($controller->getDriverOnboardSettings('company-1')))->toBe(['driverOnboardSettings' => ['enableDriverOnboardFromApp' => true]])
152
215
  ->and(fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getDriverOnboardSettings('missing')))->toBe(['driverOnboardSettings' => []])
153
216
  ->and($enabledConfigs)->toBe(['order-express', 'order-freight'])
@@ -441,3 +504,351 @@ test('setting controller sanitizes leaflet tile provider urls', function () {
441
504
  expect($defaults['leafletTileUrl'])->toBe('')
442
505
  ->and($defaults['leafletDarkTileUrl'])->toBe('');
443
506
  });
507
+
508
+ test('telematics settings expose only customer history preferences within administrator limits', function () {
509
+ config(['telematics.telemetry' => ['event_retention_days' => 45, 'processed_retention_hours' => 48, 'log_telemetry_activity' => false, 'poll_queue' => 'default']]);
510
+ $controller = new FleetOpsSettingControllerProbe();
511
+ $controller->company = (object) ['uuid' => 'company-1'];
512
+ $controller->settings['fleet-ops.telematics-settings'] = ['position_retention_days' => 120, 'event_retention_days' => 99999, 'max_event_retention_days' => 60];
513
+ $controller->companySettings['fleet-ops.telematics-settings'] = ['event_compact_after_days' => 3, 'log_telemetry_activity' => '1'];
514
+
515
+ $settings = fleetopsJsonPayload($controller->getTelematicsSettings());
516
+ expect($settings['event_retention_days'])->toBe(60)
517
+ ->and($settings['position_retention_days'])->toBe(120)
518
+ ->and($settings)->not->toHaveKeys(['event_compact_after_days', 'processed_retention_hours', 'log_telemetry_activity'])
519
+ ->and($settings['preferences'])->toBe(['event_retention_days' => null, 'position_retention_days' => null])
520
+ ->and($settings['defaults'])->toBe(['event_retention_days' => 60, 'position_retention_days' => 120])
521
+ ->and($settings['policy'])->toBe(['max_event_retention_days' => 60, 'max_position_retention_days' => 0])
522
+ ->and($settings['limits'])->toBe(['event_retention_days' => [1, 3650], 'position_retention_days' => [1, 3650]]);
523
+
524
+ $saved = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request([
525
+ 'event_retention_days' => '0',
526
+ 'event_compact_after_days' => -3,
527
+ 'processed_retention_hours' => 5000,
528
+ 'log_telemetry_activity' => 'false',
529
+ 'unexpected' => 'ignored',
530
+ ])));
531
+ expect($saved['status'])->toBe('ok')
532
+ ->and($saved['event_retention_days'])->toBe(60)
533
+ ->and($saved['position_retention_days'])->toBe(120)
534
+ ->and($saved['preferences'])->toBe(['event_retention_days' => 60, 'position_retention_days' => null])
535
+ ->and($saved)->not->toHaveKeys(['event_compact_after_days', 'processed_retention_hours', 'log_telemetry_activity'])
536
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe(['event_retention_days' => 60]);
537
+
538
+ $admin = fleetopsJsonPayload($controller->getAdminTelematicsSettings());
539
+ expect($admin['event_retention_days'])->toBe(3650)
540
+ ->and($admin['limits'])->toHaveKeys(['sync_run_retention_days', 'max_event_retention_days', 'max_position_retention_days'])
541
+ ->and($admin['log_telemetry_activity'])->toBeFalse();
542
+
543
+ $savedAdmin = fleetopsJsonPayload($controller->saveAdminTelematicsSettings(new Request([
544
+ 'event_retention_days' => 60,
545
+ 'sync_run_retention_days' => 0,
546
+ 'max_event_retention_days' => 90,
547
+ 'max_position_retention_days' => 45,
548
+ 'log_telemetry_activity' => true,
549
+ ])));
550
+ expect($savedAdmin['event_retention_days'])->toBe(60)
551
+ ->and($savedAdmin['sync_run_retention_days'])->toBe(0)
552
+ ->and($savedAdmin['processed_retention_hours'])->toBe(48)
553
+ ->and($savedAdmin['max_event_retention_days'])->toBe(90)
554
+ ->and($savedAdmin['max_position_retention_days'])->toBe(45)
555
+ ->and($savedAdmin['log_telemetry_activity'])->toBeTrue()
556
+ ->and($controller->reconciledTelematicsDefaults['max_event_retention_days'])->toBe(90)
557
+ ->and($controller->reconciledTelematicsDefaults['max_position_retention_days'])->toBe(45)
558
+ ->and($controller->configured['fleet-ops.telematics-settings']['position_retention_days'])->toBe(120);
559
+
560
+ $partialAdmin = fleetopsJsonPayload($controller->saveAdminTelematicsSettings(new Request(['log_telemetry_activity' => false])));
561
+ expect($partialAdmin['max_event_retention_days'])->toBe(90)
562
+ ->and($partialAdmin['max_position_retention_days'])->toBe(45);
563
+
564
+ $inherited = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request(['event_retention_days' => null, 'position_retention_days' => null])));
565
+ expect($inherited['event_retention_days'])->toBe(60)
566
+ ->and($inherited['position_retention_days'])->toBe(45)
567
+ ->and($inherited['preferences'])->toBe(['event_retention_days' => null, 'position_retention_days' => null])
568
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe([]);
569
+ config(['telematics.telemetry' => []]);
570
+ });
571
+
572
+ test('explicit unlimited customer history stays distinct from inheritance and reads back with current maximums', function () {
573
+ $controller = new FleetOpsSettingControllerProbe();
574
+ $controller->company = (object) ['uuid' => 'company-1'];
575
+
576
+ $saved = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request([
577
+ 'event_retention_days' => 0,
578
+ 'position_retention_days' => null,
579
+ ])));
580
+ expect($saved['event_retention_days'])->toBe(0)
581
+ ->and($saved['preferences'])->toBe(['event_retention_days' => 0, 'position_retention_days' => null])
582
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe(['event_retention_days' => 0])
583
+ ->and(fleetopsJsonPayload($controller->getTelematicsSettings())['preferences'])->toBe($saved['preferences']);
584
+
585
+ $controller->settings['fleet-ops.telematics-settings'] = ['max_event_retention_days' => 90, 'max_position_retention_days' => 360];
586
+ $loaded = fleetopsJsonPayload($controller->getTelematicsSettings());
587
+ expect($loaded['event_retention_days'])->toBe(90)
588
+ ->and($loaded['preferences'])->toBe(['event_retention_days' => 90, 'position_retention_days' => null])
589
+ ->and($loaded['policy'])->toBe(['max_event_retention_days' => 90, 'max_position_retention_days' => 360]);
590
+ });
591
+
592
+ test('telematics customer settings require a company session', function () {
593
+ $controller = new FleetOpsSettingControllerProbe();
594
+ expect($controller->getTelematicsSettings()->getStatusCode())->toBe(401)
595
+ ->and($controller->saveTelematicsSettings(new Request())->getStatusCode())->toBe(401)
596
+ ->and($controller->configuredCompany)->toBe([])
597
+ ->and($controller->pruned)->toBe([]);
598
+ });
599
+
600
+ test('admin telematics storage and cleanup cover the system without a company session', function () {
601
+ $hadRequest = app()->bound('request');
602
+ $originalRequest = $hadRequest ? app('request') : null;
603
+ $originalQueueConfig = config('queue', []);
604
+ config(['queue.default' => 'database', 'queue.connections.database.driver' => 'database']);
605
+ app()->instance('request', new Request(['include_payload_counts' => true]));
606
+ Illuminate\Support\Carbon::setTestNow('2026-09-23 12:00:00 UTC');
607
+ try {
608
+ $controller = new FleetOpsSettingControllerProbe();
609
+ $controller->counts = ['device_events' => 500, 'positions' => 40, 'telematic_deliveries' => 6, 'telematic_sync_runs' => 3];
610
+ $controller->oldest = ['device_events' => '2026-08-01 00:00:00'];
611
+ $controller->tableStatistics = [
612
+ 'device_events' => ['rows' => 500, 'rows_estimated' => true, 'estimated_bytes' => 16000000],
613
+ 'positions' => ['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 28000],
614
+ ];
615
+
616
+ $usage = fleetopsJsonPayload($controller->getTelematicsStorageUsage());
617
+ expect($usage['tables']['device_events'])->toBe([
618
+ 'rows' => 500,
619
+ 'rows_estimated' => true,
620
+ 'estimated_bytes' => 16000000,
621
+ 'oldest' => '2026-08-01 00:00:00',
622
+ 'raw_payload_rows' => 500,
623
+ 'compactable_rows' => 500,
624
+ ])
625
+ ->and($usage['tables']['positions'])->toBe(['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 28000, 'oldest' => null])
626
+ ->and($usage['tables']['telematic_deliveries'])->toBe(['rows' => 6, 'oldest' => null])
627
+ ->and($usage['tables']['telematic_sync_runs']['rows'])->toBe(3)
628
+ ->and($usage['scope'])->toBe('system')
629
+ ->and($usage)->not->toHaveKey('company')
630
+ ->and($usage['generated_at'])->toBe('2026-09-23T12:00:00.000000Z')
631
+ ->and($controller->countScopes)->toBe(['telematic_deliveries', 'telematic_sync_runs', 'device_events', 'device_events']);
632
+
633
+ // Missing metadata uses bounded full-table counts, independent of any organization's connections.
634
+ $controller->tableStatistics = [];
635
+ $controller->settings['fleet-ops.telematics-settings'] = ['event_compact_after_days' => 0];
636
+ $controller->countScopes = [];
637
+ $usage = fleetopsJsonPayload($controller->getTelematicsStorageUsage());
638
+ expect($usage['tables']['telematic_deliveries'])->toBe(['rows' => 6, 'oldest' => null])
639
+ ->and($usage['tables']['telematic_sync_runs'])->toBe(['rows' => 3, 'oldest' => null])
640
+ ->and($usage['tables']['device_events']['compactable_rows'])->toBe(0)
641
+ ->and($controller->countScopes)->toBe(['device_events', 'positions', 'telematic_deliveries', 'telematic_sync_runs', 'device_events']);
642
+
643
+ $queued = $controller->runTelematicsRetention();
644
+ expect($queued->getStatusCode())->toBe(202)
645
+ ->and(fleetopsJsonPayload($queued)['status'])->toBe('queued')
646
+ ->and(fleetopsJsonPayload($queued)['scope'])->toBe('system')
647
+ ->and($controller->pruned)->toBe(['system']);
648
+ } finally {
649
+ if ($hadRequest) {
650
+ app()->instance('request', $originalRequest);
651
+ } else {
652
+ app()->forgetInstance('request');
653
+ }
654
+ config(['queue' => $originalQueueConfig]);
655
+ Illuminate\Support\Carbon::setTestNow();
656
+ }
657
+ });
658
+
659
+ test('system cleanup does not run an entire platform inline on a synchronous queue', function () {
660
+ $originalQueueConfig = config('queue', []);
661
+ try {
662
+ config(['queue.default' => 'sync', 'queue.connections.sync.driver' => 'sync']);
663
+ $controller = new FleetOpsSettingControllerProbe();
664
+ expect($controller->runTelematicsRetention()->getStatusCode())->toBe(503)
665
+ ->and($controller->pruned)->toBe([]);
666
+ } finally {
667
+ config(['queue' => $originalQueueConfig]);
668
+ }
669
+ });
670
+
671
+ test('telematics storage helpers query all rows and allocated table bytes from MySQL statistics', function () {
672
+ $connection = new class(new PDO('sqlite::memory:')) extends Illuminate\Database\SQLiteConnection {
673
+ public string $driver = 'sqlite';
674
+ public array $selects = [];
675
+
676
+ public function getDriverName()
677
+ {
678
+ return $this->driver;
679
+ }
680
+
681
+ public function select($query, $bindings = [], $useReadPdo = true)
682
+ {
683
+ if (str_contains($query, 'information_schema')) {
684
+ $this->selects[] = [$query, $bindings];
685
+
686
+ // information_schema reports prefixed table names.
687
+ return [(object) ['name' => $bindings[0], 'row_count' => '500', 'data_bytes' => '16000000', 'index_bytes' => '5000'], (object) ['name' => $bindings[1], 'row_count' => 40, 'data_bytes' => 28000, 'index_bytes' => 2000]];
688
+ }
689
+
690
+ return parent::select($query, $bindings, $useReadPdo);
691
+ }
692
+ };
693
+ $connection->setTablePrefix('');
694
+ $originalDb = app()->bound('db') ? app('db') : null;
695
+ app()->instance('db', new class($connection) {
696
+ public function __construct(public $connection)
697
+ {
698
+ }
699
+
700
+ public function connection($name = null)
701
+ {
702
+ return $this->connection;
703
+ }
704
+
705
+ public function __call($method, $arguments)
706
+ {
707
+ return $this->connection->{$method}(...$arguments);
708
+ }
709
+ });
710
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
711
+ try {
712
+ $schema = $connection->getSchemaBuilder();
713
+ $schema->create('telematics', function ($table) {
714
+ $table->increments('id');
715
+ $table->string('uuid');
716
+ $table->string('company_uuid');
717
+ });
718
+ $schema->create('device_events', function ($table) {
719
+ $table->increments('id');
720
+ $table->string('company_uuid')->nullable();
721
+ $table->timestamp('created_at')->nullable();
722
+ });
723
+ $connection->table('telematics')->insert([['uuid' => 'tm-1', 'company_uuid' => 'company-1'], ['uuid' => 'tm-2', 'company_uuid' => 'company-2']]);
724
+ $connection->table('device_events')->insert([
725
+ ['company_uuid' => 'company-1', 'created_at' => '2026-08-01 00:00:00'],
726
+ ['company_uuid' => 'company-1', 'created_at' => '2026-09-01 00:00:00'],
727
+ ['company_uuid' => 'company-2', 'created_at' => '2026-07-01 00:00:00'],
728
+ ]);
729
+
730
+ $controller = new class extends SettingController {
731
+ public function usage(string $table, Closure $scope, string $column): array
732
+ {
733
+ return $this->tableUsage($table, $scope, $column);
734
+ }
735
+
736
+ public function statistics(array $tables): array
737
+ {
738
+ return $this->storageTableStatistics($tables);
739
+ }
740
+ };
741
+ expect($controller->usage('device_events', fn ($query) => $query, 'created_at'))->toBe(['rows' => 3, 'oldest' => '2026-07-01 00:00:00'])
742
+ ->and($controller->usage('device_events', fn ($query) => $query->where('company_uuid', 'none'), 'created_at'))->toBe(['rows' => 0, 'oldest' => null])
743
+ ->and($controller->statistics(['device_events', 'positions']))->toBe([]);
744
+
745
+ $connection->driver = 'mysql';
746
+ $connection->setTablePrefix('fb_');
747
+ expect($controller->statistics(['device_events', 'positions']))->toBe([
748
+ 'device_events' => ['rows' => 500, 'rows_estimated' => true, 'estimated_bytes' => 16005000],
749
+ 'positions' => ['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 30000],
750
+ ])
751
+ ->and($connection->selects[0][1])->toBe(['fb_device_events', 'fb_positions'])
752
+ ->and($connection->selects[0][0])->toContain('TABLE_NAME IN (?, ?)');
753
+ } finally {
754
+ if ($originalDb) {
755
+ app()->instance('db', $originalDb);
756
+ } else {
757
+ app()->forgetInstance('db');
758
+ }
759
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
760
+ }
761
+ });
762
+
763
+ test('admin telematics cleanup dispatches the system-wide fanout job', function () {
764
+ $controller = new class extends SettingController {
765
+ public function prune(): void
766
+ {
767
+ $this->dispatchTelematicsPrune();
768
+ }
769
+ };
770
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
771
+ $originalCacheConfig = config('cache', []);
772
+ $dispatcherContract = Illuminate\Contracts\Bus\Dispatcher::class;
773
+ $originalDispatcher = app()->bound($dispatcherContract) ? app($dispatcherContract) : null;
774
+ try {
775
+ config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]);
776
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\CacheManager(app()));
777
+ $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher {
778
+ public array $jobs = [];
779
+ public bool $fail = true;
780
+
781
+ public function dispatch($command)
782
+ {
783
+ if ($this->fail) {
784
+ throw new RuntimeException('Broker unavailable');
785
+ }
786
+ $this->jobs[] = $command;
787
+
788
+ return $command;
789
+ }
790
+ };
791
+ app()->instance($dispatcherContract, $dispatcher);
792
+
793
+ expect(fn () => $controller->prune())->toThrow(RuntimeException::class, 'Broker unavailable');
794
+ $dispatcher->fail = false;
795
+ $controller->prune();
796
+ $controller->prune();
797
+ expect($dispatcher->jobs)->toHaveCount(1)
798
+ ->and($dispatcher->jobs[0])->toBeInstanceOf(Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs::class)
799
+ ->and($dispatcher->jobs[0]->afterCompanyId)->toBe(0);
800
+ } finally {
801
+ Illuminate\Support\Facades\Cache::swap($originalCache);
802
+ config(['cache' => $originalCacheConfig]);
803
+ if ($originalDispatcher) {
804
+ app()->instance($dispatcherContract, $originalDispatcher);
805
+ } else {
806
+ app()->forgetInstance($dispatcherContract);
807
+ }
808
+ }
809
+ });
810
+
811
+ test('customer history save reports a failed persistence operation', function () {
812
+ $controller = new class extends FleetOpsSettingControllerProbe {
813
+ protected function configureCompanySetting(string $key, mixed $value): mixed
814
+ {
815
+ return false;
816
+ }
817
+ };
818
+ $controller->company = (object) ['uuid' => 'company-a'];
819
+ $response = $controller->saveTelematicsSettings(Request::create('/', 'POST', ['event_retention_days' => 0]));
820
+ expect($response->getStatusCode())->toBe(500);
821
+ });
822
+
823
+ test('optional payload counts omit timeouts but surface other database errors', function () {
824
+ $hadRequest = app()->bound('request');
825
+ $original = $hadRequest ? app('request') : null;
826
+ app()->instance('request', Request::create('/', 'GET', ['include_payload_counts' => true]));
827
+ try {
828
+ $controller = new class extends FleetOpsSettingControllerProbe {
829
+ public int $errorCode = 3024;
830
+
831
+ protected function tableUsage(string $table, Closure $scope, string $ageColumn): array
832
+ {
833
+ return ['rows' => 5, 'oldest' => null];
834
+ }
835
+
836
+ protected function tableCount(string $table, Closure $scope): int
837
+ {
838
+ $error = new PDOException('Query failed');
839
+ $error->errorInfo = ['HY000', $this->errorCode, 'Query failed'];
840
+ throw new Illuminate\Database\QueryException('mysql', 'select payload', [], $error);
841
+ }
842
+ };
843
+ $data = $controller->getTelematicsStorageUsage()->getData(true);
844
+ expect($data['scope'])->toBe('system')->and($data['tables']['device_events'])->toBe(['rows' => 5, 'oldest' => null]);
845
+ $controller->errorCode = 1146;
846
+ expect(fn () => $controller->getTelematicsStorageUsage())->toThrow(Illuminate\Database\QueryException::class);
847
+ } finally {
848
+ if ($hadRequest) {
849
+ app()->instance('request', $original);
850
+ } else {
851
+ app()->forgetInstance('request');
852
+ }
853
+ }
854
+ });
@@ -383,11 +383,14 @@ test('sensor and service area controllers export selections and eager load query
383
383
  });
384
384
 
385
385
  test('service area zone and getting started controllers expose small lifecycle contracts', function () {
386
- $serviceArea = new FleetOpsControllerCustomFieldModelFake();
387
- (new ServiceAreaController())->afterSave(new Request([
388
- 'service_area' => ['custom_field_values' => [['key' => 'area_code', 'value' => 'A1']]],
389
- ]), $serviceArea);
390
- (new ServiceAreaController())->afterSave(new Request(['service_area' => ['custom_field_values' => []]]), $serviceArea);
386
+ $serviceArea = new FleetOpsControllerCustomFieldModelFake();
387
+ $serviceAreaRequest = new Request([
388
+ 'serviceArea' => ['custom_field_values' => [['key' => 'area_code', 'value' => 'raw-request-value']]],
389
+ ]);
390
+ (new ServiceAreaController())->afterSave($serviceAreaRequest, $serviceArea, [
391
+ 'custom_field_values' => [['key' => 'area_code', 'value' => 'A1']],
392
+ ]);
393
+ (new ServiceAreaController())->afterSave($serviceAreaRequest, $serviceArea, ['custom_field_values' => []]);
391
394
 
392
395
  $zone = new FleetOpsControllerZoneFake();
393
396
  (new ZoneController())->afterSave(new Request([
@@ -0,0 +1,55 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Console\Commands\PruneTelematicsData;
4
+ use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy;
5
+ use Illuminate\Support\Collection;
6
+
7
+ /**
8
+ * Drives fleetops:prune-telematics-data without the console kernel: options,
9
+ * output and the settings-backed policy are all supplied by the test.
10
+ */
11
+ class PruneTelematicsDataProbe extends PruneTelematicsData
12
+ {
13
+ public array $messages = [];
14
+ public array $options = ['company' => null, 'table' => [], 'batch-size' => 1000, 'max-batches' => 50, 'dry-run' => false, 'no-lock' => true];
15
+
16
+ /** company uuid ('' for the system defaults) => policy values passed to RetentionPolicy::fromArray */
17
+ public array $policies = [];
18
+
19
+ /** When set, replaces the companies table lookup. */
20
+ public ?Collection $companyRows = null;
21
+
22
+ public function option($key = null, $default = null)
23
+ {
24
+ return $this->options[$key] ?? $default;
25
+ }
26
+
27
+ public function info($string, $verbosity = null)
28
+ {
29
+ $this->messages[] = ['info', $string];
30
+ }
31
+
32
+ public function warn($string, $verbosity = null)
33
+ {
34
+ $this->messages[] = ['warn', $string];
35
+ }
36
+
37
+ public function error($string, $verbosity = null)
38
+ {
39
+ $this->messages[] = ['error', $string];
40
+ }
41
+
42
+ protected function policyFor(?string $companyUuid): RetentionPolicy
43
+ {
44
+ return RetentionPolicy::fromArray($this->policies[$companyUuid ?? ''] ?? $this->policies[''] ?? []);
45
+ }
46
+
47
+ protected function companies(?string $only): Collection
48
+ {
49
+ if ($this->companyRows === null) {
50
+ return parent::companies($only);
51
+ }
52
+
53
+ return $this->companyRows->filter(fn ($company) => !$only || $company->uuid === $only || $company->public_id === $only)->values();
54
+ }
55
+ }
@@ -125,9 +125,9 @@ class FleetOpsProcessOperationalAlertsCommandProbe extends ProcessOperationalAle
125
125
  return $this->query;
126
126
  }
127
127
 
128
- protected function alertSettings(): array
128
+ protected function alertSettings(Order $order): array
129
129
  {
130
- $this->settingsCalls[] = session('company');
130
+ $this->settingsCalls[] = $order->company_uuid;
131
131
 
132
132
  return [
133
133
  'late_departures' => ['enabled' => true],