@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
@@ -0,0 +1,363 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Order;
5
+ use Fleetbase\FleetOps\Models\Place;
6
+ use Fleetbase\FleetOps\Models\Vehicle;
7
+ use Fleetbase\FleetOps\Models\Waypoint;
8
+ use Fleetbase\FleetOps\Support\TrackingScope;
9
+ use Fleetbase\FleetOps\Support\TrackingUpdate;
10
+ use Fleetbase\FleetOps\Tracking\TrackingStop;
11
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
12
+ use Fleetbase\Models\User;
13
+ use Illuminate\Database\ConnectionResolver;
14
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
15
+ use Illuminate\Database\SQLiteConnection;
16
+ use Illuminate\Support\Carbon;
17
+
18
+ /**
19
+ * Covers the per-customer public tracking payload: only the viewing customer's stops, ETAs and
20
+ * timelines; whole-route counts; the driver's first name, vehicle label and online flag with no
21
+ * photo or phone; and the rounded vehicle position only while en route to a stop of theirs.
22
+ */
23
+ class TrackingUpdateExplodingDriver extends Driver
24
+ {
25
+ public function getLocationAttribute()
26
+ {
27
+ throw new RuntimeException('unreadable location');
28
+ }
29
+ }
30
+
31
+ const TRACKING_UPDATE_CONTACT = 'Fleetbase\\FleetOps\\Models\\Contact';
32
+
33
+ function trackingUpdateModel(string $class, array $attributes): EloquentModel
34
+ {
35
+ $model = (new ReflectionClass($class))->newInstanceWithoutConstructor();
36
+ $model->setRawAttributes($attributes, true);
37
+
38
+ return $model;
39
+ }
40
+
41
+ function trackingUpdatePlace(string $name, string $address): Place
42
+ {
43
+ return trackingUpdateModel(Place::class, ['uuid' => 'place-' . md5($name), 'public_id' => 'place_' . substr(md5($name), 0, 7), 'name' => $name, 'street1' => $address, 'location' => new Point(1.4, 103.9)]);
44
+ }
45
+
46
+ function trackingUpdateWaypoint(string $publicId, ?string $customerUuid): Waypoint
47
+ {
48
+ return trackingUpdateModel(Waypoint::class, [
49
+ 'uuid' => 'uuid-' . $publicId,
50
+ 'public_id' => $publicId,
51
+ 'customer_uuid' => $customerUuid,
52
+ 'customer_type' => $customerUuid ? TRACKING_UPDATE_CONTACT : null,
53
+ ]);
54
+ }
55
+
56
+ function trackingUpdateStop(string $type, int $sequence, bool $completed, ?Waypoint $waypoint, string $trackingNumber, string $placeName): TrackingStop
57
+ {
58
+ return new TrackingStop(
59
+ uuid: 'place-' . $sequence,
60
+ publicId: 'place_' . $sequence,
61
+ type: $type,
62
+ status: $completed ? 'completed' : 'enroute',
63
+ place: trackingUpdatePlace($placeName, $placeName . ' Street'),
64
+ waypoint: $waypoint,
65
+ completed: $completed,
66
+ sequence: $sequence,
67
+ trackingNumberUuid: $trackingNumber,
68
+ );
69
+ }
70
+
71
+ /**
72
+ * A started order for contact-1111 sharing its route with contact-2222:
73
+ * pickup (theirs, done), Jane's stop, their own drop, Jane's second stop.
74
+ */
75
+ function trackingUpdateStops(bool $janeDone = false): array
76
+ {
77
+ return [
78
+ trackingUpdateStop('pickup', 1, true, null, 'tn-pickup', 'Acme Warehouse'),
79
+ trackingUpdateStop('waypoint', 2, $janeDone, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222'), 'tn-jane1', 'Jane Home'),
80
+ trackingUpdateStop('waypoint', 3, false, trackingUpdateWaypoint('waypoint_own', null), 'tn-own', 'Own Office'),
81
+ trackingUpdateStop('waypoint', 4, $janeDone, trackingUpdateWaypoint('waypoint_jane2', 'contact-2222'), 'tn-jane2', 'Jane Gym'),
82
+ ];
83
+ }
84
+
85
+ function trackingUpdateDriver(array $attributes = [], string $class = Driver::class): Driver
86
+ {
87
+ $driver = trackingUpdateModel($class, array_merge([
88
+ 'uuid' => 'driver-1111',
89
+ 'public_id' => 'driver_1111',
90
+ 'online' => true,
91
+ 'heading' => 87.6,
92
+ 'location' => new Point(1.352083, 103.819836),
93
+ 'phone' => '+6591234567',
94
+ 'updated_at' => Carbon::parse('2026-10-06 10:00:30'),
95
+ ], $attributes));
96
+ $driver->setRelation('user', trackingUpdateModel(User::class, ['name' => 'Ana Lopez', 'phone' => '+6591234567', 'avatar_url' => 'https://cdn.example/ana.jpg']));
97
+ $driver->setRelation('vehicle', null);
98
+
99
+ return $driver;
100
+ }
101
+
102
+ function trackingUpdateVehicle(array $attributes = []): Vehicle
103
+ {
104
+ return trackingUpdateModel(Vehicle::class, array_merge([
105
+ 'uuid' => 'vehicle-1111',
106
+ 'name' => 'Van 7',
107
+ 'plate_number' => 'SGX1234A',
108
+ 'heading' => 10,
109
+ 'location' => new Point(1.30001, 103.80001),
110
+ 'updated_at' => Carbon::parse('2026-10-06 10:00:00'),
111
+ ], $attributes));
112
+ }
113
+
114
+ function trackingUpdateOrder(array $attributes = [], ?Driver $driver = null, ?Vehicle $vehicle = null): Order
115
+ {
116
+ $order = trackingUpdateModel(Order::class, array_merge([
117
+ 'uuid' => 'order-1111',
118
+ 'company_uuid' => 'company-1111',
119
+ 'customer_type' => TRACKING_UPDATE_CONTACT,
120
+ 'customer_uuid' => 'contact-1111',
121
+ 'tracking_number_uuid' => 'tn-order',
122
+ 'status' => 'started',
123
+ 'started' => true,
124
+ ], $attributes));
125
+ $order->setRelation('driverAssigned', $driver);
126
+ $order->setRelation('vehicleAssigned', $vehicle);
127
+
128
+ return $order;
129
+ }
130
+
131
+ function trackingUpdateTimelines(): array
132
+ {
133
+ $entry = fn (string $status, string $code) => ['status' => $status, 'code' => $code, 'complete' => false, 'created_at' => '2026-10-06T09:00:00.000000Z'];
134
+
135
+ return [
136
+ 'tn-order' => [$entry('Order started', 'started')],
137
+ 'tn-pickup' => [$entry('Picked up', 'picked_up')],
138
+ 'tn-own' => [$entry('Driver en route', 'enroute')],
139
+ 'tn-jane1' => [$entry('Arriving at Jane Home', 'enroute')],
140
+ 'tn-jane2' => [$entry('Queued for Jane Gym', 'pending')],
141
+ ];
142
+ }
143
+
144
+ function trackingUpdateEtas(): array
145
+ {
146
+ return [
147
+ 2 => ['seconds' => 300, 'at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1200],
148
+ 3 => ['seconds' => 600, 'at' => '2026-10-06T10:10:00.000000Z', 'distance_m' => 2500],
149
+ 4 => ['seconds' => 900, 'at' => '2026-10-06T10:15:00.000000Z', 'distance_m' => 4100],
150
+ ];
151
+ }
152
+
153
+ beforeEach(function () {
154
+ Carbon::setTestNow('2026-10-06 10:01:00');
155
+ });
156
+
157
+ afterEach(function () {
158
+ Carbon::setTestNow();
159
+ });
160
+
161
+ test('the order customer sees only their own stops with whole-route counts', function () {
162
+ $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle());
163
+ $scope = TrackingScope::forOrder($order);
164
+ $update = new TrackingUpdate($order, $scope, trackingUpdateStops(), trackingUpdateEtas(), trackingUpdateTimelines());
165
+
166
+ $payload = $update->toArray(TrackingUpdate::REASON_STOP);
167
+
168
+ expect($payload['event'])->toBe('tracking.updated')
169
+ ->and($payload['reason'])->toBe('stop')
170
+ ->and($payload['sent_at'])->toBe('2026-10-06T10:01:00.000000Z')
171
+ ->and($payload['data']['order'])->toBe([
172
+ 'status' => 'started',
173
+ 'en_route' => true,
174
+ 'completed' => false,
175
+ 'canceled' => false,
176
+ 'timeline' => trackingUpdateTimelines()['tn-order'],
177
+ ])
178
+ ->and($payload['data']['route'])->toBe(['total_stops' => 4, 'completed_stops' => 1, 'stops_before' => 1])
179
+ ->and($payload['data']['stops'])->toBe([
180
+ ['id' => null, 'type' => 'pickup', 'sequence' => 1, 'status' => 'completed', 'complete' => true, 'eta' => null, 'timeline' => trackingUpdateTimelines()['tn-pickup']],
181
+ ['id' => 'waypoint_own', 'type' => 'waypoint', 'sequence' => 3, 'status' => 'enroute', 'complete' => false, 'eta' => trackingUpdateEtas()[3], 'timeline' => trackingUpdateTimelines()['tn-own']],
182
+ ])
183
+ ->and($payload['data']['driver'])->toBe(['name' => 'Ana', 'vehicle' => 'Van 7', 'online' => true])
184
+ ->and($payload['data']['location'])->toBe(['latitude' => 1.3521, 'longitude' => 103.8198, 'heading' => 88, 'seen_at' => '2026-10-06T10:00:30.000000Z'])
185
+ ->and($update->timelineTrackingNumbers())->toBe(['tn-pickup', 'tn-own', 'tn-order']);
186
+
187
+ // Nothing of the other customer's stops, places or people, and nothing personal about the driver.
188
+ $json = json_encode($payload);
189
+ foreach (['waypoint_jane1', 'waypoint_jane2', 'Jane', 'contact-2222', 'Acme Warehouse', 'Own Office', 'Street', 'Lopez', '+6591234567', 'ana.jpg', 'SGX1234A', 'tn-'] as $forbidden) {
190
+ expect($json)->not->toContain($forbidden);
191
+ }
192
+ });
193
+
194
+ test('a waypoint customer sees only their stops and never the order timeline', function () {
195
+ $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle());
196
+ $scope = TrackingScope::forStop($order, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222'));
197
+ $update = new TrackingUpdate($order, $scope, trackingUpdateStops(), trackingUpdateEtas(), trackingUpdateTimelines());
198
+
199
+ $payload = $update->toArray();
200
+
201
+ expect($payload['reason'])->toBe('status')
202
+ ->and(array_column($payload['data']['stops'], 'id'))->toBe(['waypoint_jane1', 'waypoint_jane2'])
203
+ ->and(array_column($payload['data']['stops'], 'eta'))->toBe([trackingUpdateEtas()[2], trackingUpdateEtas()[4]])
204
+ ->and($payload['data']['stops'][0]['timeline'])->toBe(trackingUpdateTimelines()['tn-jane1'])
205
+ ->and($payload['data']['order']['timeline'])->toBe([])
206
+ ->and($payload['data']['route'])->toBe(['total_stops' => 4, 'completed_stops' => 1, 'stops_before' => 0])
207
+ ->and($update->timelineTrackingNumbers())->toBe(['tn-jane1', 'tn-jane2'])
208
+ ->and($payload['data']['location'])->not->toBeNull();
209
+
210
+ $json = json_encode($payload);
211
+ foreach (['waypoint_own', 'contact-1111', 'Order started', 'Picked up', 'Driver en route', 'Acme', 'Own Office', 'Lopez', '+6591234567', 'ana.jpg'] as $forbidden) {
212
+ expect($json)->not->toContain($forbidden);
213
+ }
214
+ });
215
+
216
+ test('the driver and position are shown only while en route to a stop of the viewer', function () {
217
+ $driver = trackingUpdateDriver();
218
+ $vehicle = trackingUpdateVehicle();
219
+
220
+ // Not started yet: no driver and no position.
221
+ $order = trackingUpdateOrder(['status' => 'dispatched', 'started' => false], $driver, $vehicle);
222
+ $update = new TrackingUpdate($order, TrackingScope::forOrder($order), trackingUpdateStops());
223
+ expect($update->isEnRoute())->toBeFalse()
224
+ ->and($update->driver())->toBeNull()
225
+ ->and($update->location())->toBeNull()
226
+ ->and($update->locationPayload())->toBeNull();
227
+
228
+ // Started by status or timestamp alone.
229
+ $byStatus = trackingUpdateOrder(['status' => 'started', 'started' => false], $driver, $vehicle);
230
+ $byTime = trackingUpdateOrder(['status' => 'enroute', 'started' => false, 'started_at' => Carbon::parse('2026-10-06 09:00:00')], $driver, $vehicle);
231
+ expect((new TrackingUpdate($byStatus, TrackingScope::forOrder($byStatus), trackingUpdateStops()))->isEnRoute())->toBeTrue()
232
+ ->and((new TrackingUpdate($byTime, TrackingScope::forOrder($byTime), trackingUpdateStops()))->isEnRoute())->toBeTrue();
233
+
234
+ // Delivered or canceled: hidden, and the order state says so.
235
+ foreach (['completed', 'canceled'] as $status) {
236
+ $done = trackingUpdateOrder(['status' => $status], $driver, $vehicle);
237
+ $payload = (new TrackingUpdate($done, TrackingScope::forOrder($done), trackingUpdateStops()))->toArray();
238
+ expect($payload['data']['driver'])->toBeNull()
239
+ ->and($payload['data']['location'])->toBeNull()
240
+ ->and($payload['data']['order'][$status])->toBeTrue()
241
+ ->and($payload['data']['order']['en_route'])->toBeFalse();
242
+ }
243
+
244
+ // En route, but every stop of this customer is done: hidden for them, shown to the other.
245
+ $order = trackingUpdateOrder([], $driver, $vehicle);
246
+ $jane = new TrackingUpdate($order, TrackingScope::forStop($order, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222')), trackingUpdateStops(true));
247
+ $own = new TrackingUpdate($order, TrackingScope::forOrder($order), trackingUpdateStops(true));
248
+ expect($jane->customerHasIncompleteStop())->toBeFalse()
249
+ ->and($jane->locationPayload())->toBeNull()
250
+ ->and($jane->route()['stops_before'])->toBe(0)
251
+ ->and($own->locationPayload()['event'])->toBe('tracking.location')
252
+ ->and($own->locationPayload()['reason'])->toBe('location')
253
+ ->and($own->locationPayload()['data'])->toBe([
254
+ 'driver' => ['name' => 'Ana', 'vehicle' => 'Van 7', 'online' => true],
255
+ 'location' => ['latitude' => 1.3521, 'longitude' => 103.8198, 'heading' => 88, 'seen_at' => '2026-10-06T10:00:30.000000Z'],
256
+ ]);
257
+
258
+ // A customer with no stops on the route sees no vehicle.
259
+ $stranger = new TrackingUpdate($order, new TrackingScope('order-1111', TRACKING_UPDATE_CONTACT, 'contact-9999'), trackingUpdateStops());
260
+ expect($stranger->ownStops())->toHaveCount(0)
261
+ ->and($stranger->location())->toBeNull()
262
+ ->and($stranger->toArray()['data']['stops'])->toBe([]);
263
+ });
264
+
265
+ test('the position comes from the most recently seen of driver and vehicle', function () {
266
+ $stops = trackingUpdateStops();
267
+
268
+ // The vehicle reported last.
269
+ $order = trackingUpdateOrder([], trackingUpdateDriver(['updated_at' => Carbon::parse('2026-10-06 09:00:00')]), trackingUpdateVehicle(['updated_at' => Carbon::parse('2026-10-06 10:00:50')]));
270
+ expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location())
271
+ ->toBe(['latitude' => 1.3, 'longitude' => 103.8, 'heading' => 10, 'seen_at' => '2026-10-06T10:00:50.000000Z']);
272
+
273
+ // The driver's position is unusable (null island), so the vehicle's is used.
274
+ $order = trackingUpdateOrder([], trackingUpdateDriver(['location' => new Point(0, 0)]), trackingUpdateVehicle(['heading' => 'n/a', 'updated_at' => null]));
275
+ expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location())
276
+ ->toBe(['latitude' => 1.3, 'longitude' => 103.8, 'heading' => null, 'seen_at' => null]);
277
+
278
+ // Unreadable or missing positions everywhere: no position, but the driver is still named.
279
+ $order = trackingUpdateOrder([], trackingUpdateDriver([], TrackingUpdateExplodingDriver::class), trackingUpdateVehicle(['location' => null]));
280
+ $update = new TrackingUpdate($order, TrackingScope::forOrder($order), $stops);
281
+ expect($update->location())->toBeNull()
282
+ ->and($update->locationPayload())->toBeNull()
283
+ ->and($update->driver()['name'])->toBe('Ana');
284
+
285
+ // Without a driver, the assigned vehicle alone gives the position.
286
+ $order = trackingUpdateOrder([], null, trackingUpdateVehicle());
287
+ $update = new TrackingUpdate($order, TrackingScope::forOrder($order), $stops);
288
+ expect($update->driver())->toBeNull()
289
+ ->and($update->location()['latitude'])->toBe(1.3);
290
+
291
+ // Without an assigned vehicle the driver's current vehicle is labelled; an unnamed one has no label.
292
+ $driver = trackingUpdateDriver();
293
+ $driver->setRelation('vehicle', trackingUpdateVehicle(['name' => 'Truck 9']));
294
+ $order = trackingUpdateOrder([], $driver, null);
295
+ expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->driver()['vehicle'])->toBe('Truck 9');
296
+
297
+ $driver->setRelation('vehicle', trackingUpdateVehicle(['name' => null]));
298
+ expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->driver()['vehicle'])->toBeNull();
299
+
300
+ // Nobody assigned at all.
301
+ $order = trackingUpdateOrder([], null, null);
302
+ expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location())->toBeNull();
303
+ });
304
+
305
+ test('order state, names and etas tolerate missing values', function () {
306
+ $order = trackingUpdateOrder(['status' => null, 'started' => false], null, null);
307
+ $update = new TrackingUpdate($order, TrackingScope::forOrder($order), ['not a stop', ...trackingUpdateStops()]);
308
+
309
+ expect($update->toArray()['data']['order']['status'])->toBeNull()
310
+ ->and($update->route()['total_stops'])->toBe(4)
311
+ ->and($update->isTerminal())->toBeFalse()
312
+ ->and(TrackingUpdate::firstName(null))->toBeNull()
313
+ ->and(TrackingUpdate::firstName(' '))->toBeNull()
314
+ ->and(TrackingUpdate::firstName('Cher'))->toBe('Cher')
315
+ ->and(TrackingUpdate::firstName(' Ana Maria Lopez '))->toBe('Ana');
316
+
317
+ expect(TrackingUpdate::etasFromTracker([]))->toBe([])
318
+ ->and(TrackingUpdate::etasFromTracker(['route' => ['legs' => [
319
+ ['stop' => null, 'eta_seconds' => 10],
320
+ ['stop' => ['sequence' => 2], 'eta_seconds' => 299.6, 'eta_at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1199.7],
321
+ ['stop' => ['sequence' => '3'], 'eta_seconds' => null, 'eta_at' => null],
322
+ ]]]))->toBe([
323
+ 2 => ['seconds' => 300, 'at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1200],
324
+ 3 => ['seconds' => null, 'at' => null, 'distance_m' => null],
325
+ ]);
326
+ });
327
+
328
+ test('timelines are loaded for the viewer\'s tracking numbers only, without free-text details', function () {
329
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
330
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
331
+ $resolver->setDefaultConnection('mysql');
332
+ EloquentModel::setConnectionResolver($resolver);
333
+ $connection->getSchemaBuilder()->create('tracking_statuses', function ($blueprint) {
334
+ $blueprint->increments('id');
335
+ foreach (['uuid', 'public_id', 'tracking_number_uuid', 'status', 'details', 'code', 'complete'] as $column) {
336
+ $blueprint->string($column)->nullable();
337
+ }
338
+ $blueprint->timestamp('created_at')->nullable();
339
+ $blueprint->timestamp('updated_at')->nullable();
340
+ $blueprint->timestamp('deleted_at')->nullable();
341
+ });
342
+ $connection->table('tracking_statuses')->insert([
343
+ ['uuid' => 's1', 'tracking_number_uuid' => 'tn-order', 'status' => 'Order created', 'details' => 'Created by Jane', 'code' => 'CREATED', 'complete' => '0', 'created_at' => '2026-10-06 08:00:00'],
344
+ ['uuid' => 's2', 'tracking_number_uuid' => 'tn-order', 'status' => 'Order started', 'details' => 'Driver left Acme', 'code' => 'started', 'complete' => '0', 'created_at' => '2026-10-06 09:00:00'],
345
+ ['uuid' => 's3', 'tracking_number_uuid' => 'tn-own', 'status' => 'Driver en route', 'details' => null, 'code' => null, 'complete' => '0', 'created_at' => null],
346
+ ['uuid' => 's4', 'tracking_number_uuid' => 'tn-jane1', 'status' => 'Arriving at Jane Home', 'details' => null, 'code' => 'enroute', 'complete' => '0', 'created_at' => '2026-10-06 09:30:00'],
347
+ ]);
348
+
349
+ $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle());
350
+ $tracker = ['route' => ['legs' => [['stop' => ['sequence' => 3], 'eta_seconds' => 600, 'eta_at' => '2026-10-06T10:11:00.000000Z', 'distance_m' => 2500]]]];
351
+ $update = TrackingUpdate::for($order, TrackingScope::forOrder($order), trackingUpdateStops(), $tracker);
352
+ $payload = $update->toArray();
353
+
354
+ expect($payload['data']['order']['timeline'])->toBe([
355
+ ['status' => 'Order created', 'code' => 'created', 'complete' => false, 'created_at' => '2026-10-06T08:00:00.000000Z'],
356
+ ['status' => 'Order started', 'code' => 'started', 'complete' => false, 'created_at' => '2026-10-06T09:00:00.000000Z'],
357
+ ])
358
+ ->and($payload['data']['stops'][1]['timeline'])->toBe([['status' => 'Driver en route', 'code' => null, 'complete' => false, 'created_at' => null]])
359
+ ->and($payload['data']['stops'][1]['eta'])->toBe(['seconds' => 600, 'at' => '2026-10-06T10:11:00.000000Z', 'distance_m' => 2500])
360
+ ->and(json_encode($payload))->not->toContain('Jane')
361
+ ->and(json_encode($payload))->not->toContain('Acme')
362
+ ->and(TrackingUpdate::loadTimelines([]))->toBe([]);
363
+ });
@@ -70,6 +70,15 @@ function fleetopsUtilsAdditionalPolygon(): Polygon
70
70
  ]);
