@fleetbase/fleetops-engine 0.6.68 → 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 (294) hide show
  1. package/RELEASE.md +44 -9
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/contact/form.hbs +13 -2
  5. package/addon/components/customer/details.hbs +17 -17
  6. package/addon/components/customer/form.hbs +13 -2
  7. package/addon/components/customer/form.js +0 -56
  8. package/addon/components/driver/details.hbs +13 -19
  9. package/addon/components/driver/form.hbs +20 -35
  10. package/addon/components/driver/form.js +0 -58
  11. package/addon/components/fuel-integration/hub.hbs +1 -1
  12. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  13. package/addon/components/map/leaflet-live-map.hbs +4 -242
  14. package/addon/components/map/leaflet-live-map.js +2 -1
  15. package/addon/components/map/marker-card/driver.hbs +57 -0
  16. package/addon/components/map/marker-card/driver.js +3 -0
  17. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  18. package/addon/components/map/marker-card/vehicle.js +3 -0
  19. package/addon/components/modals/driver-form.hbs +2 -2
  20. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  21. package/addon/components/modals/reset-profile-credentials.js +70 -0
  22. package/addon/components/profile-identity-hint.hbs +13 -0
  23. package/addon/components/profile-identity-hint.js +49 -0
  24. package/addon/components/telematic/hub.hbs +1 -1
  25. package/addon/components/widget/live-fleet.hbs +8 -40
  26. package/addon/controllers/analytics/reports/index.js +5 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -0
  28. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  29. package/addon/controllers/connectivity/sensors/index.js +4 -0
  30. package/addon/controllers/connectivity/telematics/index.js +4 -0
  31. package/addon/controllers/maintenance/equipment/index.js +11 -4
  32. package/addon/controllers/maintenance/inspection-forms/index.js +2 -2
  33. package/addon/controllers/maintenance/inspection-submissions/index.js +8 -2
  34. package/addon/controllers/maintenance/maintenances/index.js +4 -0
  35. package/addon/controllers/maintenance/parts/index.js +11 -4
  36. package/addon/controllers/maintenance/schedules/index.js +17 -4
  37. package/addon/controllers/maintenance/work-orders/index.js +11 -4
  38. package/addon/controllers/management/contacts/index.js +4 -0
  39. package/addon/controllers/management/drivers/index.js +8 -0
  40. package/addon/controllers/management/fleets/index.js +4 -0
  41. package/addon/controllers/management/fuel-reports/index.js +4 -0
  42. package/addon/controllers/management/fuel-transactions/index.js +9 -7
  43. package/addon/controllers/management/issues/index.js +4 -0
  44. package/addon/controllers/management/places/index.js +4 -0
  45. package/addon/controllers/management/vehicles/index.js +4 -0
  46. package/addon/controllers/management/vendors/index.js +4 -0
  47. package/addon/controllers/operations/orders/index/details.js +30 -1
  48. package/addon/controllers/operations/orders/index.js +7 -0
  49. package/addon/controllers/operations/service-rates/index.js +3 -0
  50. package/addon/controllers/settings/index.js +13 -0
  51. package/addon/controllers/settings/telematics.js +113 -0
  52. package/addon/extension.js +42 -8
  53. package/addon/routes/analytics/index.js +14 -1
  54. package/addon/routes/analytics/reports/index/details.js +1 -1
  55. package/addon/routes/analytics/reports/index/edit.js +1 -1
  56. package/addon/routes/analytics/reports/index/new.js +14 -1
  57. package/addon/routes/analytics/reports.js +14 -1
  58. package/addon/routes/connectivity/devices/index/new.js +14 -1
  59. package/addon/routes/connectivity/devices.js +14 -1
  60. package/addon/routes/connectivity/events.js +14 -1
  61. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  62. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  63. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  64. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  65. package/addon/routes/connectivity/fuel-providers.js +14 -1
  66. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  67. package/addon/routes/connectivity/sensors.js +14 -1
  68. package/addon/routes/connectivity/telematics/new.js +14 -1
  69. package/addon/routes/connectivity/telematics.js +14 -1
  70. package/addon/routes/connectivity/tracking.js +14 -1
  71. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  72. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  73. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  74. package/addon/routes/maintenance/equipment.js +14 -1
  75. package/addon/routes/maintenance/index.js +14 -1
  76. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  77. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  78. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  79. package/addon/routes/maintenance/inspection-forms.js +14 -1
  80. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  81. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  82. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  83. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  84. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  85. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  86. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  87. package/addon/routes/maintenance/maintenances.js +14 -1
  88. package/addon/routes/maintenance/parts/index/details.js +2 -2
  89. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  90. package/addon/routes/maintenance/parts/index/new.js +14 -1
  91. package/addon/routes/maintenance/parts.js +14 -1
  92. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  93. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  94. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  95. package/addon/routes/maintenance/schedules.js +15 -1
  96. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  97. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  98. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  99. package/addon/routes/maintenance/work-orders.js +14 -1
  100. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  101. package/addon/routes/management/fuel-transactions.js +14 -1
  102. package/addon/routes/management/index.js +13 -0
  103. package/addon/routes/management/trailers.js +16 -0
  104. package/addon/routes/management/vendors/integrated/details.js +14 -1
  105. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  106. package/addon/routes/management/vendors/integrated/new.js +14 -1
  107. package/addon/routes/management/vendors/integrated.js +14 -1
  108. package/addon/routes/operations/orders.js +40 -1
  109. package/addon/routes/operations/routes/index/details.js +14 -1
  110. package/addon/routes/operations/routes/index/new.js +13 -0
  111. package/addon/routes/operations/routes.js +14 -1
  112. package/addon/routes/operations/service-rates/index/new.js +11 -0
  113. package/addon/routes/operations/service-rates.js +14 -1
  114. package/addon/routes/settings/avatars.js +14 -1
  115. package/addon/routes/settings/custom-fields.js +14 -1
  116. package/addon/routes/settings/map.js +14 -1
  117. package/addon/routes/settings/notifications.js +11 -0
  118. package/addon/routes/settings/orchestrator.js +1 -1
  119. package/addon/routes/settings/payments/index.js +1 -1
  120. package/addon/routes/settings/routing.js +14 -1
  121. package/addon/routes/settings/scheduling.js +14 -1
  122. package/addon/routes/settings/telematics.js +21 -0
  123. package/addon/routes/virtual.js +11 -0
  124. package/addon/routes.js +1 -0
  125. package/addon/services/contact-actions.js +50 -89
  126. package/addon/services/driver-actions.js +98 -0
  127. package/addon/templates/settings/telematics.hbs +70 -0
  128. package/addon/utils/profile-login-actions.js +172 -0
  129. package/app/components/admin/telematics-settings.js +1 -0
  130. package/app/components/{modals/reset-customer-credentials.js → map/marker-card/driver.js} +1 -1
  131. package/app/components/map/marker-card/vehicle.js +1 -0
  132. package/app/components/modals/reset-profile-credentials.js +1 -0
  133. package/app/components/profile-identity-hint.js +1 -0
  134. package/app/controllers/settings/telematics.js +1 -0
  135. package/app/routes/settings/telematics.js +1 -0
  136. package/app/templates/settings/telematics.js +1 -0
  137. package/composer.json +2 -2
  138. package/docs/AFAQY.md +1 -1
  139. package/docs/TELEMATICS_QUEUES.md +1 -1
  140. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  141. package/extension.json +1 -1
  142. package/package.json +3 -3
  143. package/scripts/pest-bootstrap.php +12 -0
  144. package/server/config/telemetry.php +12 -0
  145. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  146. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  147. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  148. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  149. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  150. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  151. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  152. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  153. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  154. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  155. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  156. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  157. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  158. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  159. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  160. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  161. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  162. package/server/src/Http/Controllers/Internal/v1/ContactController.php +16 -17
  163. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +54 -4
  164. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  165. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  166. package/server/src/Http/Controllers/Internal/v1/DriverController.php +204 -162
  167. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  168. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  169. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  170. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  171. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  172. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  173. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  174. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  175. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  176. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  177. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  178. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  179. package/server/src/Http/Controllers/Internal/v1/OrderController.php +178 -27
  180. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  181. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  182. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  183. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  184. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  185. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  186. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  187. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  188. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  189. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  190. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  191. package/server/src/Http/Resources/v1/Contact.php +3 -0
  192. package/server/src/Http/Resources/v1/Driver.php +3 -0
  193. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  194. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  195. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  196. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  197. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  198. package/server/src/Models/Contact.php +55 -90
  199. package/server/src/Models/Sensor.php +3 -5
  200. package/server/src/Observers/ContactObserver.php +2 -22
  201. package/server/src/Observers/DriverObserver.php +5 -6
  202. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  203. package/server/src/Providers/FleetOpsServiceProvider.php +18 -0
  204. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  205. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  206. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  207. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  208. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  209. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  210. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  211. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  212. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  213. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  214. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  215. package/server/src/Support/Authorization.php +103 -0
  216. package/server/src/Support/Database/TableIndexes.php +66 -0
  217. package/server/src/Support/OrderTracker.php +2 -2
  218. package/server/src/Support/ProfileAccountManager.php +405 -0
  219. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  220. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  221. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  222. package/server/src/Support/Telematics/TelematicService.php +12 -2
  223. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  224. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  225. package/server/src/Tracking/TrackingOptions.php +2 -2
  226. package/server/src/Traits/AuthorizesMethods.php +37 -0
  227. package/server/src/routes.php +14 -1
  228. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  229. package/server/tests/AnalyticsRoutesTest.php +29 -2
  230. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  231. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  232. package/server/tests/CommandContractsTest.php +4 -4
  233. package/server/tests/ControllerHelperContractsTest.php +3 -11
  234. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +21 -1
  235. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  236. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  237. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  238. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  239. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  240. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  241. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  242. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  243. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  244. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  245. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  246. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  247. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  248. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  249. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  250. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  251. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  252. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  253. package/server/tests/ModelAccessorContractsTest.php +82 -20
  254. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  255. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  256. package/server/tests/ObserverContractsTest.php +28 -3
  257. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  258. package/server/tests/ProviderContractsTest.php +10 -0
  259. package/server/tests/ReportSchemaContractsTest.php +120 -6
  260. package/server/tests/RequestContractsTest.php +19 -11
  261. package/server/tests/SettingControllerContractsTest.php +429 -18
  262. package/server/tests/SmallControllerContractsTest.php +8 -5
  263. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  264. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  265. package/server/tests/TelematicControllerContractsTest.php +22 -4
  266. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  267. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  268. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  269. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  270. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  271. package/server/tests/Unit/Models/ContactTest.php +48 -5
  272. package/server/tests/Unit/Models/SensorTest.php +2 -2
  273. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  274. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  275. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  276. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  277. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  278. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  279. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  280. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  281. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  282. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  283. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  284. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  285. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  286. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  287. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  288. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  289. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  290. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  291. package/translations/en-us.yaml +85 -2
  292. package/translations/uk-ua.yaml +3475 -0
  293. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  294. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -9,7 +9,7 @@ if (!Illuminate\Support\Str::hasMacro('humanize')) {
9
9
  }
