@fleetbase/fleetops-engine 0.6.71 → 0.6.72

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 (378) hide show
  1. package/RELEASE.md +15 -18
  2. package/addon/components/admin/tracking-page-settings.hbs +56 -0
  3. package/addon/components/admin/tracking-page-settings.js +67 -0
  4. package/addon/components/device/form.hbs +2 -2
  5. package/addon/components/driver/details.hbs +1 -1
  6. package/addon/components/driver/form.hbs +1 -1
  7. package/addon/components/equipment/form.hbs +1 -1
  8. package/addon/components/layout/fleet-ops-sidebar.js +11 -3
  9. package/addon/components/maintenance/form.hbs +3 -3
  10. package/addon/components/map/google-live-map.js +2 -2
  11. package/addon/components/map/leaflet-live-map.js +45 -32
  12. package/addon/components/orchestrator/phase-builder.js +2 -1
  13. package/addon/components/orchestrator-workbench.hbs +7 -1
  14. package/addon/components/orchestrator-workbench.js +16 -2
  15. package/addon/components/order/form/route.hbs +1 -1
  16. package/addon/components/order/form/route.js +13 -1
  17. package/addon/components/service-rate/details.hbs +1 -1
  18. package/addon/components/service-rate/form.hbs +1 -1
  19. package/addon/components/tracking-page/full-view.hbs +276 -0
  20. package/addon/components/tracking-page/full-view.js +474 -0
  21. package/addon/components/tracking-page/locale-selector.js +30 -0
  22. package/addon/components/tracking-page/map.hbs +44 -0
  23. package/addon/components/tracking-page/map.js +328 -0
  24. package/addon/components/tracking-page/order-list.hbs +35 -0
  25. package/addon/components/tracking-page/public-view.hbs +60 -0
  26. package/addon/components/tracking-page/settings-preview.hbs +21 -0
  27. package/addon/components/tracking-page/settings-preview.js +23 -0
  28. package/addon/components/tracking-page/verify-panel.hbs +109 -0
  29. package/addon/components/tracking-page.hbs +142 -0
  30. package/addon/components/tracking-page.js +564 -0
  31. package/addon/components/trailer/form.hbs +2 -2
  32. package/addon/components/vehicle/form.hbs +3 -3
  33. package/addon/components/warranty/form.hbs +2 -2
  34. package/addon/components/widget/live-fleet.js +54 -6
  35. package/addon/components/widget/proof-of-delivery.hbs +49 -0
  36. package/addon/components/widget/proof-of-delivery.js +62 -0
  37. package/addon/components/work-order/form.hbs +3 -3
  38. package/addon/controllers/operations/orders/index/details.js +19 -3
  39. package/addon/controllers/operations/scheduler/index.js +45 -14
  40. package/addon/controllers/settings/index.js +6 -0
  41. package/addon/controllers/settings/map.js +75 -0
  42. package/addon/controllers/settings/orchestrator.js +8 -6
  43. package/addon/controllers/settings/tracking-page.js +212 -0
  44. package/addon/extension.js +65 -1
  45. package/addon/helpers/parcel-box-image.js +16 -0
  46. package/addon/routes/operations/orders/index/new.js +5 -4
  47. package/addon/routes/operations/scheduler/index.js +1 -1
  48. package/addon/routes/settings/tracking-page.js +31 -0
  49. package/addon/routes.js +1 -0
  50. package/addon/services/location.js +69 -21
  51. package/addon/services/map-settings.js +28 -0
  52. package/addon/services/order-allocation.js +1 -1
  53. package/addon/styles/fleetops-engine.css +1391 -0
  54. package/addon/templates/settings/map.hbs +46 -0
  55. package/addon/templates/settings/tracking-page.hbs +318 -0
  56. package/addon/utils/identity-cell-resource.js +2 -56
  57. package/addon/utils/map-default-view.js +132 -0
  58. package/addon/utils/placeholder-images.js +4 -69
  59. package/addon/utils/resource-descriptors/assets.js +2 -57
  60. package/addon/utils/resource-descriptors/helpers.js +23 -122
  61. package/addon/utils/resource-descriptors/operations.js +2 -61
  62. package/addon/utils/resource-descriptors/people.js +3 -142
  63. package/addon/utils/resource-descriptors/shared.js +18 -0
  64. package/addon/utils/tracking-live-source.js +123 -0
  65. package/addon/utils/tracking-page-theme.js +123 -0
  66. package/app/components/admin/tracking-page-settings.js +1 -0
  67. package/app/components/tracking-page/full-view.js +1 -0
  68. package/app/components/tracking-page/locale-selector.js +1 -0
  69. package/app/components/tracking-page/map.js +1 -0
  70. package/app/components/tracking-page/order-list.js +1 -0
  71. package/app/components/tracking-page/public-view.js +1 -0
  72. package/app/components/tracking-page/settings-preview.js +1 -0
  73. package/app/components/tracking-page/verify-panel.js +1 -0
  74. package/app/components/tracking-page.js +1 -0
  75. package/app/components/widget/proof-of-delivery.js +1 -0
  76. package/app/controllers/settings/tracking-page.js +1 -0
  77. package/app/helpers/parcel-box-image.js +1 -0
  78. package/app/routes/settings/tracking-page.js +1 -0
  79. package/app/templates/settings/tracking-page.js +1 -0
  80. package/app/utils/map-default-view.js +1 -0
  81. package/app/utils/tracking-live-source.js +1 -0
  82. package/app/utils/tracking-page-theme.js +1 -0
  83. package/composer.json +2 -2
  84. package/extension.json +1 -1
  85. package/package.json +4 -4
  86. package/scripts/pest-bootstrap.php +4 -0
  87. package/server/config/fleetops.php +4 -0
  88. package/server/migrations/2026_10_08_000001_create_tracking_sessions_tables.php +51 -0
  89. package/server/migrations/2026_10_10_000001_add_trashed_index_to_device_events.php +40 -0
  90. package/server/migrations/2026_10_10_000002_add_trashed_index_to_positions.php +40 -0
  91. package/server/migrations/2026_10_12_000001_add_deleted_at_to_tracking_sessions_tables.php +32 -0
  92. package/server/resources/views/labels/default.php +2 -2
  93. package/server/resources/views/labels/entity-label.php +2 -2
  94. package/server/resources/views/labels/waypoint-label.php +2 -2
  95. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  96. package/server/src/Console/Commands/ProcessOperationalAlerts.php +1 -0
  97. package/server/src/Console/Commands/PruneTelematicsData.php +101 -26
  98. package/server/src/Events/OrderChanged.php +257 -0
  99. package/server/src/Events/OrderDriverUnassigned.php +68 -0
  100. package/server/src/Exceptions/OrderEditLockedException.php +32 -0
  101. package/server/src/Expansions/CompanyExpansion.php +3 -2
  102. package/server/src/Expansions/UserExpansion.php +12 -10
  103. package/server/src/Http/Controllers/Api/v1/CustomerController.php +108 -18
  104. package/server/src/Http/Controllers/Api/v1/DriverController.php +95 -10
  105. package/server/src/Http/Controllers/Api/v1/EntityController.php +66 -0
  106. package/server/src/Http/Controllers/Api/v1/FleetController.php +10 -4
  107. package/server/src/Http/Controllers/Api/v1/OrderController.php +139 -36
  108. package/server/src/Http/Controllers/Api/v1/PayloadController.php +76 -8
  109. package/server/src/Http/Controllers/Api/v1/PlaceController.php +3 -3
  110. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +6 -32
  111. package/server/src/Http/Controllers/Api/v1/VehicleController.php +5 -0
  112. package/server/src/Http/Controllers/Internal/v1/ContactController.php +3 -0
  113. package/server/src/Http/Controllers/Internal/v1/DriverController.php +10 -0
  114. package/server/src/Http/Controllers/Internal/v1/FleetController.php +16 -2
  115. package/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php +13 -8
  116. package/server/src/Http/Controllers/Internal/v1/IssueController.php +2 -0
  117. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +4 -2
  118. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +93 -11
  119. package/server/src/Http/Controllers/Internal/v1/OrderController.php +84 -24
  120. package/server/src/Http/Controllers/Internal/v1/PositionController.php +22 -0
  121. package/server/src/Http/Controllers/Internal/v1/ProofController.php +55 -4
  122. package/server/src/Http/Controllers/Internal/v1/SettingController.php +303 -10
  123. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +1 -0
  124. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +1 -0
  125. package/server/src/Http/Controllers/Public/TrackingController.php +518 -0
  126. package/server/src/Http/Filter/ContactFilter.php +1 -1
  127. package/server/src/Http/Filter/InspectionFormFilter.php +23 -0
  128. package/server/src/Http/Filter/InspectionSubmissionFilter.php +23 -0
  129. package/server/src/Http/Filter/MaintenanceFilter.php +23 -0
  130. package/server/src/Http/Filter/OrderFilter.php +39 -4
  131. package/server/src/Http/Filter/ProofFilter.php +18 -0
  132. package/server/src/Http/Filter/RouteFilter.php +18 -0
  133. package/server/src/Http/Filter/TelematicFilter.php +23 -0
  134. package/server/src/Http/Filter/WarrantyFilter.php +23 -0
  135. package/server/src/Http/Requests/CancelOrderRequest.php +2 -1
  136. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +14 -1
  137. package/server/src/Http/Resources/v1/Index/Customer.php +18 -0
  138. package/server/src/Http/Resources/v1/Index/Order.php +11 -2
  139. package/server/src/Http/Resources/v1/PublicOrderTracking.php +153 -0
  140. package/server/src/Http/Resources/v1/TrackingNumber.php +8 -34
  141. package/server/src/Http/Resources/v1/Zone.php +31 -23
  142. package/server/src/Jobs/FinalizeApiOrderCreation.php +3 -1
  143. package/server/src/Jobs/ProcessAllocationJob.php +16 -6
  144. package/server/src/Jobs/PublishTrackingUpdate.php +50 -0
  145. package/server/src/Listeners/HandleGeofenceEntered.php +31 -15
  146. package/server/src/Listeners/HandleOrderCanceled.php +9 -1
  147. package/server/src/Listeners/HandleUserRemovedFromCompany.php +2 -1
  148. package/server/src/Listeners/PublishTrackingUpdates.php +27 -0
  149. package/server/src/Mail/CustomerCredentialsMail.php +4 -2
  150. package/server/src/Models/AssetConnection.php +3 -0
  151. package/server/src/Models/Contact.php +3 -0
  152. package/server/src/Models/Device.php +28 -0
  153. package/server/src/Models/DeviceEvent.php +7 -0
  154. package/server/src/Models/Driver.php +20 -7
  155. package/server/src/Models/Entity.php +12 -4
  156. package/server/src/Models/Fleet.php +11 -32
  157. package/server/src/Models/FleetDriver.php +3 -0
  158. package/server/src/Models/FleetVehicle.php +3 -0
  159. package/server/src/Models/FuelReport.php +5 -13
  160. package/server/src/Models/Issue.php +4 -16
  161. package/server/src/Models/Order.php +200 -9
  162. package/server/src/Models/Payload.php +227 -35
  163. package/server/src/Models/Place.php +7 -1
  164. package/server/src/Models/ServiceArea.php +7 -0
  165. package/server/src/Models/ServiceRate.php +7 -17
  166. package/server/src/Models/TrackingNumber.php +101 -2
  167. package/server/src/Models/TrackingSession.php +33 -0
  168. package/server/src/Models/TrackingSessionGrant.php +22 -0
  169. package/server/src/Models/TrackingStatus.php +6 -0
  170. package/server/src/Models/Vehicle.php +21 -2
  171. package/server/src/Models/Vendor.php +7 -0
  172. package/server/src/Models/Waypoint.php +13 -4
  173. package/server/src/Notifications/OrderAssigned.php +2 -1
  174. package/server/src/Notifications/OrderCanceled.php +7 -1
  175. package/server/src/Notifications/OrderChanged.php +87 -0
  176. package/server/src/Notifications/OrderCompleted.php +2 -1
  177. package/server/src/Notifications/OrderDispatchFailed.php +2 -1
  178. package/server/src/Notifications/OrderDispatched.php +2 -1
  179. package/server/src/Notifications/OrderFailed.php +2 -1
  180. package/server/src/Notifications/OrderUnassigned.php +66 -0
  181. package/server/src/Notifications/WaypointCompleted.php +2 -1
  182. package/server/src/Observers/CompanyUserObserver.php +8 -7
  183. package/server/src/Observers/DriverObserver.php +6 -1
  184. package/server/src/Observers/OrderObserver.php +141 -1
  185. package/server/src/Observers/TrackingNumberObserver.php +18 -6
  186. package/server/src/Observers/UserObserver.php +2 -1
  187. package/server/src/Observers/VehicleObserver.php +8 -3
  188. package/server/src/Orchestration/Engines/RouteSequencingEngine.php +21 -3
  189. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +38 -8
  190. package/server/src/Orchestration/OrchestrationEngineRegistry.php +61 -1
  191. package/server/src/Orchestration/Support/OrchestratorSettings.php +61 -0
  192. package/server/src/Orchestration/Support/RouteMetrics.php +165 -0
  193. package/server/src/Providers/EventServiceProvider.php +11 -0
  194. package/server/src/Providers/FleetOpsServiceProvider.php +13 -0
  195. package/server/src/Support/ApiCacheThrottle.php +76 -0
  196. package/server/src/Support/LiveOrderQuery.php +3 -1
  197. package/server/src/Support/OrderEdit.php +365 -0
  198. package/server/src/Support/OrderEditPolicy.php +292 -0
  199. package/server/src/Support/ProfileAccountManager.php +54 -1
  200. package/server/src/Support/ResolvesOrderServiceStops.php +5 -0
  201. package/server/src/Support/SocketChannels.php +346 -0
  202. package/server/src/Support/Telematics/TelematicService.php +7 -2
  203. package/server/src/Support/Telematics/Telemetry/Inbox.php +8 -1
  204. package/server/src/Support/TrackingChannel.php +55 -0
  205. package/server/src/Support/TrackingCode.php +242 -0
  206. package/server/src/Support/TrackingPage/TrackingAccess.php +356 -0
  207. package/server/src/Support/TrackingPage/TrackingPageConfig.php +264 -0
  208. package/server/src/Support/TrackingPage/TrackingRecipient.php +111 -0
  209. package/server/src/Support/TrackingPage/TrackingResolver.php +201 -0
  210. package/server/src/Support/TrackingPage/TrackingTarget.php +40 -0
  211. package/server/src/Support/TrackingPage/TrackingView.php +543 -0
  212. package/server/src/Support/TrackingPublisher.php +261 -0
  213. package/server/src/Support/TrackingScope.php +182 -0
  214. package/server/src/Support/TrackingUpdate.php +402 -0
  215. package/server/src/Support/Utils.php +15 -3
  216. package/server/src/Traits/HasTrackingNumber.php +2 -0
  217. package/server/src/routes.php +39 -7
  218. package/server/tests/ApiCustomerControllerContractsTest.php +54 -7
  219. package/server/tests/ApiDriverPasswordContractsTest.php +25 -21
  220. package/server/tests/ApiEntityControllerContractsTest.php +84 -0
  221. package/server/tests/ApiOrderControllerContractsTest.php +245 -0
  222. package/server/tests/ApiPayloadControllerContractsTest.php +121 -0
  223. package/server/tests/ApiPlaceControllerContractsTest.php +1 -1
  224. package/server/tests/ApiTrackingNumberControllerContractsTest.php +6 -9
  225. package/server/tests/CompactResourceSerializationTest.php +23 -59
  226. package/server/tests/ControllerFilterContractsTest.php +1 -1
  227. package/server/tests/CustomerContactIdentitySafetyTest.php +1 -1
  228. package/server/tests/EventContractsTest.php +26 -60
  229. package/server/tests/ExpansionContractsTest.php +15 -2
  230. package/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php +29 -3
  231. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +23 -0
  232. package/server/tests/Feature/Http/Api/OrderControllerNearbyFiltersTest.php +16 -16
  233. package/server/tests/Feature/Http/Api/OrderControllerQueryFiltersTest.php +41 -14
  234. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +57 -22
  235. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +23 -9
  236. package/server/tests/Feature/Http/Internal/OrchestrationControllerContractsTest.php +171 -1
  237. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +110 -14
  238. package/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php +7 -5
  239. package/server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php +135 -0
  240. package/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php +3 -3
  241. package/server/tests/Feature/Http/Internal/ProofControllerRecentTest.php +170 -0
  242. package/server/tests/Feature/Http/Public/TrackingControllerTest.php +495 -0
  243. package/server/tests/IndexCustomerPhotoTest.php +151 -0
  244. package/server/tests/ModelAccessorContractsTest.php +4 -3
  245. package/server/tests/NotificationAndMailContractsTest.php +5 -8
  246. package/server/tests/ObserverContractsTest.php +72 -10
  247. package/server/tests/OrderActivityFlowRegressionTest.php +3 -2
  248. package/server/tests/OrderFilterExecutionTest.php +167 -2
  249. package/server/tests/ProcessAllocationJobContractsTest.php +20 -0
  250. package/server/tests/PublicOrderTrackingResourceTest.php +221 -0
  251. package/server/tests/QueueJobContractsTest.php +2 -1
  252. package/server/tests/RequestContractsTest.php +1 -1
  253. package/server/tests/RouteRegistrationExecutionTest.php +26 -0
  254. package/server/tests/RouteSequencingEngineTest.php +17 -0
  255. package/server/tests/SettingControllerContractsTest.php +110 -0
  256. package/server/tests/Support/TrackingPageDatabase.php +200 -0
  257. package/server/tests/TenantScopingTest.php +206 -0
  258. package/server/tests/TrackingPageSettingsTest.php +343 -0
  259. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +51 -0
  260. package/server/tests/Unit/Http/Filter/ContactFilterAddressTest.php +61 -0
  261. package/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php +30 -0
  262. package/server/tests/Unit/Jobs/FinalizeApiOrderCreationDispatchGuardTest.php +266 -0
  263. package/server/tests/Unit/Listeners/HandleGeofenceEnteredTest.php +182 -22
  264. package/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php +5 -0
  265. package/server/tests/Unit/Models/DriverAssignVehicleTest.php +81 -0
  266. package/server/tests/Unit/Models/FleetTest.php +4 -3
  267. package/server/tests/Unit/Models/OrderCancelReasonTest.php +143 -0
  268. package/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php +60 -0
  269. package/server/tests/Unit/Models/OrderPayloadRouteTest.php +4 -2
  270. package/server/tests/Unit/Models/PayloadTest.php +18 -4
  271. package/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php +2 -2
  272. package/server/tests/Unit/Models/TrackingNumberTest.php +67 -6
  273. package/server/tests/Unit/Models/VehicleTest.php +16 -2
  274. package/server/tests/Unit/Models/WaypointTest.php +4 -3
  275. package/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +59 -0
  276. package/server/tests/Unit/Notifications/TerminalOrderNotificationsTest.php +3 -3
  277. package/server/tests/Unit/Observers/DriverDeletionCleanupTest.php +158 -0
  278. package/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php +20 -0
  279. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +3 -3
  280. package/server/tests/Unit/Observers/OrderAssignmentObserverTest.php +100 -0
  281. package/server/tests/Unit/Observers/OrderDeleteCascadeTest.php +213 -0
  282. package/server/tests/Unit/Observers/VehicleObserverDeleteTest.php +67 -0
  283. package/server/tests/Unit/Orchestration/EngineSelectionAndRouteMetricsTest.php +336 -0
  284. package/server/tests/Unit/Orchestration/Engines/VroomOrchestrationEngineLifecycleTest.php +8 -1
  285. package/server/tests/Unit/Support/ApiCacheDependenciesTest.php +347 -0
  286. package/server/tests/Unit/Support/OrderEditPolicyTest.php +156 -0
  287. package/server/tests/Unit/Support/OrderOperationsTest.php +514 -0
  288. package/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php +4 -3
  289. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +141 -0
  290. package/server/tests/Unit/Support/QuerySeamsTest.php +5 -1
  291. package/server/tests/Unit/Support/SocketChannelsTest.php +396 -0
  292. package/server/tests/Unit/Support/TrackingChannelTest.php +232 -0
  293. package/server/tests/Unit/Support/TrackingCodeTest.php +186 -0
  294. package/server/tests/Unit/Support/TrackingPage/TrackingAccessTest.php +200 -0
  295. package/server/tests/Unit/Support/TrackingPage/TrackingRecipientTest.php +51 -0
  296. package/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php +181 -0
  297. package/server/tests/Unit/Support/TrackingPage/TrackingViewTest.php +479 -0
  298. package/server/tests/Unit/Support/TrackingPublisherTest.php +425 -0
  299. package/server/tests/Unit/Support/TrackingUpdateTest.php +363 -0
  300. package/server/tests/Unit/Support/UtilsAdditionalTest.php +9 -0
  301. package/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php +25 -0
  302. package/server/tests/VehicleResourceContractsTest.php +46 -0
  303. package/server/tests/VroomOrchestrationEngineTest.php +34 -1
  304. package/server/tests/ZoneGeofenceSettingsResourceTest.php +121 -0
  305. package/translations/ar-ae.yml +274 -0
  306. package/translations/bg-bg.yaml +274 -0
  307. package/translations/en-us.yaml +287 -0
  308. package/translations/es-pa.yaml +274 -0
  309. package/translations/fr-fr.yaml +274 -0
  310. package/translations/mn-mn.yaml +274 -0
  311. package/translations/pt-br.yaml +274 -0
  312. package/translations/ru-ru.yaml +276 -2
  313. package/translations/uk-ua.yaml +275 -1
  314. package/translations/vi-vn.yaml +274 -0
  315. package/addon/components/cell/contact-identity.hbs +0 -1
  316. package/addon/components/cell/customer-identity.hbs +0 -1
  317. package/addon/components/cell/driver-identity.hbs +0 -1
  318. package/addon/components/cell/fleet-identity.hbs +0 -1
  319. package/addon/components/cell/order-identity.hbs +0 -1
  320. package/addon/components/cell/place-identity.hbs +0 -1
  321. package/addon/components/cell/vehicle-identity.hbs +0 -1
  322. package/addon/components/cell/vendor-identity.hbs +0 -1
  323. package/addon/components/contact/pill.hbs +0 -24
  324. package/addon/components/contact/summary.hbs +0 -1
  325. package/addon/components/customer/pill.hbs +0 -24
  326. package/addon/components/customer/summary.hbs +0 -1
  327. package/addon/components/driver/pill.hbs +0 -24
  328. package/addon/components/driver/summary.hbs +0 -1
  329. package/addon/components/fleet/pill.hbs +0 -24
  330. package/addon/components/fleet/summary.hbs +0 -1
  331. package/addon/components/order/pill.hbs +0 -24
  332. package/addon/components/order/summary.hbs +0 -1
  333. package/addon/components/place/pill.hbs +0 -24
  334. package/addon/components/place/summary.hbs +0 -1
  335. package/addon/components/select-option/contact.hbs +0 -12
  336. package/addon/components/select-option/customer.hbs +0 -12
  337. package/addon/components/select-option/driver.hbs +0 -12
  338. package/addon/components/select-option/fleet.hbs +0 -12
  339. package/addon/components/select-option/order.hbs +0 -12
  340. package/addon/components/select-option/place.hbs +0 -12
  341. package/addon/components/select-option/vehicle.hbs +0 -12
  342. package/addon/components/select-option/vendor.hbs +0 -12
  343. package/addon/components/vehicle/pill.hbs +0 -24
  344. package/addon/components/vehicle/summary.hbs +0 -1
  345. package/addon/components/vendor/pill.hbs +0 -24
  346. package/addon/components/vendor/summary.hbs +0 -1
  347. package/app/components/cell/contact-identity.js +0 -1
  348. package/app/components/cell/customer-identity.js +0 -1
  349. package/app/components/cell/driver-identity.js +0 -1
  350. package/app/components/cell/fleet-identity.js +0 -1
  351. package/app/components/cell/order-identity.js +0 -1
  352. package/app/components/cell/place-identity.js +0 -1
  353. package/app/components/cell/vehicle-identity.js +0 -1
  354. package/app/components/cell/vendor-identity.js +0 -1
  355. package/app/components/contact/pill.js +0 -1
  356. package/app/components/contact/summary.js +0 -1
  357. package/app/components/customer/pill.js +0 -1
  358. package/app/components/customer/summary.js +0 -1
  359. package/app/components/driver/pill.js +0 -1
  360. package/app/components/driver/summary.js +0 -1
  361. package/app/components/fleet/pill.js +0 -1
  362. package/app/components/fleet/summary.js +0 -1
  363. package/app/components/order/pill.js +0 -1
  364. package/app/components/order/summary.js +0 -1
  365. package/app/components/place/pill.js +0 -1
  366. package/app/components/place/summary.js +0 -1
  367. package/app/components/select-option/contact.js +0 -1
  368. package/app/components/select-option/customer.js +0 -1
  369. package/app/components/select-option/driver.js +0 -1
  370. package/app/components/select-option/fleet.js +0 -1
  371. package/app/components/select-option/order.js +0 -1
  372. package/app/components/select-option/place.js +0 -1
  373. package/app/components/select-option/vehicle.js +0 -1
  374. package/app/components/select-option/vendor.js +0 -1
  375. package/app/components/vehicle/pill.js +0 -1
  376. package/app/components/vehicle/summary.js +0 -1
  377. package/app/components/vendor/pill.js +0 -1
  378. package/app/components/vendor/summary.js +0 -1