71
71
  }
72
72
 
73
+ test('stored file url signing passes through values that are not absolute urls', function () {
74
+ // Absolute URLs into the media bucket are re-signed by core-api File::signStoredUrl (covered there);
75
+ // everything else, including avatar option keys and empty values, must come back untouched.
76
+ expect(Utils::signStoredFileUrl(null))->toBeNull()
77
+ ->and(Utils::signStoredFileUrl(''))->toBe('')
78
+ ->and(Utils::signStoredFileUrl('mini_bus'))->toBe('mini_bus')
79
+ ->and(Utils::signStoredFileUrl('custom-avatars/vehicles/c/van.png'))->toBe('custom-avatars/vehicles/c/van.png');
80
+ });
81
+
73
82
  test('company transaction currency prefers organization currency and falls back to usd', function () {
74
83
  $company = new Company();
75
84
  $company->setRawAttributes([
@@ -380,3 +380,28 @@ test('feature geometries strict lookups matrices and centroids cover edge seams'
380
380
  expect(Utils::getCentroidFromGeosPolygon($brickPolygon)->x())->toBe(103.81)
381
381
  ->and(Utils::getCentroidFromGeosMultiPolygon(Brick\Geo\MultiPolygon::of($brickPolygon))->y())->toBe(1.31);
382
382
  });
383
+
384
+ test('coordsToCircle keeps every vertex on the requested radius', function () {
385
+ $haversine = function (float $lat1, float $lng1, float $lat2, float $lng2): float {
386
+ $dLat = deg2rad($lat2 - $lat1);
387
+ $dLng = deg2rad($lng2 - $lng1);
388
+ $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
389
+
390
+ return 6378137 * 2 * atan2(sqrt($a), sqrt(1 - $a));
391
+ };
392
+
393
+ // 15 km around Seattle, the case from fleetbase/fleetops#360
394
+ foreach (Utils::coordsToCircle(47.6062, -122.3321, 15000) as [$lat, $lng]) {
395
+ expect($haversine(47.6062, -122.3321, $lat, $lng))->toEqualWithDelta(15000, 1);
396
+ }
397
+ });
398
+
399
+ test('coordsToCircle wraps longitudes across the antimeridian', function () {
400
+ $longitudes = array_column(Utils::coordsToCircle(0, -179.99, 5000), 1);
401
+
402
+ expect(min($longitudes))->toBeGreaterThanOrEqual(-180)
403
+ ->and(max($longitudes))->toBeLessThanOrEqual(180)
404
+ // the eastern half of the ring crosses to the far side of the antimeridian
405
+ ->and(max($longitudes))->toBeGreaterThan(179.9)
406
+ ->and(min($longitudes))->toBeLessThan(-179.99);
407
+ });
@@ -350,3 +350,49 @@ test('requested relations are empty when the resource cannot load any', function
350
350
  ->and(fleetopsVehicleResourceRequestedRelations((object) ['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
351
351
  ->and(fleetopsVehicleResourceRequestedRelations(fleetopsVehicleResourceFixture(), ['with' => ['driver']]))->toBe([]);
352
352
  });
353
+
354
+ class FleetOpsVehicleResourceRelationModel extends Illuminate\Database\Eloquent\Model
355
+ {
356
+ public static int $saved = 0;
357
+
358
+ protected $table = 'vehicles';
359
+
360
+ public function driver(): Illuminate\Database\Eloquent\Relations\BelongsTo
361
+ {
362
+ return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'driver_id');
363
+ }
364
+
365
+ public function parentVehicle(): Illuminate\Database\Eloquent\Relations\BelongsTo
366
+ {
367
+ return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'parent_id');
368
+ }
369
+
370
+ public function untypedDriver()
371
+ {
372
+ return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'driver_id');
373
+ }
374
+
375
+ public function saveInstance(): self
376
+ {
377
+ static::$saved++;
378
+
379
+ return $this;
380
+ }
381
+ }
382
+
383
+ test('requested relations drop names that are methods but not declared relations', function () {
384
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'))]);
385
+ $resolver->setDefaultConnection('default');
386
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
387
+ FleetOpsVehicleResourceRelationModel::$saved = 0;
388
+
389
+ $vehicle = new FleetOpsVehicleResourceRelationModel();
390
+
391
+ // `with=saveInstance` used to reach loadMissing(), which calls the method on a
392
+ // blank model. Only methods that declare a Relation return type survive, named
393
+ // as the model declares them, and nested segments are checked the same way.
394
+ expect(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['driver', 'saveInstance', 'untypedDriver', 'delete', 'driver.saveInstance', 'parent_vehicle', 'payload']]))
395
+ ->toBe(['driver', 'parentVehicle'])
396
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => 'saveInstance']))->toBe([])
397
+ ->and(FleetOpsVehicleResourceRelationModel::$saved)->toBe(0);
398
+ })->skip(fn () => !class_exists(Fleetbase\Support\RequestRelations::class), 'needs fleetbase/core-api with Fleetbase\Support\RequestRelations');
@@ -112,7 +112,40 @@ test('vroom maps shipment delivery steps back to fleetops assignments', function
112
112
  'duration' => 900,
113
113
  'distance' => 4200,
114
114
  ]);