10
10
 
11
11
  use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
12
- use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
12
+ use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
13
13
  use Fleetbase\FleetOps\Models\Contact;
14
14
  use Fleetbase\Models\User;
15
15
  use Illuminate\Database\ConnectionResolver;
@@ -75,9 +75,38 @@ function fleetopsContactCustomerUserBoot(): SQLiteConnection
75
75
  });
76
76
  Illuminate\Support\Facades\DB::clearResolvedInstance('db');
77
77
 
78
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
79
+ public function info($hashedValue): array
80
+ {
81
+ return [];
82
+ }
83
+
84
+ public function make($value, array $options = []): string
85
+ {
86
+ return md5((string) $value);
87
+ }
88
+
89
+ public function check($value, $hashedValue, array $options = []): bool
90
+ {
91
+ return md5((string) $value) === $hashedValue;
92
+ }
93
+
94
+ public function needsRehash($hashedValue, array $options = []): bool
95
+ {
96
+ return false;
97
+ }
98
+
99
+ public function verifyConfiguration($value): bool
100
+ {
101
+ return true;
102
+ }
103
+ });
104
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
105
+
78
106
  $schema = $connection->getSchemaBuilder();
79
107
  $tables = [
80
108
  'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title'],
109
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
81
110
  'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'username', 'password', 'timezone', 'status', 'type', 'slug', 'avatar_uuid', 'last_login'],