@@ -14,7 +14,7 @@ use Illuminate\Http\Request;
14
14
  * through ProfileAccountManager, and the exception-to-error-response
15
15
  * branches. Role assignment requires the full spatie permission boot, which
16
16
  * this fixture leaves out, so flows settle in the documented error branch
17
- * after the user record is persisted (see
17
+ * and the account created for the failed driver is removed again (see
18
18
  * DriverControllerExistingUserAdoptionTest for the full flow).
19
19
  */
20
20
  if (!function_exists('Fleetbase\Support\session')) {
@@ -157,6 +157,17 @@ function fleetopsInternalDriverCreateBoot(): SQLiteConnection
157
157
  return $connection;
158
158
  }
159
159
 
160
+ /**
161
+ * Keep a copy of every removed user row so a test can inspect the account
162
+ * that was created and then cleaned up when the driver failed.
163
+ */
164
+ function fleetopsInternalDriverCreateTrackRemovedUsers(SQLiteConnection $connection): void
165
+ {
166
+ $columns = $connection->getSchemaBuilder()->getColumnListing('users');
167
+ $connection->statement('create table removed_users as select * from users where 0');
168
+ $connection->statement('create trigger keep_removed_users after delete on users begin insert into removed_users (' . implode(', ', $columns) . ') values (' . implode(', ', array_map(fn ($column) => 'old.' . $column, $columns)) . '); end');
169
+ }
170
+
160
171
  function fleetopsInternalDriverCreateRequest(array $driver): Request
161
172
  {
162
173
  return Request::create('/int/v1/drivers', 'POST', ['driver' => $driver]);
@@ -164,6 +175,7 @@ function fleetopsInternalDriverCreateRequest(array $driver): Request
164
175
 
165
176
  test('create record provisions a driver-typed user before role assignment', function () {
166
177
  $connection = fleetopsInternalDriverCreateBoot();
178
+ fleetopsInternalDriverCreateTrackRemovedUsers($connection);
167
179
 
168
180
  $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
169
181
  'name' => 'New Driver',
@@ -172,17 +184,18 @@ test('create record provisions a driver-typed user before role assignment', func
172
184
  ]));
173
185
 
174
186
  // The user record is created and typed before role assignment fails in
175
- // the harness; the exception surfaces through the error response branch.
187
+ // the harness; the exception surfaces through the error response branch
188
+ // and the account is removed so no orphan login is left behind.
176
189
  expect($result)->toBeInstanceOf(JsonResponse::class)
177
190
  ->and($result->getData(true))->toHaveKey('error')
178
- ->and($connection->table('users')->count())->toBe(1)
179
- ->and($connection->table('users')->value('type'))->toBe('driver')
180
- ->and($connection->table('users')->value('email'))->toBe('newdriver@example.com')
181
- ->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0);
191
+ ->and($connection->table('users')->count())->toBe(0)
192
+ ->and($connection->table('removed_users')->value('type'))->toBe('driver')
193
+ ->and($connection->table('removed_users')->value('email'))->toBe('newdriver@example.com');
182
194
  });