115
- expect($result['summary'])->toBe(['routes' => 1]);
115
+ expect($result['summary'])->toBe(['engine' => 'vroom', 'routes' => 1]);
116
+ });
117
+
118
+ test('vroom carries route totals onto assignments and anchors relative arrivals to the run start', function () {
119
+ $engine = new VroomOrchestrationEngine();
120
+ $method = new ReflectionMethod($engine, 'mapVroomResponse');
121
+
122
+ $method->setAccessible(true);
123
+
124
+ $before = now()->timestamp;
125
+ $result = $method->invoke($engine, [
126
+ 'routes' => [
127
+ [
128
+ 'description' => json_encode(['vehicle_id' => 'vehicle_one', 'driver_id' => 'driver_one']),
129
+ 'distance' => 9100,
130
+ 'duration' => 1500,
131
+ 'steps' => [
132
+ ['type' => 'start', 'arrival' => 0],
133
+ ['type' => 'job', 'id' => 1001, 'arrival' => 600, 'duration' => 600, 'distance' => 5000],
134
+ ['type' => 'job', 'id' => 1002],
135
+ ],
136
+ ],
137
+ ],
138
+ 'summary' => ['distance' => 9100, 'duration' => 1500],
139
+ ], [
140
+ 1001 => 'order_one',
141
+ 1002 => 'order_two',
142
+ ]);
143
+
144
+ expect($result['assignments'][0]['arrival'])->toBeGreaterThanOrEqual($before + 600)
145
+ ->and($result['assignments'][0]['arrival'])->toBeLessThanOrEqual(now()->timestamp + 600)
146
+ ->and($result['assignments'][0])->toMatchArray(['route_distance' => 9100, 'route_duration' => 1500])
147
+ ->and($result['assignments'][1]['arrival'])->toBeNull()
148
+ ->and($result['summary'])->toBe(['engine' => 'vroom', 'distance' => 9100, 'duration' => 1500]);
116
149
  });