82
111
  'companies' => ['uuid', 'public_id', 'name', 'timezone', 'owner_uuid'],
83
112
  'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
@@ -188,24 +217,45 @@ test('create user from contact adopts an existing matching user', function () {
188
217
  ->and($connection->table('users')->count())->toBe(1);
189
218
  });
190
219
 
191
- test('create user from contact rejects users already linked to another contact', function () {
220
+ test('create user from contact rejects an account that already holds a contact in the company', function () {
192
221
  $connection = fleetopsContactCustomerUserBoot();
193
- // The contact user() relation constrains users.type against the querying
194
- // contact type, which is null in the static whereHas context — a null-typed
195
- // user keeps the linked-contact subquery matchable.
196
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => null]);
222
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => 'contact']);
197
223
  $connection->table('contacts')->insert(['uuid' => 'contact-owner', 'company_uuid' => 'company-1', 'user_uuid' => 'user-9', 'name' => 'Owner', 'type' => 'contact']);
198
224
  $contact = fleetopsContactCustomerUserContact(['uuid' => 'contact-2', 'email' => 'taken@example.com']);
199
225
 
200
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(UserAlreadyExistsException::class);
226
+ expect(fn () => Contact::createUserFromContact($contact))
227
+ ->toThrow(ProfileIdentityConflictException::class, 'A contact with this email already exists.');
201
228
  });