183
195
 
184
196
  test('create record ignores a picked user account and resolves the login from the email', function () {
185
197
  $connection = fleetopsInternalDriverCreateBoot();
198
+ fleetopsInternalDriverCreateTrackRemovedUsers($connection);
186
199
  $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']);
187
200
 
188
201
  (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
@@ -193,13 +206,14 @@ test('create record ignores a picked user account and resolves the login from th
193
206
 
194
207
  // The login account is managed by the profile: a new driver account is
195
208
  // created for the email rather than the picked account being taken over
196
- expect($connection->table('users')->count())->toBe(2)
197
- ->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver')
209
+ expect($connection->table('users')->count())->toBe(1)
210
+ ->and($connection->table('removed_users')->where('email', 'someone@example.com')->value('type'))->toBe('driver')
198
211
  ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing');
199
212
  });
200
213
 
201
214
  test('create record applies photo avatars to the new driver account', function () {
202
215
  $connection = fleetopsInternalDriverCreateBoot();
216
+ fleetopsInternalDriverCreateTrackRemovedUsers($connection);
203
217
 
204
218
  $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
205
219
  'name' => 'Photo Driver',
@@ -208,7 +222,7 @@ test('create record applies photo avatars to the new driver account', function (
208
222
 
209
223
  // Avatar is set when the account is created, before the harness
210
224
  // role-assignment limitation.
211
- expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222')
225
+ expect($connection->table('removed_users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222')
212
226
  ->and($result)->toBeInstanceOf(JsonResponse::class)
213
227
  ->and($result->getData(true))->toHaveKey('error');
214
228
  });
@@ -327,6 +327,9 @@ function fleetopsOrchestrationOrder(string $publicId, ?string $dropoffUuid = 'dr
327
327
  $payload = new Payload();
328
328
  $payload->setRawAttributes(['uuid' => 'payload-' . $publicId], true);
329
329
  $payload->setRelation('dropoff', $dropoff);
330
+ // the run resolves route stops, so every stop relation is set up front
331
+ $payload->setRelation('pickup', null);
332
+ $payload->setRelation('waypoints', collect());
330
333
 
331
334
  $order = new FleetOpsOrchestrationCommitOrderFake();
332
335
  $order->setRawAttributes([
@@ -518,8 +521,17 @@ test('orchestration optimize routes hydrates missing vehicle relations before se
518
521
  'mode' => 'optimize_routes',
519
522
  ]));
520
523
 
524
+ $payload = $response->getData(true);
525
+
526
+ // greedy (the probe's configured engine) cannot sequence stops, so the
527
+ // built-in sequencer runs and the summary says so without raising a warning
521
528
  expect($response->getStatusCode())->toBe(200)
522
- ->and($response->getData(true)['summary'])->toBe(['engine' => 'route_sequence_fake'])
529
+ ->and($payload['summary'])->toMatchArray([
530
+ 'engine' => 'route_sequence_fake',
531
+ 'requested_engine' => 'greedy',
532
+ ])
533
+ ->and($payload['summary']['fallback_reason'])->toContain('does not sequence stops')
534
+ ->and($payload)->not->toHaveKey('warning')
523
535
  ->and($order->relationLoaded('vehicle'))->toBeTrue()
524
536
  ->and($order->vehicle->driver->public_id)->toBe('driver_one')
525
537
  ->and($controller->routeEngine->calls)->toHaveCount(1);
@@ -926,3 +938,161 @@ test('assign drivers skips orders without a prior assignment entry', function ()
926
938
  expect($matched->vehicle_assigned_uuid)->toBe('vehicle_one-uuid')
927
939
  ->and($unmatched->vehicle_assigned_uuid)->toBeNull();
928
940
  });
941
+
942
+ class FleetOpsSolverEngineFake implements OrchestrationEngineInterface
943
+ {
944
+ public array $calls = [];
945
+
946
+ public function allocate(Collection $orders, Collection $vehicles, array $options = []): array
947
+ {
948
+ $this->calls[] = compact('orders', 'vehicles', 'options');
949
+ $vehicle = $vehicles->first();
950
+
951
+ return [
952
+ 'assignments' => $orders->values()->map(fn ($order, $index) => [
953
+ 'order_id' => $order->public_id,
954
+ 'vehicle_id' => $vehicle->public_id,
955
+ 'driver_id' => null,
956
+ 'sequence' => $index + 1,
957
+ 'arrival' => 1778918400 + $index,
958
+ 'duration' => 600,
959
+ 'distance' => 4200,
960
+ 'route_distance' => 4200,
961
+ 'route_duration' => 600,
962
+ ])->all(),
963
+ 'unassigned' => [],
964
+ 'summary' => ['cost' => 600],
965
+ ];
966
+ }
967
+
968
+ public function getName(): string
969
+ {
970
+ return 'VROOM';
971
+ }
972
+
973
+ public function getIdentifier(): string
974
+ {
975
+ return 'vroom';
976
+ }
977
+ }
978
+
979
+ function fleetopsOrchestrationAssignedOrder(string $publicId, ?string $vehicleUuid, ?Vehicle $vehicle = null): FleetOpsOrchestrationCommitOrderFake
980
+ {
981
+ $order = fleetopsOrchestrationOrder($publicId);
982
+ $order->vehicle_assigned_uuid = $vehicleUuid;
983
+ if ($vehicle) {
984
+ $order->setRelation('vehicle', $vehicle);
985
+ }
986
+
987
+ return $order;
988
+ }
989
+
990
+ test('orchestration run falls back to greedy and reports why when the selected engine is unavailable', function () {
991
+ $registry = new OrchestrationEngineRegistry();
992
+ $registry->register(new GreedyOrchestrationEngine());
993
+ $registry->register(new FleetOpsThrowingOrchestrationEngineFake());
994
+
995
+ $vehicle = fleetopsOrchestrationVehicle('vehicle_one');
996
+ $vehicle->setRelation('driver', fleetopsOrchestrationDriver('driver_one'));
997
+
998
+ $controller = new FleetOpsOrchestrationCommitControllerProbe($registry);
999
+ $controller->engineSetting = 'throwing';
1000
+ $controller->orderQuery = new FleetOpsOrchestrationQueryFake(collect([fleetopsOrchestrationOrder('order_one'), fleetopsOrchestrationOrder('order_two')]));
1001
+ $controller->vehicleQuery = new FleetOpsOrchestrationQueryFake(collect([$vehicle]));
1002
+
1003
+ // an empty engine option defers to the organization's selected engine
1004
+ $response = $controller->run(Request::create('/orchestrator/run', 'POST', [
1005
+ 'mode' => 'assign_vehicles',
1006
+ 'options' => ['engine' => ''],
1007
+ ]));
1008
+
1009
+ $payload = $response->getData(true);
1010
+
1011
+ expect($response->getStatusCode())->toBe(200)
1012
+ ->and($payload['summary'])->toMatchArray([
1013
+ 'engine' => 'greedy',
1014
+ 'requested_engine' => 'throwing',
1015
+ 'fallback_reason' => 'orchestration unavailable',
1016
+ 'metrics' => 'estimated',
1017
+ 'distance' => 0,
1018
+ 'duration' => 0,
1019
+ ])
1020
+ ->and($payload['warning'])->toBe('The "greedy" engine was used instead of "throwing": orchestration unavailable')
1021
+ // the fallback still routes every order onto the one vehicle
1022
+ ->and(array_column($payload['assignments'], 'order_id'))->toBe(['order_one', 'order_two'])
1023
+ ->and($payload['assignments'][0])->toHaveKeys(['arrival', 'route_distance', 'route_duration']);
1024
+ });
1025
+
1026
+ test('orchestration optimize routes runs the selected solver once per vehicle', function () {
1027
+ $solver = new FleetOpsSolverEngineFake();
1028
+ $registry = new OrchestrationEngineRegistry();
1029
+ $registry->register(new GreedyOrchestrationEngine());
1030
+ $registry->register($solver);
1031
+
1032
+ $vehicle = fleetopsOrchestrationVehicle('vehicle_one');
1033
+ $vehicle->uuid = 'vehicle-one-uuid';
1034
+
1035
+ $controller = new FleetOpsOrchestrationCommitControllerProbe($registry);
1036
+ $controller->engineSetting = 'vroom';
1037
+ $controller->orderQuery = new FleetOpsOrchestrationQueryFake(collect([
1038
+ fleetopsOrchestrationAssignedOrder('order_one', 'vehicle-one-uuid', $vehicle),
1039
+ fleetopsOrchestrationAssignedOrder('order_two', 'vehicle-one-uuid', $vehicle),
1040
+ // a vehicle that can no longer be found, and an order with no vehicle
1041
+ fleetopsOrchestrationAssignedOrder('order_ghost', 'ghost-uuid'),
1042
+ fleetopsOrchestrationAssignedOrder('order_loose', null),
1043
+ ]));
1044
+ $controller->vehicleQuery = new FleetOpsOrchestrationQueryFake(collect([$vehicle]));
1045
+
1046
+ $response = $controller->run(Request::create('/orchestrator/run', 'POST', [
1047
+ 'mode' => 'optimize_routes',
1048
+ 'options' => ['allocation_strategy' => 'capacity_only'],
1049
+ ]));
1050
+
1051
+ $payload = $response->getData(true);
1052
+
1053
+ expect($response->getStatusCode())->toBe(200)
1054
+ ->and($solver->calls)->toHaveCount(1)
1055
+ ->and($solver->calls[0]['vehicles']->all())->toBe([$vehicle])
1056
+ ->and($solver->calls[0]['orders']->pluck('public_id')->all())->toBe(['order_one', 'order_two'])
1057
+ // sequencing always needs a routed solve, whatever the phase asked for
1058
+ ->and($solver->calls[0]['options']['allocation_strategy'])->toBe('route_aware')
1059
+ ->and(array_column($payload['assignments'], 'arrival'))->toBe([1778918400, 1778918401])
1060
+ ->and($payload['unassigned'])->toBe(['order_ghost', 'order_loose'])
1061
+ ->and($payload['summary'])->toBe([
1062
+ 'engine' => 'vroom',
1063
+ 'assigned' => 2,
1064
+ 'unassigned' => 2,
1065
+ 'distance' => 4200,
1066
+ 'duration' => 600,
1067
+ 'metrics' => 'engine',
1068
+ ])
1069
+ ->and($payload)->not->toHaveKey('warning');
1070
+ });
1071
+
1072
+ test('orchestration optimize routes falls back to the route sequencer with a warning', function (string $engineSetting, string $reason) {
1073
+ $registry = new OrchestrationEngineRegistry();
1074
+ $registry->register(new GreedyOrchestrationEngine());
1075
+ $registry->register(new FleetOpsThrowingOrchestrationEngineFake());
1076
+
1077
+ $vehicle = fleetopsOrchestrationVehicle('vehicle_one');
1078
+ $vehicle->uuid = 'vehicle-one-uuid';
1079
+ $vehicle->setRelation('driver', fleetopsOrchestrationDriver('driver_one'));
1080
+
1081
+ $controller = new FleetOpsOrchestrationCommitControllerProbe($registry);
1082
+ $controller->engineSetting = $engineSetting;
1083
+ $controller->orderQuery = new FleetOpsOrchestrationQueryFake(collect([fleetopsOrchestrationAssignedOrder('order_one', 'vehicle-one-uuid', $vehicle)]));
1084
+ $controller->vehicleQuery = new FleetOpsOrchestrationQueryFake(collect([$vehicle]));
1085
+
1086
+ $payload = $controller->run(Request::create('/orchestrator/run', 'POST', ['mode' => 'optimize_routes']))->getData(true);
1087
+
1088
+ expect($controller->routeEngine->calls)->toHaveCount(1)
1089
+ ->and($payload['summary'])->toMatchArray([
1090
+ 'engine' => 'route_sequence_fake',
1091
+ 'requested_engine' => $engineSetting,
1092
+ 'fallback_reason' => $reason,
1093
+ ])
1094
+ ->and($payload['warning'])->toContain($reason);
1095
+ })->with([
1096
+ 'unavailable solver' => ['throwing', 'orchestration unavailable'],
1097
+ 'unregistered' => ['missing', 'No orchestration engine is registered as "missing".'],
1098
+ ]);
@@ -1,9 +1,11 @@
1
1
  <?php
2
2
 
3
+ use Fleetbase\FleetOps\Exceptions\OrderEditLockedException;
3
4
  use Fleetbase\FleetOps\Flow\Activity;
4
5
  use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController;
5
6
  use Fleetbase\FleetOps\Http\Requests\BulkDispatchRequest;
6
7
  use Fleetbase\FleetOps\Http\Requests\CancelOrderRequest;
8
+ use Fleetbase\FleetOps\Http\Resources\v1\PublicOrderTracking;
7
9
  use Fleetbase\FleetOps\Models\Driver;
8
10
  use Fleetbase\FleetOps\Models\Entity;
9
11
  use Fleetbase\FleetOps\Models\Order;
@@ -13,6 +15,8 @@ use Fleetbase\FleetOps\Models\Place;
13
15
  use Fleetbase\FleetOps\Models\Proof;
14
16
  use Fleetbase\FleetOps\Models\TrackingStatus;
15
17
  use Fleetbase\FleetOps\Models\Waypoint;
18
+ use Fleetbase\FleetOps\Support\OrderEdit;
19
+ use Fleetbase\FleetOps\Support\OrderEditPolicy;
16
20
  use Fleetbase\FleetOps\Support\OrderTracker;
17
21
  use Fleetbase\Http\Requests\ExportRequest;
18
22
  use Fleetbase\Models\Type;
@@ -37,6 +41,43 @@ if (!class_exists('Fleetbase\Http\Requests\ExportRequest', false)) {
37
41
  eval('namespace Fleetbase\Http\Requests; class ExportRequest extends \Illuminate\Http\Request {}');
38
42
  }
39
43
 
44
+ /**
45
+ * Records the route edit guard and commit instead of reading and announcing through the database.
46
+ */
47
+ class FleetOpsInternalOrderEditFake extends OrderEdit
48
+ {
49
+ public array $guarded = [];
50
+ public array $lockWith = [];
51
+ public int $commits = 0;
52
+
53
+ public function __construct(public Order $order)
54
+ {
55
+ $this->policy = OrderEditPolicy::for($order);
56
+ }
57
+
58
+ public function requestedChanges(array $input, string $entitiesMode = self::ENTITIES_APPEND): array
59
+ {
60
+ return $input;
61
+ }
62
+
63
+ public function guard(array $changes): static
64
+ {
65
+ $this->guarded[] = $changes;
66
+ if ($this->lockWith) {
67
+ throw new OrderEditLockedException($this->lockWith);
68
+ }
69
+
70
+ return $this;
71
+ }
72
+
73
+ public function commit(array $extra = [], bool $notifyDriver = true): array
74
+ {
75
+ $this->commits++;
76
+
77
+ return [];
78
+ }
79
+ }
80
+
40
81
  class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController
41
82
  {
42
83
  public Collection $orders;
@@ -68,6 +109,7 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController
68
109
  public Collection $proofResults;
69
110
  public array $exportDownloads = [];
70
111
  public ?FleetOpsInternalOrderLifecycleOrderFake $trackingOrder = null;
112
+ public array $trackingLookups = [];
71
113
  public ?FleetOpsInternalOrderLifecycleOrderFake $scheduleOrder = null;
72
114
  public ?FleetOpsInternalOrderLifecycleDriverFake $scheduleDriver = null;
73
115
 
@@ -218,6 +260,7 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController
218
260
 
219
261
  protected function findOrderByTrackingNumber(string $trackingNumber): ?Order
220
262
  {
263
+ $this->trackingLookups[] = $trackingNumber;
221
264
  $this->trackingOrder?->setAttribute('tracking_lookup', $trackingNumber);
222
265
 
223
266
  return $this->trackingOrder;
@@ -254,6 +297,18 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController
254
297
  return $this->order;
255
298
  }
256
299
 
300
+ public ?FleetOpsInternalOrderEditFake $edit = null;
301
+
302
+ public array $editLockedWith = [];
303
+
304
+ protected function beginOrderEdit(Order $order): OrderEdit
305
+ {
306
+ $this->edit ??= new FleetOpsInternalOrderEditFake($order);
307
+ $this->edit->lockWith = $this->editLockedWith;
308
+
309
+ return $this->edit;
310
+ }
311
+
257
312
  protected function orderResponse(Order $order): array
258
313
  {
259
314
  return ['order' => $order];
@@ -413,6 +468,15 @@ class FleetOpsInternalOrderLifecycleOrderFake extends Order
413
468
  return $this;
414
469
  }
415
470
 
471
+ public ?string $cancelReasonForTest = null;
472
+
473
+ public function cancelWithReason(?string $reason = null)
474
+ {
475
+ $this->cancelReasonForTest = $reason;
476
+
477
+ return $this->cancel();
478
+ }
479
+
416
480
  public function dispatch(bool $save = true): Order
417
481
  {
418
482
  $this->dispatchedForTest = true;
@@ -908,10 +972,29 @@ test('internal order controller edits order routes and refreshes current destina
908
972
  ->and($payload->calls)->toContain(['setDropoff', ['name' => 'Dropoff'], ['save' => true]])
909
973
  ->and($payload->calls)->toContain(['setReturn', ['name' => 'Return'], ['save' => true]])
910
974
  ->and($payload->calls)->toContain(['updateWaypoints', [['name' => 'Waypoint']]])
911
- ->and($payload->calls)->toContain(['setCurrentWaypoint', $startingDestination, true])
975
+ // the current waypoint is reconciled by the committed edit, not reset to the first stop here
976
+ ->and(collect($payload->calls)->where(0, 'setCurrentWaypoint')->all())->toBe([])
977
+ ->and($controller->edit->guarded)->toBe([['pickup' => true, 'dropoff' => true, 'return' => true, 'waypoints' => true]])
978
+ ->and($controller->edit->commits)->toBe(1)
912
979
  ->and($order->loadedRelations)->toBe([['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints']]);
913
980
  });
914
981
 
982
+ test('internal order controller refuses route edits on finished orders with a 422', function () {
983
+ $payload = fleetopsInternalOrderLifecyclePayload(fleetopsInternalOrderLifecyclePlace('starting-place'));
984
+ $order = fleetopsInternalOrderLifecycleOrder('order-route', 'completed');
985
+ $order->setRelation('payload', $payload);
986
+ $controller = fleetopsInternalOrderLifecycleController([$order]);
987
+ $controller->editLockedWith = ['dropoff' => 'The order is completed and can no longer be changed.'];
988
+
989
+ $response = $controller->editOrderRoute('order-route', new Request(['dropoff' => ['name' => 'Dropoff']]));
990
+
991
+ // the harness response factory keeps only the message; the app's error macro adds `locked`
992
+ expect($response->getStatusCode())->toBe(422)
993
+ ->and($response->getData(true)['error'])->toBe('The order is completed and can no longer be changed.')
994
+ ->and($payload->calls)->toBe([])
995
+ ->and($controller->edit->commits)->toBe(0);
996
+ });
997
+
915
998
  test('internal order controller clears route endpoints and falls back to dropoff destination', function () {
916
999
  $fallbackDestination = fleetopsInternalOrderLifecyclePlace('fallback-place');
917
1000
  $payload = fleetopsInternalOrderLifecyclePayload(null, $fallbackDestination);
@@ -930,7 +1013,7 @@ test('internal order controller clears route endpoints and falls back to dropoff
930
1013
  ->and($payload->calls)->toContain(['removePlace', 'dropoff', ['save' => true]])
931
1014
  ->and($payload->calls)->toContain(['removePlace', 'return', ['save' => true]])
932
1015
  ->and($payload->calls)->toContain(['removeWaypoints'])
933
- ->and($payload->calls)->toContain(['setCurrentWaypoint', $fallbackDestination, true]);
1016
+ ->and($controller->edit->commits)->toBe(1);
934
1017
  });
935
1018
 
936
1019
  test('internal order controller reports missing order route edits', function () {
@@ -1054,7 +1137,15 @@ test('internal order controller cancels and dispatches individual orders', funct
1054
1137
  'order' => 'order-uuid',
1055
1138
  ])
1056
1139
  ->and($order->lookup_uuid)->toBe('order-uuid')
1057
- ->and($order->canceledForTest)->toBeTrue();
1140
+ ->and($order->canceledForTest)->toBeTrue()
1141
+ ->and($order->cancelReasonForTest)->toBeNull();
1142
+
1143
+ $order = fleetopsInternalOrderLifecycleOrder('order-uuid', 'created');
1144
+ $controller = fleetopsInternalOrderLifecycleController([$order]);
1145
+ $controller->cancel(fleetopsCancelOrderRequest(['order' => 'order-uuid', 'reason' => 'Customer asked to cancel']));
1146
+
1147
+ expect($order->canceledForTest)->toBeTrue()
1148
+ ->and($order->cancelReasonForTest)->toBe('Customer asked to cancel');
1058
1149
 
1059
1150
  $order = fleetopsInternalOrderLifecycleOrder('order-dispatch', 'created');
1060
1151
  $controller = fleetopsInternalOrderLifecycleController([$order]);
@@ -1549,24 +1640,29 @@ test('internal order controller export lookup and schedule endpoints use resolve
1549
1640
  ->and($export['selections'])->toBe(['order-one', 'order-two'])
1550
1641
  ->and($controller->exportDownloads[0][0])->toBe(['order-one', 'order-two']);
1551
1642
 
1552
- expect($controller->lookup(new Request())->getData(true))->toBe([
1553
- 'error' => 'No tracking number provided for lookup.',
1554
- ])
1555
- ->and($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe([
1556
- 'error' => 'No order found using tracking number provided.',
1557
- ]);
1643
+ // Missing, malformed and unknown tracking numbers all get the one answer,
1644
+ // and malformed input never reaches the query.
1645
+ $notFound = ['error' => OrderController::TRACKING_LOOKUP_ERROR];
1646
+ foreach ([[], ['tracking' => ''], ['tracking' => ' '], ['tracking' => ['TN-1']], ['tracking' => '../etc/passwd'], ['tracking' => "TN-1' OR 1=1"], ['tracking' => str_repeat('A', 101)], ['tracking' => '-TN']] as $input) {
1647
+ expect($controller->lookup(new Request($input))->getData(true))->toBe($notFound);
1648
+ }
1649
+ expect($controller->trackingLookups)->toBe([]);
1650
+
1651
+ expect($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe($notFound)
1652
+ ->and($controller->trackingLookups)->toBe(['TN-404']);
1558
1653
 
1559
1654
  $trackedOrder = fleetopsInternalOrderLifecycleOrder('tracked-order');
1560
1655
  $trackedOrder->trackerForTest = new FleetOpsInternalOrderLifecycleTrackerFake($trackedOrder);
1561
1656
  $controller->trackingOrder = $trackedOrder;
1562
1657
 
1563
- $lookup = $controller->lookup(new Request(['tracking' => 'TN-100']));
1658
+ $lookup = $controller->lookup(new Request(['tracking' => ' TN-100.a_b ']));
1564
1659
 
1565
- expect($lookup->resource)->toBe($trackedOrder)
1566
- ->and($trackedOrder->tracking_lookup)->toBe('TN-100')
1567
- ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'payload', 'trackingStatuses']])
1660
+ expect($lookup)->toBeInstanceOf(PublicOrderTracking::class)
1661
+ ->and($lookup->resource)->toBe($trackedOrder)
1662
+ ->and($trackedOrder->tracking_lookup)->toBe('TN-100.a_b')
1663
+ ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'trackingStatuses', 'payload.pickup', 'payload.dropoff', 'payload.waypoints', 'payload.entities']])
1568
1664
  ->and($trackedOrder->tracker_data)->toBe(['tracker' => 'info', 'options' => []])
1569
- ->and($trackedOrder->eta)->toBe(['eta' => [['stop' => 'dropoff']], 'options' => []]);
1665
+ ->and($trackedOrder->trackerForTest->etaOptions)->toBe([]);
1570
1666
 
1571
1667
  expect($controller->scheduleOrder(new Request(['order' => 'missing-order']))->getData(true))->toBe([
1572
1668
  'error' => 'No order found to schedule.',
@@ -157,18 +157,20 @@ test('bulk assign driver validation enforces uuid contracts', function () {
157
157
  expect($valid['driver'])->toBe('22222222-2222-4222-8222-222222222222');
158
158
  });
159
159
 
160
- test('assign driver to orders updates the assignment column', function () {
160
+ test('assign driver to orders updates the assignment column and tells the drivers it replaces', function () {
161
161
  $connection = fleetopsInternalOrderHelpersBoot();
162
162
  $connection->table('orders')->insert([
163
- ['uuid' => 'order-1', 'company_uuid' => 'company-1'],
164
- ['uuid' => 'order-2', 'company_uuid' => 'company-1'],
163
+ ['uuid' => 'order-1', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => null],
164
+ ['uuid' => 'order-2', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => null],
165
+ // already with another driver, who no longer exists: nothing to tell, nothing to fail
166
+ ['uuid' => 'order-3', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => 'driver-gone'],
165
167
  ]);
166
168
  $driver = new Driver();
167
169
  $driver->setRawAttributes(['uuid' => 'driver-1'], true);
168
170
 
169
- (new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2'], $driver);
171
+ (new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2', 'order-3'], $driver);
170
172
 
171
- expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(2);
173
+ expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(3);
172
174
  });
173
175
 
174
176
  test('dispatch domain event helper emits and returns the event', function () {
@@ -0,0 +1,135 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\PositionController;
4
+ use Fleetbase\Models\User;
5
+ use Fleetbase\Support\SocketCluster\ChannelAuthorizer;
6
+ use Fleetbase\Support\SocketCluster\ChannelDecision;
7
+ use Fleetbase\Support\SocketCluster\SocketPrincipal;
8
+ use Fleetbase\TestSupport\DispatchRecorder;
9
+ use Illuminate\Database\ConnectionResolver;
10
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
11
+ use Illuminate\Database\SQLiteConnection;
12
+ use Illuminate\Http\Request;
13
+
14
+ /**
15
+ * Covers the positions/replay channel check: with socket authentication on, a replay may only
16
+ * target a channel the requesting user could subscribe to; with it off the endpoint is unchanged.
17
+ */
18
+ class PositionReplayChannelAuthorizerFake
19
+ {
20
+ public array $checked = [];
21
+
22
+ public function __construct(public array $allowed = [])
23
+ {
24
+ }
25
+
26
+ public function authorize(?SocketPrincipal $principal, string $channel): ChannelDecision
27
+ {
28
+ $this->checked[] = [$principal, $channel];
29
+
30
+ return in_array($channel, $this->allowed, true) ? ChannelDecision::allowed('resolver') : ChannelDecision::denied('forbidden');
31
+ }
32
+ }
33
+
34
+ function positionReplayChannelBoot(bool $enabled): PositionReplayChannelAuthorizerFake
35
+ {
36
+ // Socket authentication needs both the switch and the signing key.
37
+ config([
38
+ 'broadcasting.connections.socketcluster.auth_enabled' => $enabled,
39
+ 'broadcasting.connections.socketcluster.auth_key' => $enabled ? 'fleetops-position-replay-test-key-0123456789' : null,
40
+ ]);
41
+
42
+ $pdo = new PDO('sqlite::memory:');
43
+ $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
44
+ $connection = new SQLiteConnection($pdo);
45
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
46
+ $resolver->setDefaultConnection('mysql');
47
+ EloquentModel::setConnectionResolver($resolver);
48
+
49
+ $schema = $connection->getSchemaBuilder();
50
+ foreach (['positions' => ['uuid', 'company_uuid', 'subject_uuid', 'coordinates', 'speed'], 'companies' => ['uuid', 'public_id']] as $table => $columns) {
51
+ $schema->create($table, function ($blueprint) use ($columns) {
52
+ $blueprint->increments('id');
53
+ foreach ($columns as $column) {
54
+ $blueprint->string($column)->nullable();
55
+ }
56
+ $blueprint->timestamps();
57
+ $blueprint->timestamp('deleted_at')->nullable();
58
+ });
59
+ }
60
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'public_id' => 'company_one']);
61
+ $connection->table('positions')->insert(['uuid' => 'pos-1', 'company_uuid' => 'company-1', 'subject_uuid' => 'vehicle-1', 'created_at' => '2026-10-06 08:00:00']);
62
+
63
+ session(['company' => 'company-1']);
64
+ DispatchRecorder::$dispatched = [];
65
+
66
+ $authorizer = new PositionReplayChannelAuthorizerFake(['vehicle.vehicle-1']);
67
+ app()->instance(ChannelAuthorizer::class, $authorizer);
68
+
69
+ return $authorizer;
70
+ }
71
+
72
+ function positionReplayChannelRequest(mixed $channel, ?User $user = null): Request
73
+ {
74
+ $request = Request::create('/int/v1/positions/replay', 'POST', ['channel_id' => $channel, 'position_ids' => ['pos-1']]);
75
+ $request->setUserResolver(fn () => $user);
76
+
77
+ return $request;
78
+ }
79
+
80
+ function positionReplayChannelUser(): User
81
+ {
82
+ $user = (new ReflectionClass(User::class))->newInstanceWithoutConstructor();
83
+ $user->setRawAttributes(['uuid' => 'user-1', 'public_id' => 'user_one', 'company_uuid' => 'company-1', 'type' => 'user'], true);
84
+
85
+ return $user;
86
+ }
87
+
88
+ afterEach(function () {
89
+ config(['broadcasting.connections.socketcluster.auth_enabled' => false, 'broadcasting.connections.socketcluster.auth_key' => null]);
90
+ });
91
+
92
+ test('a replay to a channel the user may subscribe to is started', function () {
93
+ $authorizer = positionReplayChannelBoot(true);
94
+
95
+ $response = (new PositionController())->replay(positionReplayChannelRequest('vehicle.vehicle-1', positionReplayChannelUser()));
96
+
97
+ [$principal, $channel] = $authorizer->checked[0];
98
+ expect($response->getData(true)['status'])->toBe('ok')
99
+ ->and(DispatchRecorder::$dispatched)->toHaveCount(1)
100
+ ->and($channel)->toBe('vehicle.vehicle-1')
101
+ ->and($principal->kind)->toBe('user')
102
+ ->and($principal->sub)->toBe('user-1')
103
+ ->and($principal->cid)->toBe('company-1')
104
+ ->and($principal->adm)->toBeFalse();
105
+ });
106
+
107
+ test('a replay to any other channel is refused before anything is queued', function () {
108
+ $authorizer = positionReplayChannelBoot(true);
109
+ $controller = new PositionController();
110
+ $user = positionReplayChannelUser();
111
+
112
+ foreach ([
113
+ positionReplayChannelRequest('chat_channel.someone-elses', $user),
114
+ positionReplayChannelRequest('vehicle.vehicle-1', null),
115
+ positionReplayChannelRequest('vehicle. vehicle-1', $user),
116
+ positionReplayChannelRequest(str_repeat('x', 256), $user),
117
+ positionReplayChannelRequest(['vehicle.vehicle-1'], $user),
118
+ ] as $request) {
119
+ $response = $controller->replay($request);
120
+ expect($response->getStatusCode())->toBe(403)
121
+ ->and($response->getData(true)['error'])->toContain('not allowed');
122
+ }
123
+
124
+ expect(DispatchRecorder::$dispatched)->toBe([])
125
+ ->and(array_column($authorizer->checked, 1))->toBe(['chat_channel.someone-elses']);
126
+ });
127
+
128
+ test('without socket authentication the replay endpoint does not check the channel', function () {
129
+ $authorizer = positionReplayChannelBoot(false);
130
+
131
+ $response = (new PositionController())->replay(positionReplayChannelRequest('anything.at-all'));
132
+
133
+ expect($response->getData(true)['status'])->toBe('ok')
134
+ ->and($authorizer->checked)->toBe([]);
135
+ });