117
150
 
118
151
  test('vroom capacity-only payload uses matrix indexes instead of coordinates', function () {
@@ -0,0 +1,121 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Resources\v1\ServiceArea as ServiceAreaResource;
4
+ use Fleetbase\FleetOps\Http\Resources\v1\Zone as ZoneResource;
5
+ use Fleetbase\FleetOps\Models\ServiceArea;
6
+ use Fleetbase\FleetOps\Models\Zone;
7
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Routing\Route;
10
+
11
+ class FleetOpsGeofenceSettingsZoneFake extends Zone
12
+ {
13
+ public function getLocationAttribute(): Point
14
+ {
15
+ return new Point(1.3, 103.8);
16
+ }
17
+
18
+ public function withCustomFields(array $attributes = [], int|string $position = 'middle'): array
19
+ {
20
+ return $attributes;
21
+ }
22
+ }
23
+
24
+ class FleetOpsGeofenceSettingsServiceAreaFake extends ServiceArea
25
+ {
26
+ public function getLocationAttribute(): Point
27
+ {
28
+ return new Point(1.3, 103.8);
29
+ }
30
+
31
+ public function withCustomFields(array $attributes = [], int|string $position = 'middle'): array
32
+ {
33
+ return $attributes;
34
+ }
35
+ }
36
+
37
+ function fleetopsGeofenceSettingsRequest(bool $internal): Request
38
+ {
39
+ $uri = $internal ? 'int/v1/zones/zone_public' : 'v1/zones/zone_public';
40
+ $request = Request::create('/' . $uri, 'GET');
41
+ $route = new Route('GET', $internal ? 'int/v1/zones/{id}' : 'v1/zones/{id}', []);
42
+
43
+ $request->setRouteResolver(fn () => $route);
44
+ app()->instance('request', $request);
45
+
46
+ return $request;
47
+ }
48
+
49
+ /**
50
+ * Raw attributes shaped the way MySQL hands them back: tinyint/smallint
51
+ * columns arrive as strings, so the resource must rely on the model casts.
52
+ */
53
+ function fleetopsGeofenceSettingsRawAttributes(): array
54
+ {
55
+ return [
56
+ 'uuid' => 'zone-uuid',
57
+ 'public_id' => 'zone_public',
58
+ 'service_area_uuid' => 'area-uuid',
59
+ 'name' => 'Loading Dock',
60
+ 'status' => 'active',
61
+ 'trigger_on_entry' => '0',
62
+ 'trigger_on_exit' => '1',
63
+ 'dwell_threshold_minutes' => '15',
64
+ 'speed_limit_kmh' => '25',
65
+ ];
66
+ }
67
+
68
+ test('zone resource returns geofence settings typed on public and internal requests', function (bool $internal) {
69
+ $zone = new FleetOpsGeofenceSettingsZoneFake();
70
+ $zone->setRawAttributes(fleetopsGeofenceSettingsRawAttributes(), true);
71
+
72
+ $payload = (new ZoneResource($zone))->resolve(fleetopsGeofenceSettingsRequest($internal));
73
+
74
+ expect($payload['id'])->toBe($internal ? null : 'zone_public')
75
+ ->and($payload['trigger_on_entry'])->toBeFalse()
76
+ ->and($payload['trigger_on_exit'])->toBeTrue()
77
+ ->and($payload['dwell_threshold_minutes'])->toBe(15)
78
+ ->and($payload['speed_limit_kmh'])->toBe(25);
79
+ })->with(['public' => false, 'internal' => true]);
80
+
81
+ test('zone webhook payload includes typed geofence settings', function () {
82
+ $zone = new FleetOpsGeofenceSettingsZoneFake();
83
+ $zone->setRawAttributes(array_merge(fleetopsGeofenceSettingsRawAttributes(), ['dwell_threshold_minutes' => null]), true);
84
+
85
+ expect((new ZoneResource($zone))->toWebhookPayload())->toMatchArray([
86
+ 'id' => 'zone_public',
87
+ 'trigger_on_entry' => false,
88
+ 'trigger_on_exit' => true,
89
+ 'dwell_threshold_minutes' => null,
90
+ 'speed_limit_kmh' => 25,
91
+ ]);
92
+ });
93
+
94
+ test('zone and service area updates fill geofence settings and round-trip through the resource', function (string $modelClass, string $resourceClass) {
95
+ // Request-shaped input, as the update endpoints pass it to fill()/update().
96
+ $input = [
97
+ 'trigger_on_entry' => '1',
98
+ 'trigger_on_exit' => false,
99
+ 'dwell_threshold_minutes' => '30',
100
+ 'speed_limit_kmh' => 60,
101
+ ];
102
+
103
+ $model = new $modelClass();
104
+ $model->setRawAttributes(['public_id' => 'record_public', 'name' => 'Before'], true);
105
+ $model->fill($input);
106
+
107
+ expect($model->isDirty(['trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']))->toBeTrue();
108
+
109
+ $payload = (new $resourceClass($model))->resolve(fleetopsGeofenceSettingsRequest(false));
110
+
111
+ expect($payload)->toMatchArray([
112
+ 'id' => 'record_public',
113
+ 'trigger_on_entry' => true,
114
+ 'trigger_on_exit' => false,
115
+ 'dwell_threshold_minutes' => 30,
116
+ 'speed_limit_kmh' => 60,
117
+ ]);
118
+ })->with([
119
+ 'zone' => [FleetOpsGeofenceSettingsZoneFake::class, ZoneResource::class],
120
+ 'service area' => [FleetOpsGeofenceSettingsServiceAreaFake::class, ServiceAreaResource::class],
121
+ ]);