202
229
 
203
- test('create user from contact rejects staff users for customer contacts', function () {
230
+ test('create user from contact rejects staff users of another organization for customer contacts', function () {
204
231
  $connection = fleetopsContactCustomerUserBoot();
205
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
232
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-other', 'email' => 'staff@example.com', 'type' => 'user']);
206
233
  $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
207
234
 
208
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(CustomerUserConflictException::class);
235
+ expect(fn () => Contact::createUserFromContact($contact))
236
+ ->toThrow(ProfileIdentityConflictException::class, 'This email is already in use by another account.');
237
+ });
238
+
239
+ test('create user from contact links a staff member of the organization without changing their role', function () {
240
+ $connection = fleetopsContactCustomerUserBoot();
241
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'user']);
242
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'Staff@Example.com']);
243
+
244
+ $user = Contact::createUserFromContact($contact);
245
+
246
+ expect($user->uuid)->toBe('user-9')
247
+ ->and($user->type)->toBe('user')
248
+ ->and($contact->user_uuid)->toBe('user-9')
249
+ ->and($connection->table('model_has_roles')->count())->toBe(0);
250
+ });
251
+
252
+ test('create user from contact rejects a managed account of another type', function () {
253
+ $connection = fleetopsContactCustomerUserBoot();
254
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'driver']);
255
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'phone' => '+65 9111-2222']);
256
+
257
+ expect(fn () => Contact::createUserFromContact($contact))
258
+ ->toThrow(ProfileIdentityConflictException::class, 'This phone number is already used by a driver.');
209
259
  });
210
260
 
211
261
  test('create user from contact assigns the customer role and persists the link', function () {
@@ -305,17 +355,27 @@ test('identity lookup helpers resolve users by email phone and uuid', function (
305
355
 
306
356
  test('assert customer identity is available detects staff conflicts', function () {
307
357
  $connection = fleetopsContactCustomerUserBoot();
308
- $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
358
+ $connection->table('users')->insert([
359
+ ['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff'],
360
+ ['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
361
+ ]);
309
362
 
310
363
  $nonCustomer = fleetopsContactCustomerUserContact();
311
364
  $nonCustomer->assertCustomerIdentityIsAvailable();
312
365
 
313
- $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
314
- expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())->toThrow(CustomerUserConflictException::class);
366
+ // A staff member of the organization can hold a customer profile
367
+ $staffMember = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
368
+ $staffMember->assertCustomerIdentityIsAvailable();
369
+
370
+ // A driver's managed account cannot
371
+ $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'driver@example.com']);
372
+ expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())
373
+ ->toThrow(CustomerUserConflictException::class, 'This email is already used by a driver and cannot be used for a customer account.');
315
374
 
316
375
  // An already-assigned user is checked ahead of the identity lookup, so a
317
376
  // contact whose email matches nothing still trips on its own assignment
318
- $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-1', 'email' => 'assigned@example.com', 'type' => 'staff']);
377
+ // (a staff account of another organization)
378
+ $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-other', 'email' => 'assigned@example.com', 'type' => 'staff']);
319
379
  $assigned = fleetopsContactCustomerUserContact([
320
380
  'type' => 'customer',
321
381
  'email' => 'unmatched@example.com',
@@ -368,3 +428,19 @@ test('normalize customer user creates company membership and assigns the custome
368
428
  expect($connection->table('company_users')->where('user_uuid', 'user-n1')->count())->toBe(1)
369
429
  ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1);
370
430
  });
431
+
432
+ test('normalize customer user keeps a staff member of the organization untouched', function () {
433
+ $connection = fleetopsContactCustomerUserBoot();
434
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme']);
435
+ $connection->table('users')->insert(['uuid' => 'user-s1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user', 'status' => 'active']);
436
+
437
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'user_uuid' => 'user-s1']);
438
+ $user = User::where('uuid', 'user-s1')->first();
439
+
440
+ expect($contact->normalizeCustomerUser($user))->toBe($user)
441
+ ->and($contact->getRelation('user'))->toBe($user)
442
+ // A team member's account keeps its own organization membership and role
443
+ ->and($connection->table('company_users')->count())->toBe(0)
444
+ ->and($connection->table('model_has_roles')->count())->toBe(0)
445
+ ->and($connection->table('users')->where('uuid', 'user-s1')->value('type'))->toBe('user');
446
+ });
@@ -25,7 +25,8 @@ class FleetOpsContactUnitCompanyFake extends Company
25
25
  public array $addUserCalls = [];
26
26
  public ?FleetOpsContactUnitCompanyUserFake $companyUser = null;
27
27
 
28
- public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser
28
+ // Matches core-api 1.6.63+, where no role is granted unless one is given.
29
+ public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser
29
30
  {
30
31
  $this->addUserCalls[] = [$user, $role, $status];
31
32
 
@@ -71,9 +72,18 @@ class FleetOpsContactUnitUserFake extends User
71
72
  return true;
72
73
  }
73
74
 
75
+ public function save(array $options = []): bool
76
+ {
77
+ $this->updates[] = $this->getDirty();
78
+ $this->syncOriginal();
79
+
80
+ return true;
81
+ }
82
+
74
83
  public function delete()
75
84
  {
76
85
  $this->deleted = true;
86
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
77
87
 
78
88
  return true;
79
89
  }
@@ -180,6 +190,16 @@ class FleetOpsContactUnitAssignableFake extends FleetOpsContactUnitFake
180
190
  }
181
191
  }
182
192
 
193
+ function fleetopsContactUnitUseProfileTables(): SQLiteConnection
194
+ {
195
+ $connection = fleetopsContactUnitUseInMemoryConnection();
196
+ $connection->statement('create table drivers (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
197
+ $connection->statement('create table contacts (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
198
+ $connection->statement('create table company_users (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, created_at datetime null, updated_at datetime null, deleted_at datetime null)');
199
+
200
+ return $connection;
201
+ }
202
+
183
203
  function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
184
204
  {
185
205
  $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
@@ -496,6 +516,8 @@ test('contact user conflict helpers guard staff users and allow customers', func
496
516
  });
497
517
 
498
518
  test('contact sync delete and user presence helpers use loaded user relations', function () {
519
+ fleetopsContactUnitUseProfileTables();
520
+
499
521
  $user = new FleetOpsContactUnitUserFake();
500
522
  $user->setRawAttributes([
501
523
  'uuid' => 'user-uuid',
@@ -521,6 +543,7 @@ test('contact sync delete and user presence helpers use loaded user relations',
521
543
  ]);
522
544
  $contact->setRelation('user', $user);
523
545
  $contact->fakeUser = $user;
546
+ $contact->exists = true;
524
547
 
525
548
  expect($contact->syncWithUser())->toBeTrue()
526
549
  ->and($user->updates)->toBe([[
@@ -543,20 +566,40 @@ test('contact sync delete and user presence helpers use loaded user relations',
543
566
  ->and($unlinked->doesntHaveUser())->toBeTrue();
544
567
  });
545
568
 
546
- test('contact delete user ignores mismatched relation types', function () {
569
+ test('contact delete user leaves team member accounts alone', function () {
547
570
  $user = new FleetOpsContactUnitUserFake();
548
571
  $user->setRawAttributes([
549
572
  'uuid' => 'user-uuid',
550
- 'type' => 'customer',
573
+ 'type' => 'user',
551
574
  ], true);
552
575
 
553
- $contact = new FleetOpsContactUnitFake(['type' => 'contact']);
554
- $contact->setRelation('user', $user);
576
+ $contact = new FleetOpsContactUnitFake(['type' => 'customer']);
577
+ $contact->fakeUser = $user;
555
578
 
556
579
  expect($contact->deleteUser())->toBeFalse()
557
580
  ->and($user->deleted)->toBeFalse();
558
581
  });
559
582
 
583
+ test('contact delete user keeps a managed account still used by another organization', function () {
584
+ $connection = fleetopsContactUnitUseProfileTables();
585
+ $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company']);
586
+ $connection->table('company_users')->insert([
587
+ ['uuid' => 'cu-1', 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid'],
588
+ ['uuid' => 'cu-2', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company'],
589
+ ]);
590
+
591
+ $user = new FleetOpsContactUnitUserFake();
592
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
593
+
594
+ $contact = new FleetOpsContactUnitFake(['type' => 'contact', 'company_uuid' => 'company-uuid']);
595
+ $contact->fakeUser = $user;
596
+
597
+ // The account only leaves this organization
598
+ expect($contact->deleteUser())->toBeFalse()
599
+ ->and($user->deleted)->toBeFalse()
600
+ ->and($connection->table('company_users')->whereNull('deleted_at')->pluck('company_uuid')->all())->toBe(['other-company']);
601
+ });
602
+
560
603
  test('contact real user helpers return loaded relations without database lookup', function () {
561
604
  $user = new FleetOpsContactUnitUserFake();
562
605
  $user->setRawAttributes([
@@ -116,7 +116,7 @@ test('sensor model exposes relation builders and logging options', function () {
116
116
 
117
117
  $sensor = new Sensor();
118
118
 
119
- expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type'])
119
+ expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type'])
120
120
  ->and($sensor->getSlugOptions()->slugField)->toBe('slug')
121
121
  ->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue()
122
122
  ->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class)
@@ -222,7 +222,7 @@ test('sensor scopes write expected query constraints', function () {
222
222
  ->and($sensor->scopeActive($query))->toBe($query)
223
223
  ->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query)
224
224
  ->and($sensor->scopeOutOfThreshold($query))->toBe($query)
225
- ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']])
225
+ ->and($query->calls[0])->toBe(['where', ['type', 'temperature']])
226
226
  ->and($query->calls[1])->toBe(['where', ['status', 'active']])
227
227
  ->and($query->calls[2][0])->toBe('where')
228
228
  ->and($query->calls[2][1][0])->toBe('last_reading_at')
@@ -176,45 +176,51 @@ test('customer creation forces the customer type and scopes orders', function ()
176
176
  expect($customer->orders())->toBeInstanceOf(HasMany::class);
177
177
  });
178
178
 
179
- test('contact observer availability checks and deletion resolve against users', function () {
179
+ test('contact observer deletion releases the managed account and leaves team members alone', function () {
180
180
  $connection = fleetopsSmallModelBoot();
181
- $connection->table('users')->insert(['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
181
+ config(['activitylog.enabled' => false]);
182
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
183
+ $connection->getSchemaBuilder()->create('drivers', function ($blueprint) {
184
+ $blueprint->increments('id');
185
+ $blueprint->string('uuid')->nullable();
186
+ $blueprint->string('company_uuid')->nullable();
187
+ $blueprint->string('user_uuid')->nullable();
188
+ $blueprint->timestamp('deleted_at')->nullable();
189
+ });
190
+ $connection->table('users')->insert([
191
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'],
192
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'],
193
+ ]);
182
194
 
183
- $observer = new ContactObserver();
184
- $reflection = new ReflectionClass($observer);
195
+ $observer = new ContactObserver();
185
196
 
186
197
  $contact = new Fleetbase\FleetOps\Models\Contact();
187
- $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'email' => 'taken@example.test', 'phone' => '+6512345678', 'type' => 'contact'], true);
198
+ $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true);
188
199
  $contact->exists = true;
189
200
 
190
- $emailCheck = $reflection->getMethod('isEmailUnavailable');
191
- $emailCheck->setAccessible(true);
192
- expect($emailCheck->invoke($observer, $contact))->toBeTrue();
193
-
194
- $phoneCheck = $reflection->getMethod('isPhoneUnavailable');
195
- $phoneCheck->setAccessible(true);
196
- expect($phoneCheck->invoke($observer, $contact))->toBeTrue();
201
+ $staffContact = new Fleetbase\FleetOps\Models\Contact();
202
+ $staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true);
203
+ $staffContact->exists = true;
197
204
 
198
- $contact->email = 'free@example.test';
199
- $contact->phone = '+6599999999';
200
- expect($emailCheck->invoke($observer, $contact))->toBeFalse()
201
- ->and($phoneCheck->invoke($observer, $contact))->toBeFalse();
202
-
203
- // Deletion removes the associated user account
204
205
  $observer->deleted($contact);
205
- expect(true)->toBeTrue();
206
+ $observer->deleted($staffContact);
207
+
208
+ // The managed account had no other profile, so it is deleted
209
+ expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull()
210
+ ->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull();
206
211
  });
207
212
 
208
213
  test('contact observer rejects saves whose email or phone belongs to another account', function () {
209
214
  $connection = fleetopsSmallModelBoot();
210
- $connection->table('users')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
215
+ $connection->table('users')->insert([
216
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'],
217
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'],
218
+ ]);
211
219
 
212
220
  $observer = new ContactObserver();
213
221
 
214
- // The availability checks are private, so the guards can only be reached
215
- // through saving() with a contact that genuinely collides on a real table.
216
222
  // hasUser() keys off a well-formed uuid, so a real one keeps saving() from
217
- // detouring into account provisioning before it reaches the guards.
223
+ // detouring into account provisioning before it reaches the sync.
218
224
  $contact = new Fleetbase\FleetOps\Models\Contact();
219
225
  $contact->setRawAttributes([
220
226
  'uuid' => 'contact-collide',
@@ -226,23 +232,16 @@ test('contact observer rejects saves whose email or phone belongs to another acc
226
232
  ], true);
227
233
  $contact->exists = true;
228
234
 
229
- // wasChanged() reads the post-save change set, which setRawAttributes does
230
- // not populate — syncChanges promotes the pending edit into it.
231
235
  $contact->email = 'taken@example.test';
232
- $contact->syncChanges();
233
236
 
234
237
  expect(fn () => $observer->saving($contact))
235
- ->toThrow(Exception::class, 'Email attempting to update for contact is not available.');
238
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.');
236
239
 
237
- // The phone guard sits behind the email one, so it only surfaces once the
238
- // email is back to an available value
239
- $contact->syncOriginal();
240
240
  $contact->email = 'free@example.test';
241
241
  $contact->phone = '+6512345678';
242
- $contact->syncChanges();
243
242
 
244
243
  expect(fn () => $observer->saving($contact))
245
- ->toThrow(Exception::class, 'Phone attempting to update for contact is not available.');
244
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.');
246
245
  });
247
246
 
248
247
  test('tracking status request authorizes by session and rejects duplicates', function () {
@@ -98,11 +98,16 @@ test('driver observer seams unassign orders and resolve the driver user', functi
98
98
  ->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull()
99
99
  ->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other');
100
100
 
101
- // The linked account resolves only when it is still a plain user
101
+ // The linked account resolves whatever its type: releaseForProfile decides
102
+ // whether a managed account is deleted and leaves a team member alone
102
103
  expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1');
103
104
 
105
+ $managedDriver = new Driver();
106
+ $managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
107
+ expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2');
108
+
104
109
  $driverWithoutUser = new Driver();
105
- $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
110
+ $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true);
106
111
  expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull();
107
112
  });
108
113
 
@@ -251,3 +251,23 @@ test('connection settings fall back to defaults when the settings table is absen
251
251
  expect($call('resolveVroomBaseUri'))->toBeString()
252
252
  ->and($call('resolveVroomApiKey'))->toBeIn([null, '']);
253
253
  });
254
+
255
+ test('allocation resolves organization settings from the orders company regardless of session', function (?string $sessionCompany) {
256
+ $connection = fleetopsVroomBoot();
257
+ $connection->table('settings')->insert([
258
+ ['key' => 'company.company-2.vroom', 'value' => json_encode(['api_host' => 'https://company-two-vroom.test'])],
259
+ ['key' => 'company.company-1.vroom', 'value' => json_encode(['api_host' => 'https://company-one-vroom.test'])],
260
+ ]);
261
+
262
+ $order = fleetopsVroomOrder('order_vroom_queued', fleetopsVroomPlace('p-9', new Point(1.31, 103.81)), null);
263
+ $order->setAttribute('company_uuid', 'company-2');
264
+ $vehicle = fleetopsVroomVehicle('vehicle_vroom_queued', new Point(1.20, 103.70));
265
+
266
+ Http::fake(['*' => Http::response(['routes' => [], 'unassigned' => [], 'summary' => []], 200)]);
267
+
268
+ // Background allocation must not depend on an absent or unrelated session.
269
+ session(['company' => $sessionCompany]);
270
+ (new VroomOrchestrationEngine())->allocate(collect([$order]), collect([$vehicle]));
271
+
272
+ Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://company-two-vroom.test'));
273
+ })->with([null, 'company-1']);
@@ -4,6 +4,10 @@ if (!class_exists('Fleetbase\Ai\Support\AiCapabilityRegistry', false)) {
4
4
  eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }');
5
5
  }
6
6
 
7
+ if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) {
8
+ eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }');
9
+ }
10
+
7
11
  if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) {
8
12
  eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }');
9
13
  }
@@ -44,9 +48,18 @@ test('ai capability registration wires query resources and capabilities', functi
44
48
  $capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class);
45
49
 
46
50
  fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n");
47
- expect($capabilityRegistry->registered)->toHaveCount(9)
51
+ expect($capabilityRegistry->registered)->toHaveCount(13)
48
52
  ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability)))
49
- ->toContain(Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class);
53
+ ->toContain(
54
+ Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class,
55
+ Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class,
56
+ Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class,
57
+ Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class,
58
+ Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class,
59
+ );
60
+
61
+ $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class);
62
+ expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open');
50
63
 
51
64
  // The query resources helper registers the fleet-ops queryables directly
52
65
  $freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry();
@@ -117,4 +117,46 @@ test('search capability queries scope companies with eager relations', function
117
117
  ->and($helper('vehicleSearchQuery')->count())->toBe(0)
118
118
  ->and($helper('driverSearchQuery')->count())->toBe(3)
119
119
  ->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0);
120
+
121
+ foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) {
122
+ expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
123
+ }
124
+ });
125
+
126
+ test('asset status scoped queries resolve a list permission for every supported model', function () {
127
+ fleetopsAssetStatusCapabilityBoot();
128
+
129
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
130
+ $scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery');
131
+ $scoped->setAccessible(true);
132
+
133
+ foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) {
134
+ expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
135
+ }
136
+ });
137
+
138
+ test('capability queries apply IAM directives for the list permission when the directive macro is available', function () {
139
+ fleetopsAssetStatusCapabilityBoot();
140
+
141
+ if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
142
+ Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) {
143
+ $GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names;
144
+
145
+ return $this;
146
+ });
147
+ }
148
+
149
+ $GLOBALS['fleetopsAppliedDirectivePermissions'] = [];
150
+
151
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
152
+ $scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission');
153
+ $scope->setAccessible(true);
154
+ $plain = new stdClass();
155
+
156
+ expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain);
157
+
158
+ $builder = Fleetbase\FleetOps\Models\Driver::query();
159
+
160
+ expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder)
161
+ ->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver');
120
162
  });
@@ -123,6 +123,11 @@ class FleetOpsOrderInsightsCapabilityFake extends OrderInsightsCapability
123
123
  return $this->allowed && $permission === 'fleet-ops see order';
124
124
  }
125
125
 
126
+ protected function currency(): string
127
+ {
128
+ return 'USD';
129
+ }
130
+
126
131
  protected function orderQuery(?string $companyUuid): mixed
127
132
  {
128
133
  $this->companies[] = $companyUuid;
@@ -183,6 +188,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
183
188
  'authorized' => true,
184
189
  'metric' => 'orders',
185
190
  'amount_threshold' => 125.50,
191
+ 'amount_currency' => 'USD',
186
192
  'count' => 7,
187
193
  'counts_by_status' => ['completed' => 5, 'active' => 2],
188
194
  'sample_order_ids' => ['order_1', 'order_2'],
@@ -196,7 +202,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
196
202
  ->and($capability->companies)->toBe(['company-123'])
197
203
  ->and($capability->query->recordedCalls())->toContain(
198
204
  ['whereBetween', 'created_at', [$start, $end]],
199
- ['whereHas', 'transaction', [['where', 'amount', '>', 125.50]]],
205
+ ['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]],
200
206
  ['count'],
201
207
  ['selectRaw', 'status, count(*) as aggregate'],
202
208
  ['groupBy', 'status'],
@@ -224,3 +230,15 @@ test('order insights resolve omits optional filters when prompt has no date or a
224
230
  ->and($capability->query->recordedCalls())->not->toContain(['whereBetween'])
225
231
  ->and($capability->query->recordedCalls())->not->toContain(['whereHas']);
226
232
  });
233
+
234
+ test('order insights converts major-unit thresholds using the currency exponent', function () {
235
+ $capability = new FleetOpsOrderInsightsCapabilityFake();
236
+ $method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits');
237
+ $method->setAccessible(true);
238
+
239
+ expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000)
240
+ ->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550)
241
+ ->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000)
242
+ ->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500)
243
+ ->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000);
244
+ });