@fleetbase/fleetops-engine 0.6.58 → 0.6.60

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 (701) hide show
  1. package/README.md +3 -0
  2. package/RELEASE.md +36 -0
  3. package/addon/components/customer/form.hbs +0 -49
  4. package/addon/components/entity/card.hbs +1 -0
  5. package/addon/components/modals/order-label.hbs +1 -1
  6. package/addon/components/place/form.hbs +1 -1
  7. package/addon/components/vehicle/details/schedules.hbs +2 -2
  8. package/addon/components/vehicle/details/schedules.js +7 -1
  9. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  10. package/addon/services/entity-actions.js +32 -0
  11. package/addon/services/vehicle-actions.js +4 -2
  12. package/addon/styles/fleetops-engine.css +0 -1
  13. package/composer.json +8 -3
  14. package/extension.json +1 -1
  15. package/index.js +7 -1
  16. package/package.json +2 -1
  17. package/scripts/coverage-file-runner.php +240 -0
  18. package/scripts/coverage-runner.php +2 -1
  19. package/scripts/coverage-summary.php +41 -2
  20. package/scripts/pest-bootstrap.php +237 -3
  21. package/scripts/pest-file-runner.php +13 -1
  22. package/scripts/pest-runner.php +57 -2
  23. package/server/config/fleetops.php +44 -0
  24. package/server/config/geocoder.php +1 -1
  25. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  26. package/server/resources/views/labels/entity-label.php +4 -4
  27. package/server/src/Casts/MultiPolygon.php +8 -3
  28. package/server/src/Casts/OrderConfigEntities.php +10 -3
  29. package/server/src/Casts/Point.php +10 -6
  30. package/server/src/Casts/Polygon.php +5 -2
  31. package/server/src/Console/Commands/AssignCustomerRoles.php +28 -6
  32. package/server/src/Console/Commands/AssignDriverRoles.php +36 -6
  33. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +6 -1
  34. package/server/src/Console/Commands/DispatchAdhocOrders.php +12 -2
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +46 -11
  36. package/server/src/Console/Commands/FixDriverCompanies.php +18 -3
  37. package/server/src/Console/Commands/FixLegacyOrderConfigs.php +31 -6
  38. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +94 -42
  39. package/server/src/Console/Commands/PurgeUnpurchasedServiceQuotes.php +42 -12
  40. package/server/src/Console/Commands/ReplayVehicleLocations.php +120 -55
  41. package/server/src/Console/Commands/SendDriverNotification.php +25 -4
  42. package/server/src/Console/Commands/SendMaintenanceReminders.php +43 -23
  43. package/server/src/Console/Commands/SimulateOrderRouteNavigation.php +36 -6
  44. package/server/src/Console/Commands/TrackOrderDistanceAndTime.php +6 -1
  45. package/server/src/Events/DriverLocationChanged.php +1 -1
  46. package/server/src/Events/DriverSimulatedLocationChanged.php +1 -1
  47. package/server/src/Events/EntityActivityChanged.php +1 -1
  48. package/server/src/Events/EntityCompleted.php +1 -1
  49. package/server/src/Events/EntityDriverAssigned.php +1 -1
  50. package/server/src/Events/FuelProviderTransactionImported.php +1 -1
  51. package/server/src/Events/FuelProviderTransactionMatched.php +1 -1
  52. package/server/src/Events/FuelProviderTransactionUnmatched.php +1 -1
  53. package/server/src/Events/FuelReportCreatedFromProvider.php +1 -1
  54. package/server/src/Events/GeofenceDwelled.php +1 -1
  55. package/server/src/Events/GeofenceEntered.php +1 -1
  56. package/server/src/Events/GeofenceExited.php +1 -1
  57. package/server/src/Events/VehicleLocationChanged.php +1 -1
  58. package/server/src/Events/WaypointActivityChanged.php +1 -1
  59. package/server/src/Events/WaypointCompleted.php +1 -1
  60. package/server/src/Exceptions/IntegratedVendorException.php +2 -2
  61. package/server/src/Exceptions/TelematicRateLimitExceededException.php +1 -1
  62. package/server/src/Http/Controllers/Api/v1/ContactController.php +89 -22
  63. package/server/src/Http/Controllers/Api/v1/CustomerController.php +346 -101
  64. package/server/src/Http/Controllers/Api/v1/DeviceController.php +48 -14
  65. package/server/src/Http/Controllers/Api/v1/DriverController.php +224 -64
  66. package/server/src/Http/Controllers/Api/v1/EntityController.php +112 -66
  67. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +40 -10
  68. package/server/src/Http/Controllers/Api/v1/FleetController.php +54 -14
  69. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +54 -14
  70. package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +36 -11
  71. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +73 -23
  72. package/server/src/Http/Controllers/Api/v1/IssueController.php +54 -14
  73. package/server/src/Http/Controllers/Api/v1/LabelController.php +61 -22
  74. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +32 -15
  75. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +35 -5
  76. package/server/src/Http/Controllers/Api/v1/OrderController.php +388 -203
  77. package/server/src/Http/Controllers/Api/v1/PartController.php +40 -10
  78. package/server/src/Http/Controllers/Api/v1/PayloadController.php +90 -30
  79. package/server/src/Http/Controllers/Api/v1/PlaceController.php +188 -91
  80. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +100 -36
  81. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +96 -26
  82. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +169 -81
  83. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +104 -75
  84. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +92 -14
  85. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +66 -16
  86. package/server/src/Http/Controllers/Api/v1/VehicleController.php +121 -67
  87. package/server/src/Http/Controllers/Api/v1/VendorController.php +54 -14
  88. package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +47 -12
  89. package/server/src/Http/Controllers/Api/v1/ZoneController.php +84 -24
  90. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  91. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +15 -5
  92. package/server/src/Http/Controllers/Internal/v1/ContactController.php +113 -28
  93. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +36 -11
  94. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +9 -4
  95. package/server/src/Http/Controllers/Internal/v1/DriverController.php +168 -70
  96. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +18 -3
  97. package/server/src/Http/Controllers/Internal/v1/FleetController.php +137 -60
  98. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +24 -9
  99. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +18 -3
  100. package/server/src/Http/Controllers/Internal/v1/GeocoderController.php +26 -8
  101. package/server/src/Http/Controllers/Internal/v1/GettingStartedController.php +12 -2
  102. package/server/src/Http/Controllers/Internal/v1/IntegratedVendorController.php +27 -6
  103. package/server/src/Http/Controllers/Internal/v1/IssueController.php +82 -29
  104. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +34 -26
  105. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +141 -97
  106. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +28 -8
  107. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +21 -12
  108. package/server/src/Http/Controllers/Internal/v1/MorphController.php +90 -18
  109. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +88 -35
  110. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +149 -39
  111. package/server/src/Http/Controllers/Internal/v1/OrderConfigController.php +54 -18
  112. package/server/src/Http/Controllers/Internal/v1/OrderController.php +298 -86
  113. package/server/src/Http/Controllers/Internal/v1/PartController.php +18 -3
  114. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +46 -14
  115. package/server/src/Http/Controllers/Internal/v1/PlaceController.php +31 -5
  116. package/server/src/Http/Controllers/Internal/v1/ProofController.php +88 -48
  117. package/server/src/Http/Controllers/Internal/v1/SearchController.php +1 -1
  118. package/server/src/Http/Controllers/Internal/v1/SensorController.php +6 -1
  119. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +6 -1
  120. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +255 -105
  121. package/server/src/Http/Controllers/Internal/v1/SettingController.php +88 -35
  122. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +1 -4
  123. package/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php +42 -14
  124. package/server/src/Http/Controllers/Internal/v1/VendorController.php +103 -33
  125. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +42 -12
  126. package/server/src/Http/Filter/DriverFilter.php +2 -2
  127. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  128. package/server/src/Http/Filter/OrderFilter.php +1 -1
  129. package/server/src/Http/Filter/VehicleFilter.php +21 -7
  130. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +12 -2
  131. package/server/src/Http/Requests/BulkDispatchRequest.php +3 -9
  132. package/server/src/Http/Requests/CancelOrderRequest.php +2 -6
  133. package/server/src/Http/Requests/CreateContactRequest.php +2 -6
  134. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  135. package/server/src/Http/Requests/CreateDeviceRequest.php +2 -2
  136. package/server/src/Http/Requests/CreateDriverRequest.php +3 -9
  137. package/server/src/Http/Requests/CreateEntityRequest.php +2 -6
  138. package/server/src/Http/Requests/CreateEquipmentRequest.php +2 -2
  139. package/server/src/Http/Requests/CreateFleetRequest.php +2 -6
  140. package/server/src/Http/Requests/CreateFuelReportRequest.php +16 -9
  141. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +66 -3
  142. package/server/src/Http/Requests/CreateIssueRequest.php +2 -6
  143. package/server/src/Http/Requests/CreateOrderRequest.php +7 -8
  144. package/server/src/Http/Requests/CreatePartRequest.php +41 -3
  145. package/server/src/Http/Requests/CreatePayloadRequest.php +7 -9
  146. package/server/src/Http/Requests/CreatePlaceRequest.php +2 -6
  147. package/server/src/Http/Requests/CreatePurchaseRateRequest.php +2 -6
  148. package/server/src/Http/Requests/CreateSensorRequest.php +2 -2
  149. package/server/src/Http/Requests/CreateServiceAreaRequest.php +2 -6
  150. package/server/src/Http/Requests/CreateServiceQuoteRequest.php +2 -6
  151. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -6
  152. package/server/src/Http/Requests/CreateTrackingNumberRequest.php +2 -6
  153. package/server/src/Http/Requests/CreateTrackingStatusRequest.php +2 -6
  154. package/server/src/Http/Requests/CreateVehicleRequest.php +2 -6
  155. package/server/src/Http/Requests/CreateVendorRequest.php +2 -6
  156. package/server/src/Http/Requests/CreateWorkOrderRequest.php +2 -2
  157. package/server/src/Http/Requests/CreateZoneRequest.php +2 -6
  158. package/server/src/Http/Requests/DecodeTrackingNumberQR.php +2 -6
  159. package/server/src/Http/Requests/DriverSimulationRequest.php +2 -6
  160. package/server/src/Http/Requests/Internal/AssignOrderRequest.php +3 -7
  161. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +4 -12
  162. package/server/src/Http/Requests/Internal/CreateOrderConfigRequest.php +2 -6
  163. package/server/src/Http/Requests/Internal/CreateOrderRequest.php +7 -8
  164. package/server/src/Http/Requests/Internal/FleetActionRequest.php +18 -12
  165. package/server/src/Http/Requests/Internal/UpdateDriverRequest.php +6 -3
  166. package/server/src/Http/Requests/QueryServiceQuotesRequest.php +2 -6
  167. package/server/src/Http/Requests/ScheduleOrderRequest.php +2 -6
  168. package/server/src/Http/Requests/UpdateIssueRequest.php +1 -3
  169. package/server/src/Http/Resources/v1/Customer.php +2 -2
  170. package/server/src/Http/Resources/v1/Maintenance.php +3 -4
  171. package/server/src/Http/Resources/v1/MaintenanceSchedule.php +3 -4
  172. package/server/src/Http/Resources/v1/Order.php +3 -7
  173. package/server/src/Http/Resources/v1/PurchaseRate.php +4 -6
  174. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  175. package/server/src/Http/Resources/v1/TrackingStatus.php +4 -6
  176. package/server/src/Http/Resources/v1/WorkOrder.php +3 -4
  177. package/server/src/Imports/ContactImport.php +10 -1
  178. package/server/src/Imports/DriverImport.php +10 -1
  179. package/server/src/Imports/EquipmentImport.php +6 -1
  180. package/server/src/Imports/FleetImport.php +10 -1
  181. package/server/src/Imports/FuelReportImport.php +10 -1
  182. package/server/src/Imports/IssueImport.php +10 -1
  183. package/server/src/Imports/MaintenanceImport.php +6 -1
  184. package/server/src/Imports/MaintenanceScheduleImport.php +6 -1
  185. package/server/src/Imports/PartImport.php +6 -1
  186. package/server/src/Imports/PlaceImport.php +10 -1
  187. package/server/src/Imports/VehicleImport.php +10 -1
  188. package/server/src/Imports/VendorImport.php +10 -1
  189. package/server/src/Imports/WorkOrderImport.php +6 -1
  190. package/server/src/Integrations/Lalamove/Lalamove.php +3 -7
  191. package/server/src/Integrations/Lalamove/LalamoveServiceType.php +5 -8
  192. package/server/src/Jobs/CheckGeofenceDwell.php +41 -16
  193. package/server/src/Jobs/FinalizeApiOrderCreation.php +17 -2
  194. package/server/src/Jobs/FinalizeInternalOrderCreation.php +11 -1
  195. package/server/src/Jobs/NotifyBulkAssignedDriver.php +29 -16
  196. package/server/src/Jobs/ProcessAllocationJob.php +59 -25
  197. package/server/src/Jobs/ReplayPositions.php +17 -10
  198. package/server/src/Jobs/SendPositionReplay.php +18 -7
  199. package/server/src/Jobs/SimulateDrivingRoute.php +14 -3
  200. package/server/src/Jobs/SyncFuelProviderTransactionsJob.php +12 -2
  201. package/server/src/Listeners/HandleDeliveryCompletion.php +11 -1
  202. package/server/src/Listeners/HandleGeofenceDwelled.php +6 -1
  203. package/server/src/Listeners/HandleGeofenceEntered.php +4 -2
  204. package/server/src/Listeners/HandleGeofenceExited.php +6 -1
  205. package/server/src/Listeners/HandleOrderCanceled.php +18 -3
  206. package/server/src/Listeners/HandleOrderDispatchFailed.php +12 -2
  207. package/server/src/Listeners/HandleOrderDispatched.php +61 -26
  208. package/server/src/Listeners/HandleOrderDriverAssigned.php +12 -2
  209. package/server/src/Listeners/HandleUserRemovedFromCompany.php +19 -6
  210. package/server/src/Listeners/NotifyDriverOnShiftChange.php +24 -4
  211. package/server/src/Listeners/NotifyOrderEvent.php +11 -6
  212. package/server/src/Models/Contact.php +6 -7
  213. package/server/src/Models/Device.php +26 -3
  214. package/server/src/Models/Driver.php +0 -6
  215. package/server/src/Models/Entity.php +4 -4
  216. package/server/src/Models/Equipment.php +1 -1
  217. package/server/src/Models/FuelReport.php +26 -1
  218. package/server/src/Models/IntegratedVendor.php +2 -2
  219. package/server/src/Models/Issue.php +5 -1
  220. package/server/src/Models/MaintenanceSchedule.php +25 -10
  221. package/server/src/Models/Order.php +18 -15
  222. package/server/src/Models/OrderConfig.php +1 -1
  223. package/server/src/Models/Payload.php +9 -16
  224. package/server/src/Models/Place.php +20 -13
  225. package/server/src/Models/Sensor.php +24 -0
  226. package/server/src/Models/ServiceArea.php +3 -6
  227. package/server/src/Models/ServiceQuote.php +5 -0
  228. package/server/src/Models/ServiceRate.php +13 -6
  229. package/server/src/Models/Telematic.php +2 -2
  230. package/server/src/Models/TrackingNumber.php +55 -10
  231. package/server/src/Models/TrackingStatus.php +24 -4
  232. package/server/src/Models/Waypoint.php +44 -10
  233. package/server/src/Models/Zone.php +3 -6
  234. package/server/src/Notifications/DriverArrivedAtGeofence.php +1 -1
  235. package/server/src/Notifications/OrderDispatched.php +3 -9
  236. package/server/src/Notifications/OrderPing.php +3 -9
  237. package/server/src/Observers/CategoryObserver.php +6 -1
  238. package/server/src/Observers/CompanyObserver.php +5 -0
  239. package/server/src/Observers/CompanyUserObserver.php +12 -2
  240. package/server/src/Observers/DriverObserver.php +19 -4
  241. package/server/src/Observers/FleetObserver.php +6 -1
  242. package/server/src/Observers/PurchaseRateObserver.php +66 -11
  243. package/server/src/Observers/ServiceAreaObserver.php +12 -2
  244. package/server/src/Observers/ServiceRateObserver.php +21 -6
  245. package/server/src/Observers/TrackingNumberObserver.php +19 -4
  246. package/server/src/Observers/UserObserver.php +6 -1
  247. package/server/src/Observers/VehicleObserver.php +27 -7
  248. package/server/src/Observers/WorkOrderObserver.php +27 -7
  249. package/server/src/Orchestration/Engines/DriverAssignmentEngine.php +8 -3
  250. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +2 -2
  251. package/server/src/Providers/FleetOpsServiceProvider.php +12 -0
  252. package/server/src/Providers/NotificationServiceProvider.php +6 -0
  253. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +36 -26
  254. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +18 -5
  255. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +6 -1
  256. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +26 -6
  257. package/server/src/Support/Analytics/FuelEfficiency.php +9 -3
  258. package/server/src/Support/Analytics/FuelProviderSummary.php +14 -5
  259. package/server/src/Support/Analytics/GeofenceViolations.php +40 -20
  260. package/server/src/Support/Analytics/IssuesInsights.php +38 -18
  261. package/server/src/Support/Analytics/MaintenanceOverview.php +51 -21
  262. package/server/src/Support/Analytics/OnTimeDelivery.php +3 -2
  263. package/server/src/Support/Analytics/OperationsPulse.php +59 -35
  264. package/server/src/Support/Analytics/OrdersByStatus.php +12 -7
  265. package/server/src/Support/Analytics/RevenueTrend.php +16 -6
  266. package/server/src/Support/Analytics/TopDrivers.php +3 -2
  267. package/server/src/Support/FleetOps.php +109 -104
  268. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  269. package/server/src/Support/Geocoding.php +52 -14
  270. package/server/src/Support/GeofenceIntersectionService.php +47 -27
  271. package/server/src/Support/LiveOrderQuery.php +6 -1
  272. package/server/src/Support/Metrics/ActiveRevenueQuery.php +3 -2
  273. package/server/src/Support/Metrics/FuelCostsMetric.php +6 -1
  274. package/server/src/Support/ResolvesOrderServiceStops.php +10 -0
  275. package/server/src/Support/Telematics/Providers/GeotabProvider.php +9 -4
  276. package/server/src/Support/Utils.php +122 -16
  277. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +6 -1
  278. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +11 -6
  279. package/server/src/Tracking/TrackingProviderManager.php +2 -3
  280. package/server/src/Tracking/TrackingProviderRegistry.php +8 -4
  281. package/server/src/Traits/PayloadAccessors.php +6 -1
  282. package/server/src/routes.php +24 -17
  283. package/server/tests/AiOperationalQueryCapabilityTest.php +457 -1
  284. package/server/tests/AiPreviewCapabilityContractsTest.php +180 -0
  285. package/server/tests/AnalyticsRoutesTest.php +380 -1
  286. package/server/tests/AnalyticsWidgetContractsTest.php +740 -0
  287. package/server/tests/ApiContactControllerContractsTest.php +284 -0
  288. package/server/tests/ApiCustomerControllerContractsTest.php +1130 -0
  289. package/server/tests/ApiDriverControllerContractsTest.php +603 -0
  290. package/server/tests/ApiEntityControllerContractsTest.php +329 -0
  291. package/server/tests/ApiEquipmentControllerContractsTest.php +239 -0
  292. package/server/tests/ApiFleetControllerContractsTest.php +176 -0
  293. package/server/tests/ApiFuelReportControllerContractsTest.php +217 -0
  294. package/server/tests/ApiIssueControllerContractsTest.php +217 -0
  295. package/server/tests/ApiOrderControllerContractsTest.php +1030 -0
  296. package/server/tests/ApiPartControllerContractsTest.php +251 -0
  297. package/server/tests/ApiPayloadControllerContractsTest.php +314 -0
  298. package/server/tests/ApiPlaceControllerContractsTest.php +541 -0
  299. package/server/tests/ApiPurchaseRateControllerContractsTest.php +283 -0
  300. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +442 -0
  301. package/server/tests/ApiServiceQuoteControllerContractsTest.php +322 -0
  302. package/server/tests/ApiTrackingNumberControllerContractsTest.php +225 -0
  303. package/server/tests/ApiTrackingStatusControllerContractsTest.php +254 -0
  304. package/server/tests/ApiVehicleControllerContractsTest.php +368 -0
  305. package/server/tests/ApiVendorControllerContractsTest.php +199 -0
  306. package/server/tests/AssetAndPlaceSearchContractsTest.php +220 -0
  307. package/server/tests/BackendSmallModelContractsTest.php +354 -0
  308. package/server/tests/CapacityAllocationEngineTest.php +56 -0
  309. package/server/tests/CommandContractsTest.php +2695 -0
  310. package/server/tests/CompactResourceSerializationTest.php +2838 -0
  311. package/server/tests/ControllerFilterContractsTest.php +1273 -0
  312. package/server/tests/ControllerHelperContractsTest.php +1740 -0
  313. package/server/tests/ControllerSerializationContractsTest.php +141 -0
  314. package/server/tests/CoverageSummaryScriptTest.php +93 -0
  315. package/server/tests/CustomerContactIdentitySafetyTest.php +1 -6
  316. package/server/tests/CustomerEndpointTest.php +169 -2
  317. package/server/tests/DriverFilterExecutionTest.php +263 -0
  318. package/server/tests/DriverGeofenceProcessingTest.php +153 -0
  319. package/server/tests/DriverResourceSerializationTest.php +245 -0
  320. package/server/tests/DriverSchedulingTraitContractsTest.php +259 -0
  321. package/server/tests/EventContractsTest.php +1259 -0
  322. package/server/tests/ExceptionContractsTest.php +81 -0
  323. package/server/tests/ExpansionContractsTest.php +242 -0
  324. package/server/tests/ExportCoverageTest.php +116 -0
  325. package/server/tests/Feature/Http/Api/ContactControllerPhotoAndDeleteTest.php +199 -0
  326. package/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php +831 -0
  327. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +347 -0
  328. package/server/tests/Feature/Http/Api/DeviceControllerCrudTest.php +176 -0
  329. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +530 -0
  330. package/server/tests/Feature/Http/Api/DriverControllerHelpersTest.php +240 -0
  331. package/server/tests/Feature/Http/Api/DriverControllerLookupTest.php +186 -0
  332. package/server/tests/Feature/Http/Api/DriverControllerOrganizationsTest.php +112 -0
  333. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +207 -0
  334. package/server/tests/Feature/Http/Api/DriverControllerSwitchOrgAndGeofenceTest.php +329 -0
  335. package/server/tests/Feature/Http/Api/DriverControllerTrackTest.php +383 -0
  336. package/server/tests/Feature/Http/Api/EntityControllerHelpersTest.php +150 -0
  337. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +352 -0
  338. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +461 -0
  339. package/server/tests/Feature/Http/Api/LabelControllerSubjectsTest.php +148 -0
  340. package/server/tests/Feature/Http/Api/OrderConfigControllerTest.php +112 -0
  341. package/server/tests/Feature/Http/Api/OrderControllerCreateTest.php +279 -0
  342. package/server/tests/Feature/Http/Api/OrderControllerIntegratedVendorTest.php +295 -0
  343. package/server/tests/Feature/Http/Api/OrderControllerLifecycleActionsTest.php +279 -0
  344. package/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php +207 -0
  345. package/server/tests/Feature/Http/Api/OrderControllerNearbyFiltersTest.php +282 -0
  346. package/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php +216 -0
  347. package/server/tests/Feature/Http/Api/OrderControllerQueryFiltersTest.php +184 -0
  348. package/server/tests/Feature/Http/Api/OrderControllerStartActivityTest.php +663 -0
  349. package/server/tests/Feature/Http/Api/PayloadControllerHelpersTest.php +134 -0
  350. package/server/tests/Feature/Http/Api/PlaceAndServiceQuoteSeamsTest.php +479 -0
  351. package/server/tests/Feature/Http/Api/PurchaseRateControllerHelpersTest.php +176 -0
  352. package/server/tests/Feature/Http/Api/PurchaseRateControllerOrderCreationTest.php +268 -0
  353. package/server/tests/Feature/Http/Api/SensorControllerCrudTest.php +239 -0
  354. package/server/tests/Feature/Http/Api/ServiceAreaControllerBorderAndSeamsTest.php +187 -0
  355. package/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php +405 -0
  356. package/server/tests/Feature/Http/Api/ServiceQuoteControllerVendorBridgeTest.php +212 -0
  357. package/server/tests/Feature/Http/Api/ServiceRateControllerContractsTest.php +263 -0
  358. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +272 -0
  359. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +160 -0
  360. package/server/tests/Feature/Http/Api/TrackingStatusControllerHelpersTest.php +165 -0
  361. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +483 -0
  362. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +326 -0
  363. package/server/tests/Feature/Http/Api/WorkOrderControllerHelpersTest.php +130 -0
  364. package/server/tests/Feature/Http/Api/ZoneControllerBordersAndSeamsTest.php +294 -0
  365. package/server/tests/Feature/Http/FleetOpsRouteRegistrationTest.php +189 -0
  366. package/server/tests/Feature/Http/Internal/AnalyticsControllerSeamsTest.php +53 -0
  367. package/server/tests/Feature/Http/Internal/ContactControllerContractsTest.php +240 -0
  368. package/server/tests/Feature/Http/Internal/ContactControllerCustomerGuardsTest.php +128 -0
  369. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +294 -0
  370. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +196 -0
  371. package/server/tests/Feature/Http/Internal/DeviceControllerContractsTest.php +370 -0
  372. package/server/tests/Feature/Http/Internal/DeviceControllerExportAndFiltersTest.php +151 -0
  373. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +1167 -0
  374. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +233 -0
  375. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +710 -0
  376. package/server/tests/Feature/Http/Internal/DriverControllerHelpersTest.php +218 -0
  377. package/server/tests/Feature/Http/Internal/FleetAssignmentsAndTriggersTest.php +198 -0
  378. package/server/tests/Feature/Http/Internal/FleetOpsLookupControllerDbTest.php +90 -0
  379. package/server/tests/Feature/Http/Internal/HubControllerTest.php +206 -0
  380. package/server/tests/Feature/Http/Internal/IssueControllerContractsTest.php +404 -0
  381. package/server/tests/Feature/Http/Internal/IssueControllerExportImportTest.php +118 -0
  382. package/server/tests/Feature/Http/Internal/LiveControllerEndpointsTest.php +246 -0
  383. package/server/tests/Feature/Http/Internal/LiveControllerTest.php +778 -0
  384. package/server/tests/Feature/Http/Internal/MaintenanceScheduleControllerContractsTest.php +263 -0
  385. package/server/tests/Feature/Http/Internal/MaintenanceScheduleControllerSurfacesTest.php +214 -0
  386. package/server/tests/Feature/Http/Internal/MetricsControllerContractsTest.php +249 -0
  387. package/server/tests/Feature/Http/Internal/MetricsControllerHelpersTest.php +66 -0
  388. package/server/tests/Feature/Http/Internal/MorphControllerHelpersTest.php +125 -0
  389. package/server/tests/Feature/Http/Internal/NavigatorControllerHelpersTest.php +201 -0
  390. package/server/tests/Feature/Http/Internal/OrchestrationControllerContractsTest.php +928 -0
  391. package/server/tests/Feature/Http/Internal/OrchestrationControllerHelpersTest.php +214 -0
  392. package/server/tests/Feature/Http/Internal/OrchestrationControllerImportOrdersTest.php +340 -0
  393. package/server/tests/Feature/Http/Internal/OrderConfigControllerContractsTest.php +314 -0
  394. package/server/tests/Feature/Http/Internal/OrderControllerActivityFlowsTest.php +663 -0
  395. package/server/tests/Feature/Http/Internal/OrderControllerCapturePhotoTest.php +507 -0
  396. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +1742 -0
  397. package/server/tests/Feature/Http/Internal/OrderControllerCreateRecordTest.php +488 -0
  398. package/server/tests/Feature/Http/Internal/OrderControllerHelperSeamsTest.php +298 -0
  399. package/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php +267 -0
  400. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +179 -0
  401. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +80 -0
  402. package/server/tests/Feature/Http/Internal/PaymentControllerPaymentsTest.php +154 -0
  403. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +252 -0
  404. package/server/tests/Feature/Http/Internal/PositionControllerReplayMetricsTest.php +163 -0
  405. package/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php +219 -0
  406. package/server/tests/Feature/Http/Internal/SearchControllerEndpointTest.php +151 -0
  407. package/server/tests/Feature/Http/Internal/ServiceQuoteControllerHelpersTest.php +208 -0
  408. package/server/tests/Feature/Http/Internal/ServiceQuoteControllerQueryRecordTest.php +217 -0
  409. package/server/tests/Feature/Http/Internal/ServiceQuoteControllerVendorBridgeTest.php +239 -0
  410. package/server/tests/Feature/Http/Internal/ServiceQuotePreliminaryQueryTest.php +271 -0
  411. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +133 -0
  412. package/server/tests/Feature/Http/Internal/SmallInternalControllersTest.php +180 -0
  413. package/server/tests/Feature/Http/Internal/TelematicControllerEndpointsTest.php +417 -0
  414. package/server/tests/Feature/Http/Internal/VehicleControllerEndpointsTest.php +361 -0
  415. package/server/tests/Feature/Http/Internal/VendorControllerEndpointsTest.php +203 -0
  416. package/server/tests/Feature/Http/Internal/WorkOrderAndConfigSurfacesTest.php +289 -0
  417. package/server/tests/Feature/Http/Internal/WorkOrderControllerContractsTest.php +199 -0
  418. package/server/tests/Feature/Http/TelematicWebhookIngestTest.php +343 -0
  419. package/server/tests/Feature/Http/Telematics/TelematicWebhookControllerProcessingTest.php +254 -0
  420. package/server/tests/FleetOpsSupportContractsTest.php +187 -0
  421. package/server/tests/FlespiProviderContractsTest.php +244 -0
  422. package/server/tests/FlowResourceTest.php +295 -0
  423. package/server/tests/FuelProviderConnectionControllerContractsTest.php +303 -0
  424. package/server/tests/FuelProviderImplementationTest.php +753 -0
  425. package/server/tests/FuelProviderResourceContractsTest.php +171 -0
  426. package/server/tests/FuelProviderTransactionControllerContractsTest.php +198 -0
  427. package/server/tests/GeofenceIntersectionServiceContractsTest.php +164 -0
  428. package/server/tests/HOSConstraintContractsTest.php +128 -0
  429. package/server/tests/HubAndGettingStartedContractsTest.php +158 -0
  430. package/server/tests/ImportContractsTest.php +191 -0
  431. package/server/tests/InternalCustomerControllerContractsTest.php +238 -0
  432. package/server/tests/InternalFleetControllerContractsTest.php +389 -0
  433. package/server/tests/InternalImportExportControllerContractsTest.php +289 -0
  434. package/server/tests/InternalMetricsControllerContractsTest.php +69 -0
  435. package/server/tests/InternalPlaceWorkOrderControllerContractsTest.php +207 -0
  436. package/server/tests/InternalServiceQuoteCheckoutControllerContractsTest.php +470 -0
  437. package/server/tests/InternalVendorControllerContractsTest.php +331 -0
  438. package/server/tests/InventoryTelemetryModelContractsTest.php +516 -0
  439. package/server/tests/IssueTimelineContractsTest.php +191 -0
  440. package/server/tests/LabelPdfRenderingTest.php +57 -0
  441. package/server/tests/LalamoveIntegrationContractsTest.php +167 -0
  442. package/server/tests/LiveOrderQueryTest.php +145 -18
  443. package/server/tests/LookupAndGeocoderControllerContractsTest.php +357 -0
  444. package/server/tests/MaintainableTraitContractsTest.php +245 -0
  445. package/server/tests/MaintenanceScheduleControllerContractsTest.php +101 -0
  446. package/server/tests/MaintenanceWarrantyContractsTest.php +237 -0
  447. package/server/tests/ManifestControllerContractsTest.php +310 -0
  448. package/server/tests/MetricsRegistryTest.php +596 -10
  449. package/server/tests/MiddlewareContractsTest.php +150 -0
  450. package/server/tests/ModelAccessorContractsTest.php +3201 -0
  451. package/server/tests/MorphControllerContractsTest.php +410 -0
  452. package/server/tests/NavigatorControllerContractsTest.php +206 -0
  453. package/server/tests/NotificationAndMailContractsTest.php +677 -0
  454. package/server/tests/ObserverContractsTest.php +1414 -0
  455. package/server/tests/OperationalAlgorithmContractsTest.php +852 -0
  456. package/server/tests/OperationsMonitorEndpointTest.php +2 -1
  457. package/server/tests/OrchestrationPayloadBuilderTest.php +87 -0
  458. package/server/tests/OrderActivityFlowRegressionTest.php +354 -7
  459. package/server/tests/OrderDispatchListenerContractsTest.php +577 -0
  460. package/server/tests/OrderFilterExecutionTest.php +244 -0
  461. package/server/tests/OrderQueuedClosureRegressionTest.php +2 -1
  462. package/server/tests/PaymentControllerContractsTest.php +199 -0
  463. package/server/tests/PointResolutionTest.php +54 -0
  464. package/server/tests/ProcessAllocationJobContractsTest.php +224 -0
  465. package/server/tests/ProofAndSensorControllerContractsTest.php +44 -0
  466. package/server/tests/ProviderContractsTest.php +409 -0
  467. package/server/tests/PublicApiControllerInputContractsTest.php +294 -0
  468. package/server/tests/QueueJobContractsTest.php +764 -0
  469. package/server/tests/ReportSchemaContractsTest.php +271 -0
  470. package/server/tests/RequestContractsTest.php +1058 -0
  471. package/server/tests/RouteRegistrationExecutionTest.php +178 -0
  472. package/server/tests/RouteSequencingEngineTest.php +179 -0
  473. package/server/tests/RulesAndCastsTest.php +246 -0
  474. package/server/tests/SamsaraProviderContractsTest.php +272 -0
  475. package/server/tests/SearchControllerContractsTest.php +311 -0
  476. package/server/tests/ServiceRateRouteValidationTest.php +76 -0
  477. package/server/tests/SettingControllerContractsTest.php +410 -0
  478. package/server/tests/SmallControllerContractsTest.php +480 -0
  479. package/server/tests/SupportAnalyticsCustomerAuthContractsTest.php +221 -0
  480. package/server/tests/SupportJobAndAiCoverageTest.php +812 -0
  481. package/server/tests/SupportServiceContractsTest.php +287 -0
  482. package/server/tests/SupportValueObjectsTest.php +155 -0
  483. package/server/tests/TelematicControllerContractsTest.php +185 -0
  484. package/server/tests/TelematicProviderRegistryTest.php +167 -0
  485. package/server/tests/TelematicServiceHelperContractsTest.php +315 -0
  486. package/server/tests/TelematicWebhookControllerContractsTest.php +253 -0
  487. package/server/tests/TelematicsHardeningTest.php +642 -86
  488. package/server/tests/TrackingIntelligenceTest.php +644 -1
  489. package/server/tests/TrackingNumberTraitContractsTest.php +106 -0
  490. package/server/tests/Unit/Casts/SpatialCastBranchesTest.php +75 -0
  491. package/server/tests/Unit/Console/AssignCustomerRolesCommandTest.php +102 -0
  492. package/server/tests/Unit/Console/AssignDriverRolesCommandTest.php +113 -0
  493. package/server/tests/Unit/Console/Commands/FixLegacyOrderConfigsHelpersTest.php +108 -0
  494. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +265 -0
  495. package/server/tests/Unit/Console/Commands/ReplayVehicleLocationsCommandTest.php +320 -0
  496. package/server/tests/Unit/Console/Commands/SimulateGeofenceEventsCommandTest.php +207 -0
  497. package/server/tests/Unit/Console/FixCommandsAndMaintenanceControllerTest.php +256 -0
  498. package/server/tests/Unit/Console/FleetOpsCommandsTest.php +198 -0
  499. package/server/tests/Unit/Console/ReplayVehicleLocationsCommandTest.php +252 -0
  500. package/server/tests/Unit/Console/SendMaintenanceRemindersTest.php +212 -0
  501. package/server/tests/Unit/Console/SimulateGeofenceEventsCommandTest.php +232 -0
  502. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +136 -0
  503. package/server/tests/Unit/Constraints/HOSConstraintTest.php +158 -0
  504. package/server/tests/Unit/Events/EventModelRecordTest.php +93 -0
  505. package/server/tests/Unit/Events/OrderDispatchFailedTest.php +34 -0
  506. package/server/tests/Unit/Exports/ExportCollectionsTest.php +142 -0
  507. package/server/tests/Unit/Exports/OperationalExportMappingTest.php +651 -0
  508. package/server/tests/Unit/Exports/VehicleExportTest.php +284 -0
  509. package/server/tests/Unit/Flow/EventFiringTest.php +95 -0
  510. package/server/tests/Unit/Http/Api/QueryWithRequestSeamsTest.php +198 -0
  511. package/server/tests/Unit/Http/Api/SensorInputTest.php +95 -0
  512. package/server/tests/Unit/Http/Filter/DeviceFilterTest.php +199 -0
  513. package/server/tests/Unit/Http/Filter/EmptyValueGuardsTest.php +66 -0
  514. package/server/tests/Unit/Http/Filter/FilterDateRangesTest.php +72 -0
  515. package/server/tests/Unit/Http/Filter/FuelProviderTransactionFilterTest.php +172 -0
  516. package/server/tests/Unit/Http/Filter/InternalRelationUuidLookupTest.php +135 -0
  517. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +129 -0
  518. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  519. package/server/tests/Unit/Http/Filter/PartFilterTest.php +173 -0
  520. package/server/tests/Unit/Http/Internal/ControllerDelegationSeamsTest.php +136 -0
  521. package/server/tests/Unit/Http/Internal/ControllerExcelSeamsTest.php +89 -0
  522. package/server/tests/Unit/Http/Middleware/SetupDriverSessionTest.php +32 -0
  523. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +68 -0
  524. package/server/tests/Unit/Http/Requests/FleetActionRequestTest.php +80 -0
  525. package/server/tests/Unit/Http/Requests/RequestAuthorizationTest.php +50 -0
  526. package/server/tests/Unit/Http/Resources/CustomerResourceTest.php +108 -0
  527. package/server/tests/Unit/Http/Resources/EntityCustomerTypeTest.php +35 -0
  528. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +102 -0
  529. package/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php +210 -0
  530. package/server/tests/Unit/Http/Resources/IndexVehicleResourceTest.php +194 -0
  531. package/server/tests/Unit/Http/Resources/MaintenanceResourcesTest.php +182 -0
  532. package/server/tests/Unit/Http/Resources/OrderAndDriverResourceTest.php +121 -0
  533. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +47 -0
  534. package/server/tests/Unit/Http/Resources/PayloadResourceTest.php +111 -0
  535. package/server/tests/Unit/Http/Resources/RelationResourceFallbacksTest.php +32 -0
  536. package/server/tests/Unit/Http/Resources/ResourceRelationResolutionTest.php +55 -0
  537. package/server/tests/Unit/Http/Resources/TrackingNumberResourceTest.php +174 -0
  538. package/server/tests/Unit/Http/Resources/WaypointResourceTest.php +295 -0
  539. package/server/tests/Unit/Http/Resources/WorkOrderResourceTest.php +164 -0
  540. package/server/tests/Unit/Imports/ImportDelegationTest.php +151 -0
  541. package/server/tests/Unit/Integrations/Lalamove/LalamoveDeliveryStopTest.php +79 -0
  542. package/server/tests/Unit/Integrations/Lalamove/LalamoveLifecycleTest.php +211 -0
  543. package/server/tests/Unit/Integrations/Lalamove/LalamoveQuoteAndServiceOrderTest.php +381 -0
  544. package/server/tests/Unit/Integrations/Lalamove/LalamoveServiceTypeTest.php +51 -0
  545. package/server/tests/Unit/Integrations/LalamoveQuotationTest.php +194 -0
  546. package/server/tests/Unit/Integrations/LalamoveResponseHandlingTest.php +58 -0
  547. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +190 -0
  548. package/server/tests/Unit/Jobs/ReplayPositionsTest.php +57 -0
  549. package/server/tests/Unit/Jobs/SimulateWaypointReachedTest.php +92 -0
  550. package/server/tests/Unit/Listeners/HandleGeofenceEnteredTest.php +256 -0
  551. package/server/tests/Unit/Listeners/HandleOrderDispatchFailedListenerTest.php +101 -0
  552. package/server/tests/Unit/Listeners/HandleOrderDispatchedConcreteTest.php +203 -0
  553. package/server/tests/Unit/Listeners/HandleOrderDispatchedHelpersTest.php +186 -0
  554. package/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php +65 -0
  555. package/server/tests/Unit/Listeners/ListenerDelegationSeamsTest.php +83 -0
  556. package/server/tests/Unit/Models/AssetTest.php +469 -0
  557. package/server/tests/Unit/Models/ContactCustomerUserTest.php +370 -0
  558. package/server/tests/Unit/Models/ContactTest.php +577 -0
  559. package/server/tests/Unit/Models/DeviceEventTest.php +481 -0
  560. package/server/tests/Unit/Models/DeviceLastPositionDefaultTest.php +128 -0
  561. package/server/tests/Unit/Models/DriverModelHelpersTest.php +268 -0
  562. package/server/tests/Unit/Models/DriverTest.php +401 -0
  563. package/server/tests/Unit/Models/EntityTest.php +171 -0
  564. package/server/tests/Unit/Models/EquipmentTest.php +434 -0
  565. package/server/tests/Unit/Models/FleetTest.php +153 -0
  566. package/server/tests/Unit/Models/FuelProviderTransactionTest.php +82 -0
  567. package/server/tests/Unit/Models/FuelReportImportAndLabelsTest.php +193 -0
  568. package/server/tests/Unit/Models/ImportRowFallbackSeamsTest.php +100 -0
  569. package/server/tests/Unit/Models/IntegratedVendorLifecycleTest.php +203 -0
  570. package/server/tests/Unit/Models/IntegratedVendorTest.php +135 -0
  571. package/server/tests/Unit/Models/IssueTest.php +160 -0
  572. package/server/tests/Unit/Models/MaintenanceScheduleTest.php +319 -0
  573. package/server/tests/Unit/Models/MaintenanceTest.php +277 -0
  574. package/server/tests/Unit/Models/MaintenanceWorkOrderImportTest.php +161 -0
  575. package/server/tests/Unit/Models/ManifestStopTest.php +121 -0
  576. package/server/tests/Unit/Models/ManifestTest.php +163 -0
  577. package/server/tests/Unit/Models/ModelLookupFallbackSeamsTest.php +149 -0
  578. package/server/tests/Unit/Models/OrderConfigTest.php +341 -0
  579. package/server/tests/Unit/Models/OrderDynamicContractsTest.php +190 -0
  580. package/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php +426 -0
  581. package/server/tests/Unit/Models/OrderPayloadRouteTest.php +227 -0
  582. package/server/tests/Unit/Models/OrderPurchaseAndDriversTest.php +188 -0
  583. package/server/tests/Unit/Models/OrderTest.php +917 -0
  584. package/server/tests/Unit/Models/PartTest.php +399 -0
  585. package/server/tests/Unit/Models/PayloadTest.php +710 -0
  586. package/server/tests/Unit/Models/PayloadWaypointsAndEntitiesTest.php +317 -0
  587. package/server/tests/Unit/Models/PlaceCreationAndImportTest.php +384 -0
  588. package/server/tests/Unit/Models/PlaceGeocodingResultsTest.php +253 -0
  589. package/server/tests/Unit/Models/PlaceSharedAndGeocodingTest.php +271 -0
  590. package/server/tests/Unit/Models/PlaceTest.php +589 -0
  591. package/server/tests/Unit/Models/PositionTest.php +49 -0
  592. package/server/tests/Unit/Models/RouteTest.php +55 -0
  593. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  594. package/server/tests/Unit/Models/SensorReadingsTest.php +168 -0
  595. package/server/tests/Unit/Models/SensorTest.php +284 -0
  596. package/server/tests/Unit/Models/ServiceAreaGeometryTest.php +129 -0
  597. package/server/tests/Unit/Models/ServiceAreaTest.php +125 -0
  598. package/server/tests/Unit/Models/ServiceQuoteTest.php +617 -0
  599. package/server/tests/Unit/Models/ServiceRateFeeTest.php +73 -0
  600. package/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php +428 -0
  601. package/server/tests/Unit/Models/ServiceRateTest.php +533 -0
  602. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +294 -0
  603. package/server/tests/Unit/Models/TrackingNumberTest.php +199 -0
  604. package/server/tests/Unit/Models/TrackingStatusTest.php +131 -0
  605. package/server/tests/Unit/Models/VehicleDeviceTest.php +115 -0
  606. package/server/tests/Unit/Models/VehiclePositionsAndImportTest.php +220 -0
  607. package/server/tests/Unit/Models/VehicleTest.php +266 -0
  608. package/server/tests/Unit/Models/VendorRelationsAndImportTest.php +107 -0
  609. package/server/tests/Unit/Models/WarrantyTest.php +187 -0
  610. package/server/tests/Unit/Models/WaypointTest.php +366 -0
  611. package/server/tests/Unit/Models/WorkOrderTest.php +272 -0
  612. package/server/tests/Unit/Models/ZoneTest.php +83 -0
  613. package/server/tests/Unit/Notifications/MonitoringNotificationsTest.php +136 -0
  614. package/server/tests/Unit/Notifications/OrderAssignedChannelsTest.php +53 -0
  615. package/server/tests/Unit/Notifications/OrderDispatchedChannelsTest.php +81 -0
  616. package/server/tests/Unit/Notifications/OrderFailedChannelsTest.php +73 -0
  617. package/server/tests/Unit/Notifications/OrderPingChannelsTest.php +69 -0
  618. package/server/tests/Unit/Notifications/TerminalOrderNotificationsTest.php +185 -0
  619. package/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php +133 -0
  620. package/server/tests/Unit/Observers/ObserverHelperExecutionTest.php +135 -0
  621. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +267 -0
  622. package/server/tests/Unit/Observers/PurchaseRateObserverAndOrderJobsTest.php +169 -0
  623. package/server/tests/Unit/Orchestration/Engines/DriverAssignmentEngineTest.php +393 -0
  624. package/server/tests/Unit/Orchestration/Engines/VroomOrchestrationEngineLifecycleTest.php +397 -0
  625. package/server/tests/Unit/Orchestration/OrchestrationEngineRegistryTest.php +45 -0
  626. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +253 -0
  627. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +59 -0
  628. package/server/tests/Unit/Rules/ResolvableVehicleTest.php +85 -0
  629. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +120 -0
  630. package/server/tests/Unit/Support/Ai/Capabilities/CreateOrderPreviewCapabilityTest.php +385 -0
  631. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +226 -0
  632. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +237 -0
  633. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +124 -0
  634. package/server/tests/Unit/Support/AlgoEdgeBranchesTest.php +24 -0
  635. package/server/tests/Unit/Support/Analytics/GeofenceViolationsTest.php +109 -0
  636. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +205 -0
  637. package/server/tests/Unit/Support/Analytics/MaintenanceOverviewTest.php +236 -0
  638. package/server/tests/Unit/Support/Analytics/OnTimeDeliveryAndTopDriversTest.php +250 -0
  639. package/server/tests/Unit/Support/CreateOrderPreviewDraftingTest.php +235 -0
  640. package/server/tests/Unit/Support/CustomerAuthTest.php +132 -0
  641. package/server/tests/Unit/Support/FuelEfficiencyAndCanceledHandlersTest.php +164 -0
  642. package/server/tests/Unit/Support/FuelProviderRegistryResolutionTest.php +51 -0
  643. package/server/tests/Unit/Support/FuelProviderServiceMatchingTest.php +280 -0
  644. package/server/tests/Unit/Support/GeocodingInjectedClientTest.php +268 -0
  645. package/server/tests/Unit/Support/GeocodingLocateTest.php +32 -0
  646. package/server/tests/Unit/Support/GeofenceIntersectionServiceTest.php +188 -0
  647. package/server/tests/Unit/Support/GettingStartedTest.php +164 -0
  648. package/server/tests/Unit/Support/GuardReturnSeamsTest.php +172 -0
  649. package/server/tests/Unit/Support/IntegratedVendorsResolverTest.php +250 -0
  650. package/server/tests/Unit/Support/IssuesInsightsAndAllocationJobTest.php +156 -0
  651. package/server/tests/Unit/Support/MagicAccessorSeamsTest.php +54 -0
  652. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +215 -0
  653. package/server/tests/Unit/Support/Metrics/AvgOrderValueMetricTest.php +308 -0
  654. package/server/tests/Unit/Support/Metrics/CountMetricsTest.php +280 -0
  655. package/server/tests/Unit/Support/Metrics/FuelCostsMetricTest.php +102 -0
  656. package/server/tests/Unit/Support/Metrics/MetricQuerySeamsTest.php +129 -0
  657. package/server/tests/Unit/Support/Metrics/OrdersScheduledMetricTest.php +146 -0
  658. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +410 -0
  659. package/server/tests/Unit/Support/OperationalQueryDistributionTest.php +152 -0
  660. package/server/tests/Unit/Support/OperationsPulseTest.php +130 -0
  661. package/server/tests/Unit/Support/OptimizeOrderRouteCapabilityTest.php +147 -0
  662. package/server/tests/Unit/Support/OrchestrationPayloadBuilderTest.php +135 -0
  663. package/server/tests/Unit/Support/OrchestrationRouteAndVehicleTasksTest.php +254 -0
  664. package/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php +247 -0
  665. package/server/tests/Unit/Support/PetroAppFuelProviderTest.php +165 -0
  666. package/server/tests/Unit/Support/PlaceSearchRankingTest.php +191 -0
  667. package/server/tests/Unit/Support/QuerySeamsTest.php +309 -0
  668. package/server/tests/Unit/Support/ResolvesOrderServiceStopsDbTest.php +303 -0
  669. package/server/tests/Unit/Support/ResolvesOrderServiceStopsTest.php +382 -0
  670. package/server/tests/Unit/Support/SkipAndFallbackGuardsTest.php +70 -0
  671. package/server/tests/Unit/Support/TelematicCredentialResolutionTest.php +31 -0
  672. package/server/tests/Unit/Support/TelematicServiceTest.php +256 -0
  673. package/server/tests/Unit/Support/Telematics/Providers/AbstractProviderTest.php +154 -0
  674. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTest.php +191 -0
  675. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +138 -0
  676. package/server/tests/Unit/Support/Telematics/Providers/GeotabProviderTest.php +329 -0
  677. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +133 -0
  678. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +487 -0
  679. package/server/tests/Unit/Support/Telematics/TelematicProviderRegistryTest.php +39 -0
  680. package/server/tests/Unit/Support/Telematics/TelematicServiceLifecycleTest.php +711 -0
  681. package/server/tests/Unit/Support/Telematics/TelematicServiceTelemetryTest.php +364 -0
  682. package/server/tests/Unit/Support/UtilsAdditionalTest.php +256 -0
  683. package/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php +382 -0
  684. package/server/tests/Unit/Tracking/TrackingContextTest.php +114 -0
  685. package/server/tests/Unit/Tracking/TrackingIntelligenceAndContextTest.php +477 -0
  686. package/server/tests/Unit/Tracking/TrackingProviderManagerFallbacksTest.php +208 -0
  687. package/server/tests/Unit/Traits/HasTrackingNumberTest.php +303 -0
  688. package/server/tests/Unit/Traits/PayloadAccessorsTest.php +150 -0
  689. package/server/tests/UtilsContractsTest.php +244 -0
  690. package/server/tests/VehicleControllerHelperContractsTest.php +488 -0
  691. package/server/tests/VehicleDataParsingTest.php +45 -0
  692. package/server/tests/VehicleResourceContractsTest.php +313 -0
  693. package/translations/bg-bg.yaml +2 -0
  694. package/translations/en-us.yaml +2 -0
  695. package/translations/es-pa.yaml +2 -0
  696. package/translations/fr-fr.yaml +2 -0
  697. package/translations/mn-mn.yaml +2 -0
  698. package/translations/pt-br.yaml +2 -0
  699. package/translations/ru-ru.yaml +2 -0
  700. package/server/tests/DeviceFilterTest.php +0 -44
  701. package/server/tests/LiveControllerViewportTest.php +0 -52
@@ -0,0 +1,3201 @@
1
+ <?php
2
+
3
+ if (!class_exists('Illuminate\Foundation\Auth\User')) {
4
+ eval('namespace Illuminate\Foundation\Auth; class User extends \Illuminate\Database\Eloquent\Model {}');
5
+ }
6
+
7
+ if (!function_exists('Fleetbase\Traits\config')) {
8
+ eval('namespace Fleetbase\Traits; function config($key = null, $default = null) { return $key === "api.cache.enabled" ? false : $default; }');
9
+ }
10
+
11
+ if (!function_exists('Fleetbase\Models\config')) {
12
+ eval('namespace Fleetbase\Models; function config($key = null, $default = null) { return $key === "fleetbase.connection.db" ? "mysql" : $default; }');
13
+ }
14
+
15
+ if (!function_exists('Fleetbase\FleetOps\Models\now')) {
16
+ eval('namespace Fleetbase\FleetOps\Models; function now() { return \Illuminate\Support\Carbon::now(); }');
17
+ }
18
+
19
+ if (!function_exists('Fleetbase\FleetOps\Models\activity')) {
20
+ eval('namespace Fleetbase\FleetOps\Models; function activity($logName = null) { return new class($logName) { public array $properties = []; public function __construct(public $logName) {} public function performedOn($subject) { return $this; } public function withProperties(array $properties) { $this->properties = $properties; return $this; } public function log(string $message) { return true; } }; }');
21
+ }
22
+
23
+ use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
24
+ use Fleetbase\FleetOps\Flow\Activity;
25
+ use Fleetbase\FleetOps\Models\Contact;
26
+ use Fleetbase\FleetOps\Models\Device;
27
+ use Fleetbase\FleetOps\Models\Driver;
28
+ use Fleetbase\FleetOps\Models\Entity;
29
+ use Fleetbase\FleetOps\Models\Fleet;
30
+ use Fleetbase\FleetOps\Models\FuelProviderTransaction;
31
+ use Fleetbase\FleetOps\Models\FuelReport;
32
+ use Fleetbase\FleetOps\Models\IntegratedVendor;
33
+ use Fleetbase\FleetOps\Models\Issue;
34
+ use Fleetbase\FleetOps\Models\Maintenance;
35
+ use Fleetbase\FleetOps\Models\Manifest;
36
+ use Fleetbase\FleetOps\Models\ManifestStop;
37
+ use Fleetbase\FleetOps\Models\Order;
38
+ use Fleetbase\FleetOps\Models\OrderConfig;
39
+ use Fleetbase\FleetOps\Models\Payload;
40
+ use Fleetbase\FleetOps\Models\Place;
41
+ use Fleetbase\FleetOps\Models\Position;
42
+ use Fleetbase\FleetOps\Models\PurchaseRate;
43
+ use Fleetbase\FleetOps\Models\Route;
44
+ use Fleetbase\FleetOps\Models\ServiceArea;
45
+ use Fleetbase\FleetOps\Models\ServiceQuote;
46
+ use Fleetbase\FleetOps\Models\ServiceRate;
47
+ use Fleetbase\FleetOps\Models\ServiceRateFee;
48
+ use Fleetbase\FleetOps\Models\Telematic;
49
+ use Fleetbase\FleetOps\Models\TrackingNumber;
50
+ use Fleetbase\FleetOps\Models\TrackingStatus;
51
+ use Fleetbase\FleetOps\Models\Vehicle;
52
+ use Fleetbase\FleetOps\Models\VehicleDevice;
53
+ use Fleetbase\FleetOps\Models\Vendor;
54
+ use Fleetbase\FleetOps\Models\Warranty;
55
+ use Fleetbase\FleetOps\Models\Waypoint;
56
+ use Fleetbase\FleetOps\Models\WorkOrder;
57
+ use Fleetbase\FleetOps\Models\Zone;
58
+ use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
59
+ use Fleetbase\FleetOps\Traits\PayloadAccessors;
60
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
61
+ use Illuminate\Database\ConnectionResolver;
62
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
63
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
64
+ use Illuminate\Database\Eloquent\Relations\HasMany;
65
+ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
66
+ use Illuminate\Database\Eloquent\Relations\MorphMany;
67
+ use Illuminate\Database\Eloquent\Relations\MorphOne;
68
+ use Illuminate\Database\Eloquent\Relations\MorphTo;
69
+ use Illuminate\Database\Eloquent\Relations\Relation;
70
+ use Illuminate\Database\SQLiteConnection;
71
+ use Illuminate\Http\Request;
72
+ use Illuminate\Support\Carbon;
73
+ use Illuminate\Support\Collection;
74
+
75
+ function fleetopsModelAccessorsUseInMemoryRelationConnection(): void
76
+ {
77
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
78
+ $resolver = new ConnectionResolver([
79
+ 'default' => $connection,
80
+ 'mysql' => $connection,
81
+ ]);
82
+
83
+ $resolver->setDefaultConnection('mysql');
84
+ EloquentModel::setConnectionResolver($resolver);
85
+ }
86
+
87
+ class FleetOpsCountingRelationFake
88
+ {
89
+ public function __construct(private int $total, private int $online)
90
+ {
91
+ }
92
+
93
+ public function where(string $column, mixed $value): self
94
+ {
95
+ expect($column)->toBe('online')
96
+ ->and($value)->toBe(1);
97
+
98
+ return new self($this->online, $this->online);
99
+ }
100
+
101
+ public function count(): int
102
+ {
103
+ return $this->total;
104
+ }
105
+ }
106
+
107
+ class FleetOpsContactImportSaveFake extends Contact
108
+ {
109
+ public bool $saved = false;
110
+
111
+ public function save(array $options = []): bool
112
+ {
113
+ $this->saved = true;
114
+
115
+ return true;
116
+ }
117
+ }
118
+
119
+ class FleetOpsContactSyncUserFake extends Fleetbase\Models\User
120
+ {
121
+ public array $updates = [];
122
+ public bool $deleted = false;
123
+
124
+ public function update(array $attributes = [], array $options = []): bool
125
+ {
126
+ $this->updates[] = $attributes;
127
+ $this->setRawAttributes(array_merge($this->getAttributes(), $attributes), true);
128
+
129
+ return true;
130
+ }
131
+
132
+ public function delete()
133
+ {
134
+ $this->deleted = true;
135
+
136
+ return true;
137
+ }
138
+ }
139
+
140
+ class FleetOpsContactAccessorFake extends Contact
141
+ {
142
+ public array $loadedMissing = [];
143
+ public ?FleetOpsContactSyncUserFake $fakeUser = null;
144
+
145
+ public function loadMissing($relations)
146
+ {
147
+ $this->loadedMissing[] = $relations;
148
+
149
+ return $this;
150
+ }
151
+
152
+ public function getUser(): ?Fleetbase\Models\User
153
+ {
154
+ return $this->fakeUser;
155
+ }
156
+
157
+ public function update(array $attributes = [], array $options = []): bool
158
+ {
159
+ $this->setRawAttributes(array_merge($this->getAttributes(), $attributes), true);
160
+
161
+ return true;
162
+ }
163
+ }
164
+
165
+ class FleetOpsDriverAccessorFake extends Driver
166
+ {
167
+ public array $loadedMissing = [];
168
+ public array $loaded = [];
169
+ public array $updates = [];
170
+ public bool $saved = false;
171
+
172
+ public function loadMissing($relations)
173
+ {
174
+ $this->loadedMissing[] = $relations;
175
+
176
+ return $this;
177
+ }
178
+
179
+ public function load($relations)
180
+ {
181
+ $this->loaded[] = $relations;
182
+
183
+ return $this;
184
+ }
185
+
186
+ public function update(array $attributes = [], array $options = []): bool
187
+ {
188
+ $this->updates[] = $attributes;
189
+ $this->setRawAttributes(array_merge($this->getAttributes(), $attributes), true);
190
+
191
+ return true;
192
+ }
193
+
194
+ public function save(array $options = []): bool
195
+ {
196
+ $this->saved = true;
197
+
198
+ return true;
199
+ }
200
+ }
201
+
202
+ class FleetOpsDriverOrderAccessorFake extends Order
203
+ {
204
+ public array $loadedMissing = [];
205
+
206
+ public function loadMissing($relations)
207
+ {
208
+ $this->loadedMissing[] = $relations;
209
+
210
+ return $this;
211
+ }
212
+ }
213
+
214
+ class FleetOpsFleetAccessorFake extends Fleet
215
+ {
216
+ public function drivers()
217
+ {
218
+ return new FleetOpsCountingRelationFake(5, 2);
219
+ }
220
+
221
+ public function vehicles()
222
+ {
223
+ return new FleetOpsCountingRelationFake(7, 3);
224
+ }
225
+ }
226
+
227
+ class FleetOpsTelematicQueryFake
228
+ {
229
+ public array $calls = [];
230
+
231
+ public function where(...$arguments): self
232
+ {
233
+ $this->calls[] = ['where', $arguments];
234
+
235
+ return $this;
236
+ }
237
+
238
+ public function whereNull(...$arguments): self
239
+ {
240
+ $this->calls[] = ['whereNull', $arguments];
241
+
242
+ return $this;
243
+ }
244
+
245
+ public function orWhere(...$arguments): self
246
+ {
247
+ $this->calls[] = ['orWhere', $arguments];
248
+
249
+ return $this;
250
+ }
251
+ }
252
+
253
+ class FleetOpsTelematicUpdatingFake extends Telematic
254
+ {
255
+ public array $updated = [];
256
+
257
+ public function update(array $attributes = [], array $options = [])
258
+ {
259
+ $this->updated = $attributes;
260
+ $this->forceFill($attributes);
261
+
262
+ return true;
263
+ }
264
+ }
265
+
266
+ class FleetOpsModelAccessorTelematicRegistryFake
267
+ {
268
+ public function __construct(private ?object $descriptor)
269
+ {
270
+ }
271
+
272
+ public function findByKey(?string $key): ?object
273
+ {
274
+ if (!$key) {
275
+ return null;
276
+ }
277
+
278
+ expect($key)->toBe('safee');
279
+
280
+ return $this->descriptor;
281
+ }
282
+ }
283
+
284
+ class FleetOpsUpdatingMaintenanceFake extends Maintenance
285
+ {
286
+ public array $updates = [];
287
+ public array $dateAttributes = [];
288
+
289
+ public function getAttribute($key)
290
+ {
291
+ if (array_key_exists($key, $this->dateAttributes)) {
292
+ return $this->dateAttributes[$key] ? Carbon::parse($this->dateAttributes[$key]) : null;
293
+ }
294
+
295
+ return parent::getAttribute($key);
296
+ }
297
+
298
+ public function update(array $attributes = [], array $options = []): bool
299
+ {
300
+ $this->updates[] = $attributes;
301
+
302
+ return false;
303
+ }
304
+ }
305
+
306
+ class FleetOpsUpdatingDeviceFake extends Device
307
+ {
308
+ public array $updates = [];
309
+ public ?Carbon $lastOnlineAtFake = null;
310
+
311
+ public function getAttribute($key)
312
+ {
313
+ if ($key === 'last_online_at') {
314
+ return $this->lastOnlineAtFake;
315
+ }
316
+
317
+ return parent::getAttribute($key);
318
+ }
319
+
320
+ public function update(array $attributes = [], array $options = []): bool
321
+ {
322
+ $this->updates[] = $attributes;
323
+ $this->forceFill($attributes);
324
+
325
+ return true;
326
+ }
327
+ }
328
+
329
+ class FleetOpsDeviceTelematicCommandFake extends Telematic
330
+ {
331
+ public array $commands = [];
332
+
333
+ public function sendCommand(string $command, array $parameters = []): bool
334
+ {
335
+ $this->commands[] = [$command, $parameters];
336
+
337
+ return $command !== 'fail';
338
+ }
339
+ }
340
+
341
+ class FleetOpsDeviceRecentEventsRelationFake
342
+ {
343
+ public array $calls = [];
344
+
345
+ public function orderBy(string $column, string $direction): self
346
+ {
347
+ $this->calls[] = ['orderBy', $column, $direction];
348
+
349
+ return $this;
350
+ }
351
+
352
+ public function limit(int $limit): self
353
+ {
354
+ $this->calls[] = ['limit', $limit];
355
+
356
+ return $this;
357
+ }
358
+
359
+ public function get(): Collection
360
+ {
361
+ $this->calls[] = ['get'];
362
+
363
+ return collect(['event-a', 'event-b']);
364
+ }
365
+ }
366
+
367
+ class FleetOpsDeviceRecentEventsFake extends FleetOpsUpdatingDeviceFake
368
+ {
369
+ public FleetOpsDeviceRecentEventsRelationFake $eventsRelation;
370
+
371
+ public function __construct(array $attributes = [])
372
+ {
373
+ parent::__construct($attributes);
374
+
375
+ $this->eventsRelation = new FleetOpsDeviceRecentEventsRelationFake();
376
+ }
377
+
378
+ protected function recentEventsQuery(): FleetOpsDeviceRecentEventsRelationFake
379
+ {
380
+ return $this->eventsRelation;
381
+ }
382
+ }
383
+
384
+ class FleetOpsLoadedVehicleDeviceFake extends VehicleDevice
385
+ {
386
+ public function load($relations)
387
+ {
388
+ return $this;
389
+ }
390
+ }
391
+
392
+ class FleetOpsTrackingNumberAccessorFake extends TrackingNumber
393
+ {
394
+ public array $loaded = [];
395
+
396
+ public function load($relations)
397
+ {
398
+ $this->loaded[] = $relations;
399
+
400
+ return $this;
401
+ }
402
+
403
+ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): bool
404
+ {
405
+ return true;
406
+ }
407
+ }
408
+
409
+ class FleetOpsTrackingNumberOwnerFake extends Fleetbase\Models\Model
410
+ {
411
+ protected $fillable = ['status'];
412
+ protected $table = 'orders';
413
+ public bool $saved = false;
414
+
415
+ public function save(array $options = []): bool
416
+ {
417
+ $this->saved = true;
418
+
419
+ return true;
420
+ }
421
+ }
422
+
423
+ class FleetOpsSavingOrderFake extends Order
424
+ {
425
+ public bool $saved = false;
426
+ public array $loaded = [];
427
+ public array $loadedMissing = [];
428
+ public array $quietUpdates = [];
429
+ public array $cacheValues = [];
430
+
431
+ public function getDateFormat()
432
+ {
433
+ return 'Y-m-d H:i:s';
434
+ }
435
+
436
+ public function load($relations)
437
+ {
438
+ $this->loaded[] = $relations;
439
+
440
+ return $this;
441
+ }
442
+
443
+ public function loadMissing($relations)
444
+ {
445
+ $this->loadedMissing[] = $relations;
446
+
447
+ return $this;
448
+ }
449
+
450
+ public function fromCache($key, $default = null, $ttl = null)
451
+ {
452
+ return $this->cacheValues[$key] ?? $default;
453
+ }
454
+
455
+ public function save(array $options = []): bool
456
+ {
457
+ $this->saved = true;
458
+
459
+ return true;
460
+ }
461
+
462
+ public function updateQuietly(array $attributes = [], array $options = []): bool
463
+ {
464
+ $this->quietUpdates[] = $attributes;
465
+ $this->setRawAttributes(array_merge($this->getAttributes(), $attributes), true);
466
+
467
+ return true;
468
+ }
469
+ }
470
+
471
+ class FleetOpsActivityOrderConfigFake extends OrderConfig
472
+ {
473
+ public ?Activity $next = null;
474
+ public array $flow = [];
475
+
476
+ public function setOrderContext(Order $order): self
477
+ {
478
+ return $this;
479
+ }
480
+
481
+ public function activities(): Collection
482
+ {
483
+ return collect($this->flow);
484
+ }
485
+
486
+ public function nextActivity(Order|Waypoint|null $context = null): Collection
487
+ {
488
+ return $this->next ? collect([$this->next]) : collect();
489
+ }
490
+ }
491
+
492
+ class FleetOpsActivityOrderFake extends FleetOpsSavingOrderFake
493
+ {
494
+ public ?FleetOpsActivityOrderConfigFake $fakeConfig = null;
495
+ public array $insertedActivities = [];
496
+ public array $statuses = [];
497
+ public int $dispatches = 0;
498
+
499
+ public function config(): ?OrderConfig
500
+ {
501
+ return $this->fakeConfig;
502
+ }
503
+
504
+ public function insertActivity(Activity $activity, $location = [], $proof = null): string
505
+ {
506
+ $this->insertedActivities[] = [$activity->code, $location, $proof];
507
+
508
+ return 'tracking-status-public';
509
+ }
510
+
511
+ public function setStatus(?string $status, $andSave = true)
512
+ {
513
+ $this->statuses[] = [$status, $andSave];
514
+ $this->status = $status;
515
+
516
+ return $this;
517
+ }
518
+
519
+ public function dispatch(bool $save = true): self
520
+ {
521
+ $this->dispatches++;
522
+ $this->dispatched = true;
523
+
524
+ return $this;
525
+ }
526
+ }
527
+
528
+ class FleetOpsOrderPayloadPositionFake extends Payload
529
+ {
530
+ public array $pickupAssignments = [];
531
+ public ?Place $origin = null;
532
+ public ?Place $destination = null;
533
+ public bool $pickupFromDriver = false;
534
+
535
+ public function hasMeta($keys): bool
536
+ {
537
+ return $keys === 'pickup_is_driver_location' && $this->pickupFromDriver;
538
+ }
539
+
540
+ public function load($relations)
541
+ {
542
+ return $this;
543
+ }
544
+
545
+ public function loadMissing($relations)
546
+ {
547
+ return $this;
548
+ }
549
+
550
+ public function setPickup($location = null, $options = [])
551
+ {
552
+ $this->pickupAssignments[] = [$location, $options];
553
+
554
+ return $this;
555
+ }
556
+
557
+ public function getPickupOrCurrentWaypoint(): ?Place
558
+ {
559
+ return $this->origin;
560
+ }
561
+
562
+ public function getDropoffOrLastWaypoint(): ?Place
563
+ {
564
+ return $this->destination;
565
+ }
566
+ }
567
+
568
+ class FleetOpsUpdatingManifestFake extends Manifest
569
+ {
570
+ public array $updates = [];
571
+
572
+ public function getDateFormat()
573
+ {
574
+ return 'Y-m-d H:i:s';
575
+ }
576
+
577
+ public function update(array $attributes = [], array $options = []): bool
578
+ {
579
+ $this->updates[] = $attributes;
580
+ $this->forceFill($attributes);
581
+
582
+ return true;
583
+ }
584
+ }
585
+
586
+ class FleetOpsUpdatingManifestStopFake extends ManifestStop
587
+ {
588
+ public array $updates = [];
589
+ public ?string $status = null;
590
+ public ?Carbon $actual_arrival = null;
591
+
592
+ public function update(array $attributes = [], array $options = []): bool
593
+ {
594
+ $this->updates[] = $attributes;
595
+
596
+ foreach ($attributes as $key => $value) {
597
+ if (property_exists($this, $key)) {
598
+ $this->{$key} = $value;
599
+ }
600
+ }
601
+
602
+ return true;
603
+ }
604
+ }
605
+
606
+ class FleetOpsManifestStopManifestFake extends Manifest
607
+ {
608
+ public int $autoCompleteChecks = 0;
609
+
610
+ public function checkAndAutoComplete(): void
611
+ {
612
+ $this->autoCompleteChecks++;
613
+ }
614
+ }
615
+
616
+ class FleetOpsManifestScopeQueryFake
617
+ {
618
+ public array $calls = [];
619
+
620
+ public function where(string $column, mixed $operator = null, mixed $value = null): self
621
+ {
622
+ $this->calls[] = func_num_args() === 2
623
+ ? ['where', $column, $operator]
624
+ : ['where', $column, $operator, $value];
625
+
626
+ return $this;
627
+ }
628
+
629
+ public function whereIn(string $column, array $values): self
630
+ {
631
+ $this->calls[] = ['whereIn', $column, $values];
632
+
633
+ return $this;
634
+ }
635
+
636
+ public function whereNotIn(string $column, array $values): self
637
+ {
638
+ $this->calls[] = ['whereNotIn', $column, $values];
639
+
640
+ return $this;
641
+ }
642
+ }
643
+
644
+ class FleetOpsPayloadAccessorHostFake extends EloquentModel
645
+ {
646
+ use PayloadAccessors;
647
+
648
+ protected $guarded = [];
649
+
650
+ public function payload(): BelongsTo
651
+ {
652
+ throw new RuntimeException('payload relation query should not be executed for loaded relation access');
653
+ }
654
+ }
655
+
656
+ class FleetOpsLoadedPayloadFake extends Payload
657
+ {
658
+ public ?string $uuidFake = null;
659
+
660
+ public function getAttribute($key)
661
+ {
662
+ if ($key === 'uuid' && $this->uuidFake !== null) {
663
+ return $this->uuidFake;
664
+ }
665
+
666
+ return parent::getAttribute($key);
667
+ }
668
+
669
+ public function load($relations)
670
+ {
671
+ return $this;
672
+ }
673
+
674
+ public function loadMissing($relations)
675
+ {
676
+ return $this;
677
+ }
678
+ }
679
+
680
+ class FleetOpsPlainPlaceFake extends Place
681
+ {
682
+ public function getAddressAttribute()
683
+ {
684
+ return $this->attributes['address'] ?? $this->attributes['name'] ?? $this->attributes['street1'] ?? null;
685
+ }
686
+
687
+ public function getAddressHtmlAttribute()
688
+ {
689
+ return $this->getAddressAttribute();
690
+ }
691
+
692
+ public function getCountryDataAttribute(): array
693
+ {
694
+ return [];
695
+ }
696
+ }
697
+
698
+ class FleetOpsHydratablePlaceFake extends Place
699
+ {
700
+ public bool $saved = false;
701
+
702
+ public function save(array $options = []): bool
703
+ {
704
+ $this->saved = true;
705
+
706
+ return true;
707
+ }
708
+ }
709
+
710
+ class FleetOpsImportablePlaceFake extends Place
711
+ {
712
+ public static ?string $lastGeocodedAddress = null;
713
+ public bool $saved = false;
714
+
715
+ public static function createFromGeocodingLookup(string $address, $saveInstance = false): ?Place
716
+ {
717
+ static::$lastGeocodedAddress = $address;
718
+
719
+ return new static([
720
+ 'name' => 'Geocoded Place',
721
+ 'street1' => null,
722
+ 'location' => null,
723
+ ]);
724
+ }
725
+
726
+ public function save(array $options = []): bool
727
+ {
728
+ $this->saved = true;
729
+
730
+ return true;
731
+ }
732
+ }
733
+
734
+ class FleetOpsLoadedServiceRateFake extends ServiceRate
735
+ {
736
+ public function load($relations)
737
+ {
738
+ return $this;
739
+ }
740
+
741
+ public function loadMissing($relations)
742
+ {
743
+ return $this;
744
+ }
745
+ }
746
+
747
+ class FleetOpsUpdatingWorkOrderFake extends WorkOrder
748
+ {
749
+ public array $updates = [];
750
+
751
+ public function __construct(array $attributes = [])
752
+ {
753
+ parent::__construct();
754
+
755
+ if ($attributes) {
756
+ $this->setRawAttributes($attributes, true);
757
+ }
758
+ }
759
+
760
+ public function getAttribute($key)
761
+ {
762
+ if (in_array($key, ['checklist', 'meta'], true)) {
763
+ return $this->attributes[$key] ?? null;
764
+ }
765
+
766
+ if (in_array($key, ['opened_at', 'due_at', 'closed_at'], true)) {
767
+ return isset($this->attributes[$key]) ? Carbon::parse($this->attributes[$key]) : null;
768
+ }
769
+
770
+ return parent::getAttribute($key);
771
+ }
772
+
773
+ public function update(array $attributes = [], array $options = []): bool
774
+ {
775
+ $this->updates[] = $attributes;
776
+ $this->setRawAttributes(array_merge($this->getAttributes(), $attributes), true);
777
+
778
+ return true;
779
+ }
780
+ }
781
+
782
+ test('contact accessors imports notifications and customer identity helpers are stable', function () {
783
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
784
+
785
+ $contact = new Contact([
786
+ 'name' => 'Jane Contact',
787
+ 'email' => 'jane@example.com',
788
+ 'phone' => '+1 (555) 111-2222',
789
+ 'type' => 'customer',
790
+ ]);
791
+
792
+ $contact->setRelation('devices', new Collection([
793
+ (object) ['platform' => 'android', 'token' => 'android-token'],
794
+ (object) ['platform' => 'ios', 'token' => 'ios-token'],
795
+ (object) ['platform' => 'web', 'token' => 'web-token'],
796
+ ]));
797
+ $contact->setRelation('photo', (object) ['url' => 'https://cdn.example/avatar.png']);
798
+
799
+ expect($contact->isCustomer())->toBeTrue()
800
+ ->and($contact->is_customer)->toBeTrue()
801
+ ->and($contact->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class)
802
+ ->and($contact->getSlugOptions())->toBeInstanceOf(Spatie\Sluggable\SlugOptions::class)
803
+ ->and($contact->company())->toBeInstanceOf(BelongsTo::class)
804
+ ->and($contact->anyUser())->toBeInstanceOf(BelongsTo::class)
805
+ ->and($contact->user())->toBeInstanceOf(BelongsTo::class)
806
+ ->and($contact->photo())->toBeInstanceOf(BelongsTo::class)
807
+ ->and($contact->place())->toBeInstanceOf(BelongsTo::class)
808
+ ->and($contact->devices())->toBeInstanceOf(HasMany::class)
809
+ ->and($contact->places())->toBeInstanceOf(HasMany::class)
810
+ ->and($contact->facilitatorOrders())->toBeInstanceOf(HasMany::class)
811
+ ->and($contact->customerOrders())->toBeInstanceOf(HasMany::class)
812
+ ->and($contact->files())->toBeInstanceOf(HasMany::class)
813
+ ->and($contact->routeNotificationForFcm())->toBe(['android-token'])
814
+ ->and($contact->routeNotificationForApn())->toBe([1 => 'ios-token'])
815
+ ->and($contact->routeNotificationForTwilio())->toContain('555')
816
+ ->and($contact->photo_url)->toBe('https://cdn.example/avatar.png');
817
+
818
+ $imported = Contact::createFromImport([
819
+ 'full_name' => 'Imported Person',
820
+ 'mobile_number' => '+1 555 333 4444',
821
+ 'email_address' => 'imported@example.com',
822
+ ]);
823
+
824
+ expect($imported)->toBeInstanceOf(Contact::class)
825
+ ->and($imported->name)->toBe('Imported Person')
826
+ ->and($imported->email)->toBe('imported@example.com')
827
+ ->and($imported->type)->toBe('contact');
828
+
829
+ $savedImport = FleetOpsContactImportSaveFake::createFromImport([
830
+ 'person' => 'Saved Import',
831
+ 'telephone' => '555-777-8888',
832
+ ], true);
833
+
834
+ $noPhoto = new Contact();
835
+ $noPhoto->setRelation('photo', null);
836
+
837
+ $staffUser = new Fleetbase\Models\User(['email' => 'staff@example.com', 'type' => 'admin']);
838
+ $staffUser->phone = null;
839
+
840
+ expect(fn () => $contact->assertCustomerUserCanBeAssigned($staffUser))
841
+ ->toThrow(CustomerUserConflictException::class, 'existing staff user')
842
+ ->and(Contact::customerUserConflictMessage($staffUser))->toContain('email')
843
+ ->and(Contact::customerUserConflictMessage(new Fleetbase\Models\User(['phone' => '+15550001111'])))->toContain('phone number')
844
+ ->and(Contact::customerUserConflictMessage())->toContain('user account')
845
+ ->and($savedImport)->toBeInstanceOf(FleetOpsContactImportSaveFake::class)
846
+ ->and($savedImport->saved)->toBeTrue()
847
+ ->and($savedImport->name)->toBe('Saved Import')
848
+ ->and($noPhoto->photo_url)->toBe('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png');
849
+
850
+ $customerUser = new Fleetbase\Models\User();
851
+ $customerUser->setRawAttributes(['type' => 'customer'], true);
852
+ expect($contact->assertCustomerUserCanBeAssigned($customerUser))->toBeNull();
853
+
854
+ $contactOnly = new Contact(['type' => 'contact']);
855
+ expect($contactOnly->isCustomer())->toBeFalse()
856
+ ->and($contactOnly->is_customer)->toBeFalse();
857
+ });
858
+
859
+ test('contact user sync lookup and deletion guards are stable', function () {
860
+ $user = new FleetOpsContactSyncUserFake();
861
+ $user->setRawAttributes([
862
+ 'uuid' => 'user-uuid',
863
+ 'type' => 'customer',
864
+ 'name' => 'Old Name',
865
+ 'email' => 'old@example.com',
866
+ 'phone' => '+15550000000',
867
+ ], true);
868
+
869
+ $contact = new FleetOpsContactAccessorFake();
870
+ $contact->fakeUser = $user;
871
+ $contact->setRawAttributes([
872
+ 'uuid' => 'contact-uuid',
873
+ 'user_uuid' => 'not-a-uuid',
874
+ 'type' => 'customer',
875
+ 'name' => 'Old Name',
876
+ 'email' => 'old@example.com',
877
+ 'phone' => '+15550000000',
878
+ 'timezone' => 'UTC',
879
+ ], true);
880
+
881
+ $contact->name = 'New Name';
882
+ $contact->email = 'new@example.com';
883
+ $contact->phone = '+15551112222';
884
+ $contact->timezone = 'Asia/Singapore';
885
+
886
+ expect($contact->syncWithUser())->toBeTrue()
887
+ ->and($user->updates[0])->toBe([
888
+ 'name' => 'New Name',
889
+ 'email' => 'new@example.com',
890
+ 'phone' => '+15551112222',
891
+ 'timezone' => 'Asia/Singapore',
892
+ ])
893
+ ->and($contact->getUser())->toBe($user)
894
+ ->and($contact->hasUser())->toBeTrue()
895
+ ->and($contact->doesntHaveUser())->toBeFalse();
896
+
897
+ $deleteContact = new FleetOpsContactAccessorFake();
898
+ $deleteContact->type = 'customer';
899
+ $deleteContact->setRelation('user', $user);
900
+
901
+ expect($deleteContact->deleteUser())->toBeTrue()
902
+ ->and($user->deleted)->toBeTrue()
903
+ ->and($deleteContact->loadedMissing)->toBe(['user']);
904
+
905
+ $mismatchedUser = new FleetOpsContactSyncUserFake();
906
+ $mismatchedUser->setRawAttributes(['type' => 'admin'], true);
907
+
908
+ $mismatchedContact = new FleetOpsContactAccessorFake();
909
+ $mismatchedContact->type = 'customer';
910
+ $mismatchedContact->setRelation('user', $mismatchedUser);
911
+
912
+ $emptyContact = new FleetOpsContactAccessorFake();
913
+ $emptyContact->fakeUser = null;
914
+ $emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true);
915
+
916
+ expect($mismatchedContact->deleteUser())->toBeFalse()
917
+ ->and($emptyContact->syncWithUser())->toBeFalse()
918
+ ->and($emptyContact->getUser())->toBeNull()
919
+ ->and($emptyContact->hasUser())->toBeFalse()
920
+ ->and($emptyContact->doesntHaveUser())->toBeTrue();
921
+ });
922
+
923
+ test('device accessors connection state configuration and command guards are stable', function () {
924
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
925
+ Carbon::setTestNow(Carbon::parse('2026-01-01 12:00:00'));
926
+
927
+ $device = new FleetOpsUpdatingDeviceFake([
928
+ 'uuid' => 'device-uuid',
929
+ 'company_uuid' => 'company-uuid',
930
+ 'options' => [
931
+ 'supported_features' => ['lock', 'reboot'],
932
+ 'sample_rate' => 30,
933
+ ],
934
+ ]);
935
+
936
+ $device->setRelation('photo', (object) ['url' => 'https://cdn.example/device.png']);
937
+ $device->setRelation('warranty', (object) ['name' => 'Extended warranty']);
938
+ $device->setRelation('telematic', null);
939
+ $device->setRelation('attachable', (object) ['display_name' => 'Truck 99']);
940
+
941
+ expect($device->photo_url)->toBe('https://cdn.example/device.png')
942
+ ->and($device->warranty_name)->toBe('Extended warranty')
943
+ ->and($device->telematic_name)->toBeNull()
944
+ ->and($device->attached_to_name)->toBe('Truck 99')
945
+ ->and($device->is_online)->toBeFalse()
946
+ ->and($device->connection_status)->toBe('never_connected')
947
+ ->and($device->supportsFeature('lock'))->toBeTrue()
948
+ ->and($device->supportsFeature('unlock'))->toBeFalse()
949
+ ->and($device->getConfiguration())->toMatchArray(['sample_rate' => 30])
950
+ ->and($device->sendCommand('reboot'))->toBeFalse();
951
+
952
+ $namedAttachable = new FleetOpsUpdatingDeviceFake();
953
+ $namedAttachable->setRelation('attachable', (object) ['name' => 'Trailer 42', 'display_name' => 'Display Trailer']);
954
+
955
+ $emptyAttachable = new FleetOpsUpdatingDeviceFake();
956
+ $emptyAttachable->setRelation('attachable', null);
957
+
958
+ expect((new Device())->photo_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png')
959
+ ->and($namedAttachable->attached_to_name)->toBe('Trailer 42')
960
+ ->and($emptyAttachable->attached_to_name)->toBeNull();
961
+
962
+ $device->lastOnlineAtFake = Carbon::parse('2026-01-01 11:55:00');
963
+ expect($device->is_online)->toBeTrue()
964
+ ->and($device->connection_status)->toBe('online');
965
+
966
+ $device->lastOnlineAtFake = Carbon::parse('2026-01-01 11:30:00');
967
+ expect($device->connection_status)->toBe('recently_offline');
968
+
969
+ $device->lastOnlineAtFake = Carbon::parse('2026-01-01 01:00:00');
970
+ expect($device->connection_status)->toBe('offline');
971
+
972
+ $device->lastOnlineAtFake = Carbon::parse('2025-12-30 11:00:00');
973
+ expect($device->connection_status)->toBe('long_offline')
974
+ ->and($device->updateConfiguration(['sample_rate' => 60, 'mode' => 'eco']))->toBeTrue()
975
+ ->and($device->updates)->toHaveCount(1)
976
+ ->and($device->updates[0]['options'])->toMatchArray(['sample_rate' => 60, 'mode' => 'eco']);
977
+
978
+ expect($device->updateLastOnline())->toBeTrue()
979
+ ->and($device->updates[1])->toHaveKey('last_online_at');
980
+
981
+ $vehicle = new Vehicle();
982
+ $vehicle->setRawAttributes([
983
+ 'uuid' => 'vehicle-uuid',
984
+ 'name' => 'Vehicle One',
985
+ ], true);
986
+
987
+ expect($device->attachTo($vehicle))->toBeTrue()
988
+ ->and($device->updates[2])->toMatchArray([
989
+ 'attachable_type' => Vehicle::class,
990
+ 'attachable_uuid' => 'vehicle-uuid',
991
+ ]);
992
+
993
+ $device->attachable_type = Vehicle::class;
994
+ $device->attachable_uuid = 'vehicle-uuid';
995
+
996
+ expect($device->detach())->toBeTrue()
997
+ ->and($device->updates[3])->toBe([
998
+ 'attachable_type' => null,
999
+ 'attachable_uuid' => null,
1000
+ ]);
1001
+
1002
+ $telematic = new FleetOpsDeviceTelematicCommandFake();
1003
+ $telematic->setRawAttributes(['uuid' => 'telematic-uuid'], true);
1004
+ $device->setRawAttributes(array_merge($device->getAttributes(), ['uuid' => 'device-uuid']), true);
1005
+ $device->setRelation('telematic', $telematic);
1006
+
1007
+ expect($device->sendCommand('lock', ['door' => 'rear']))->toBeTrue()
1008
+ ->and($telematic->commands)->toBe([
1009
+ ['lock', ['door' => 'rear', 'target_device' => 'device-uuid']],
1010
+ ]);
1011
+
1012
+ $recentEvents = new FleetOpsDeviceRecentEventsFake();
1013
+ expect($recentEvents->getRecentEvents(2)->all())->toBe(['event-a', 'event-b'])
1014
+ ->and($recentEvents->eventsRelation->calls)->toBe([
1015
+ ['orderBy', 'created_at', 'desc'],
1016
+ ['limit', 2],
1017
+ ['get'],
1018
+ ]);
1019
+
1020
+ $onlineSql = Device::query()->online()->toSql();
1021
+ $offlineSql = Device::query()->offline()->toSql();
1022
+ $attachedToSql = Device::query()->attachedTo(Vehicle::class)->toSql();
1023
+
1024
+ expect($onlineSql)->toContain('last_online_at')
1025
+ ->and($offlineSql)->toContain('last_online_at')
1026
+ ->and($attachedToSql)->toContain('attachable_type');
1027
+
1028
+ Carbon::setTestNow();
1029
+ });
1030
+
1031
+ test('device relationships expose fleet ops relation contracts', function () {
1032
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1033
+
1034
+ $device = new Device();
1035
+ $device->setRawAttributes([
1036
+ 'uuid' => 'device-uuid',
1037
+ 'telematic_uuid' => 'telematic-uuid',
1038
+ 'warranty_uuid' => 'warranty-uuid',
1039
+ 'created_by_uuid' => 'creator-uuid',
1040
+ 'updated_by_uuid' => 'updater-uuid',
1041
+ ], true);
1042
+
1043
+ $telematic = $device->telematic();
1044
+ $warranty = $device->warranty();
1045
+ $createdBy = $device->createdBy();
1046
+ $updatedBy = $device->updatedBy();
1047
+ $attachable = $device->attachable();
1048
+ $events = $device->events();
1049
+ $sensors = $device->sensors();
1050
+ $photo = $device->photo();
1051
+
1052
+ expect($device->getSlugOptions()->slugField)->toBe('slug')
1053
+ ->and($device->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class)
1054
+ ->and($telematic)->toBeInstanceOf(BelongsTo::class)
1055
+ ->and($telematic->getForeignKeyName())->toBe('telematic_uuid')
1056
+ ->and($telematic->getRelated())->toBeInstanceOf(Telematic::class)
1057
+ ->and($warranty)->toBeInstanceOf(BelongsTo::class)
1058
+ ->and($warranty->getForeignKeyName())->toBe('warranty_uuid')
1059
+ ->and($warranty->getRelated())->toBeInstanceOf(Warranty::class)
1060
+ ->and($createdBy)->toBeInstanceOf(BelongsTo::class)
1061
+ ->and($createdBy->getForeignKeyName())->toBe('created_by_uuid')
1062
+ ->and($updatedBy)->toBeInstanceOf(BelongsTo::class)
1063
+ ->and($updatedBy->getForeignKeyName())->toBe('updated_by_uuid')
1064
+ ->and($attachable)->toBeInstanceOf(MorphTo::class)
1065
+ ->and($attachable->getMorphType())->toBe('attachable_type')
1066
+ ->and($attachable->getForeignKeyName())->toBe('attachable_uuid')
1067
+ ->and($events)->toBeInstanceOf(HasMany::class)
1068
+ ->and($events->getForeignKeyName())->toBe('device_uuid')
1069
+ ->and($sensors)->toBeInstanceOf(HasMany::class)
1070
+ ->and($sensors->getForeignKeyName())->toBe('device_uuid')
1071
+ ->and($photo)->toBeInstanceOf(BelongsTo::class)
1072
+ ->and($photo->getRelated())->toBeInstanceOf(Fleetbase\Models\File::class);
1073
+ });
1074
+
1075
+ test('maintenance accessors lifecycle guards and import mapping are stable', function () {
1076
+ Carbon::setTestNow(Carbon::parse('2026-01-10 12:00:00'));
1077
+
1078
+ $maintenance = new FleetOpsUpdatingMaintenanceFake([
1079
+ 'status' => 'scheduled',
1080
+ 'labor_cost' => 1000,
1081
+ 'parts_cost' => 2500,
1082
+ 'tax' => 300,
1083
+ 'total_cost' => 3800,
1084
+ 'line_items' => [['label' => 'Oil']],
1085
+ 'attachments' => [],
1086
+ 'meta' => ['estimated_duration_hours' => 4],
1087
+ ]);
1088
+ $maintenance->dateAttributes = [
1089
+ 'scheduled_at' => '2026-01-09 09:00:00',
1090
+ 'started_at' => '2026-01-09 08:00:00',
1091
+ 'completed_at' => '2026-01-09 10:00:00',
1092
+ ];
1093
+
1094
+ $maintenance->setRelation('maintainable', (object) ['display_name' => 'Truck 12']);
1095
+ $maintenance->setRelation('performedBy', (object) ['name' => 'Mechanic One']);
1096
+ $maintenance->setRelation('workOrder', (object) ['subject' => 'Quarterly service']);
1097
+
1098
+ expect($maintenance->maintainable_name)->toBe('Truck 12')
1099
+ ->and($maintenance->performed_by_name)->toBe('Mechanic One')
1100
+ ->and($maintenance->work_order_subject)->toBe('Quarterly service')
1101
+ ->and($maintenance->duration_hours)->toBe(2.0)
1102
+ ->and($maintenance->is_overdue)->toBeTrue()
1103
+ ->and($maintenance->days_until_due)->toBeLessThan(0)
1104
+ ->and($maintenance->cost_breakdown)->toMatchArray(['subtotal' => 3500, 'total_cost' => 3800])
1105
+ ->and($maintenance->getEfficiencyRating())->toBe(100.0)
1106
+ ->and($maintenance->wasCompletedOnTime())->toBeFalse()
1107
+ ->and($maintenance->getCostPerHour())->toBe(1900.0);
1108
+
1109
+ expect($maintenance->start())->toBeFalse()
1110
+ ->and($maintenance->complete(['labor_cost' => 1200, 'parts_cost' => 500, 'tax' => 100, 'notes' => 'Done']))->toBeFalse()
1111
+ ->and($maintenance->cancel('Duplicate ticket'))->toBeFalse()
1112
+ ->and($maintenance->addLineItem(['label' => 'Filter']))->toBeFalse()
1113
+ ->and($maintenance->removeLineItem(10))->toBeFalse()
1114
+ ->and($maintenance->addAttachment('file_uuid', 'Invoice'))->toBeFalse()
1115
+ ->and($maintenance->updates)->not->toBeEmpty();
1116
+
1117
+ $completed = new FleetOpsUpdatingMaintenanceFake(['status' => 'completed']);
1118
+ $completed->dateAttributes = ['scheduled_at' => '2026-01-09'];
1119
+ expect($completed->is_overdue)->toBeFalse()
1120
+ ->and($completed->days_until_due)->toBeNull()
1121
+ ->and($completed->cancel())->toBeFalse();
1122
+
1123
+ $imported = Maintenance::createFromImport([
1124
+ 'maintenance_type' => 'corrective',
1125
+ 'priority' => 'high',
1126
+ 'description' => 'Replace pads',
1127
+ 'odometer_reading' => '12345',
1128
+ 'engine_hours' => '88',
1129
+ 'labor_cost' => 1000,
1130
+ 'parts_cost' => 2000,
1131
+ 'tax' => 100,
1132
+ 'total_cost' => 3100,
1133
+ 'currency' => 'sgd',
1134
+ ]);
1135
+
1136
+ expect($imported)->toBeInstanceOf(Maintenance::class)
1137
+ ->and($imported->type)->toBe('corrective')
1138
+ ->and($imported->priority)->toBe('high')
1139
+ ->and($imported->summary)->toBe('Replace pads')
1140
+ ->and($imported->currency)->toBe('SGD');
1141
+
1142
+ Carbon::setTestNow();
1143
+ });
1144
+
1145
+ test('waypoint model mirrors tracking number status accessors', function () {
1146
+ $waypoint = new Waypoint();
1147
+ $waypoint->setRelation('trackingNumber', (object) [
1148
+ 'tracking_number' => 'TRK-123',
1149
+ 'last_status' => 'Out for delivery',
1150
+ 'last_status_code' => 'out_for_delivery',
1151
+ 'last_status_complete' => false,
1152
+ ]);
1153
+
1154
+ expect($waypoint->getTrackingAttribute())->toBe('TRK-123')
1155
+ ->and($waypoint->getStatusAttribute())->toBe('Out for delivery')
1156
+ ->and($waypoint->getStatusCodeAttribute())->toBe('out_for_delivery')
1157
+ ->and($waypoint->getCompleteAttribute())->toBeFalse();
1158
+
1159
+ $emptyWaypoint = new Waypoint();
1160
+ $emptyWaypoint->setRelation('trackingNumber', null);
1161
+
1162
+ expect($emptyWaypoint->getTrackingAttribute())->toBeNull()
1163
+ ->and($emptyWaypoint->getStatusAttribute())->toBeNull()
1164
+ ->and($emptyWaypoint->getStatusCodeAttribute())->toBeNull()
1165
+ ->and($emptyWaypoint->getCompleteAttribute())->toBeNull();
1166
+ });
1167
+
1168
+ test('waypoint model exposes relationship contracts without resolving records', function () {
1169
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1170
+
1171
+ $waypoint = new Waypoint();
1172
+ $waypoint->setRawAttributes([
1173
+ 'uuid' => 'waypoint-uuid',
1174
+ 'payload_uuid' => 'payload-uuid',
1175
+ 'place_uuid' => 'place-uuid',
1176
+ ], true);
1177
+
1178
+ expect($waypoint->place())->toBeInstanceOf(BelongsTo::class)
1179
+ ->and($waypoint->trackingNumber())->toBeInstanceOf(BelongsTo::class)
1180
+ ->and($waypoint->payload())->toBeInstanceOf(BelongsTo::class)
1181
+ ->and($waypoint->company())->toBeInstanceOf(BelongsTo::class)
1182
+ ->and($waypoint->proofs())->toBeInstanceOf(HasMany::class)
1183
+ ->and($waypoint->entities())->toBeInstanceOf(HasMany::class)
1184
+ ->and($waypoint->customer())->toBeInstanceOf(MorphTo::class);
1185
+ });
1186
+
1187
+ test('waypoint model resolves loaded place and rejects unscoped place lookup', function () {
1188
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1189
+
1190
+ $place = new Place();
1191
+ $place->setRawAttributes(['uuid' => 'place-uuid'], true);
1192
+
1193
+ $waypoint = new Waypoint();
1194
+ $waypoint->setRawAttributes(['place_uuid' => 'place-uuid'], true);
1195
+ $waypoint->setRelation('place', $place);
1196
+
1197
+ expect($waypoint->getPlace())->toBe($place)
1198
+ ->and(fn () => Waypoint::findByPlace('place_public', new Order()))
1199
+ ->toThrow(InvalidArgumentException::class, 'Missing payload UUID for lookup.');
1200
+ });
1201
+
1202
+ test('service quote model exposes pure helpers and safe request resolution defaults', function () {
1203
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1204
+
1205
+ $quote = new ServiceQuote();
1206
+ $quote->setRawAttributes([
1207
+ 'public_id' => 'quote_public',
1208
+ 'amount' => 12345,
1209
+ 'currency' => 'SGD',
1210
+ 'integrated_vendor_uuid' => null,
1211
+ 'meta' => [],
1212
+ ], true);
1213
+ $quote->setRelation('serviceRate', (object) ['service_name' => 'Same Day']);
1214
+ $quote->setRelation('company', null);
1215
+
1216
+ $vendorQuote = new ServiceQuote();
1217
+ $vendorQuote->setRawAttributes([
1218
+ 'integrated_vendor_uuid' => 'integrated-vendor-uuid',
1219
+ 'meta' => [],
1220
+ ], true);
1221
+
1222
+ $metaVendorQuote = new ServiceQuote();
1223
+ $metaVendorQuote->setRawAttributes(['meta' => ['from_integrated_vendor' => true]], true);
1224
+
1225
+ $customNames = new ServiceQuote();
1226
+ $customNames->pluralName = 'offers';
1227
+ $customNames->singularName = 'offer';
1228
+
1229
+ $payloadKey = new ServiceQuote();
1230
+ $payloadKey->payloadKey = 'rate_quote';
1231
+
1232
+ expect($quote->getServiceRateNameAttribute())->toBe('Same Day')
1233
+ ->and($quote->items())->toBeInstanceOf(HasMany::class)
1234
+ ->and($quote->company())->toBeInstanceOf(BelongsTo::class)
1235
+ ->and($quote->serviceRate())->toBeInstanceOf(BelongsTo::class)
1236
+ ->and($quote->payload())->toBeInstanceOf(BelongsTo::class)
1237
+ ->and($quote->integratedVendor())->toBeInstanceOf(BelongsTo::class)
1238
+ ->and($quote->fromIntegratedVendor())->toBeFalse()
1239
+ ->and($vendorQuote->fromIntegratedVendor())->toBeTrue()
1240
+ ->and($metaVendorQuote->fromIntegratedVendor())->toBeTrue()
1241
+ ->and(ServiceQuote::resolveFromRequest(new class extends Request {
1242
+ public function or(array $keys, mixed $default = null): mixed
1243
+ {
1244
+ return $default;
1245
+ }
1246
+ }))->toBeNull()
1247
+ ->and(ServiceQuote::resolveFromRequest(new class extends Request {
1248
+ public function or(array $keys, mixed $default = null): mixed
1249
+ {
1250
+ return '';
1251
+ }
1252
+ }))->toBeNull()
1253
+ ->and($customNames->getPluralName())->toBe('offers')
1254
+ ->and($customNames->getSingularName())->toBe('offer')
1255
+ ->and($payloadKey->getPluralName())->toBe('rate_quotes')
1256
+ ->and($payloadKey->getSingularName())->toBe('rate_quote')
1257
+ ->and((new ServiceQuote())->getPluralName())->toBe('service_quotes')
1258
+ ->and((new ServiceQuote())->getSingularName())->toBe('service_quote');
1259
+
1260
+ expect(fn () => $quote->createStripeCheckoutSession('/checkout/return'))
1261
+ ->toThrow(Exception::class, 'company you attempted to purchase');
1262
+ });
1263
+
1264
+ test('issue model mutators accessors and import defaults are stable', function () {
1265
+ Carbon::setTestNow(Carbon::parse('2026-07-26 08:45:00'));
1266
+ session(['company' => 'company-issue']);
1267
+
1268
+ $issue = new Issue();
1269
+ $issue->title = null;
1270
+ $issue->status = 'In Progress';
1271
+ $issue->setRelation('driver', (object) ['name' => 'Driver One']);
1272
+ $issue->setRelation('vehicle', (object) ['display_name' => 'Truck 42', 'public_id' => 'vehicle_public']);
1273
+ $issue->setRelation('reporter', (object) ['name' => 'Reporter One', 'public_id' => 'reporter_public']);
1274
+ $issue->setRelation('assignee', (object) ['name' => 'Assignee One', 'public_id' => 'assignee_public']);
1275
+
1276
+ $blankStatus = new Issue();
1277
+ $blankStatus->status = '';
1278
+
1279
+ $trimmedTitle = new Issue();
1280
+ $trimmedTitle->title = ' Loose bumper ';
1281
+
1282
+ expect($issue->getAttributes()['title'])->toBe('Issue reported on 26 Jul 26, 08:45')
1283
+ ->and($issue->status)->toBe('in-progress')
1284
+ ->and($blankStatus->status)->toBe('pending')
1285
+ ->and($trimmedTitle->getAttributes()['title'])->toBe('Loose bumper')
1286
+ ->and($issue->driver_name)->toBe('Driver One')
1287
+ ->and($issue->vehicle_name)->toBe('Truck 42')
1288
+ ->and($issue->getVehicleIdAttribute())->toBe('vehicle_public')
1289
+ ->and($issue->reporter_name)->toBe('Reporter One')
1290
+ ->and($issue->getReporterIdAttribute())->toBe('reporter_public')
1291
+ ->and($issue->assignee_name)->toBe('Assignee One')
1292
+ ->and($issue->getAssigneeIdAttribute())->toBe('assignee_public')
1293
+ ->and($issue->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class);
1294
+
1295
+ Carbon::setTestNow();
1296
+ });
1297
+
1298
+ test('entity model value accessors money mutators and customer assignment are stable', function () {
1299
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1300
+
1301
+ $entity = new Entity([
1302
+ 'type' => ' Fragile Parcel ',
1303
+ 'price' => '$12.99',
1304
+ 'sale_price' => '10.50',
1305
+ 'declared_value' => 'SGD 42.75',
1306
+ 'length' => 10,
1307
+ 'width' => 20,
1308
+ 'height' => 30,
1309
+ 'dimensions_unit' => 'cm',
1310
+ 'weight' => 2500,
1311
+ 'weight_unit' => 'g',
1312
+ ]);
1313
+ $entity->setRelation('photo', (object) ['url' => 'https://cdn.test/entity.png']);
1314
+ $entity->setRelation('trackingNumber', (object) [
1315
+ 'tracking_number' => 'TRK-ENTITY',
1316
+ 'last_status' => 'Packed',
1317
+ ]);
1318
+
1319
+ $vendorCustomer = new Vendor();
1320
+ $vendorCustomer->setRawAttributes(['uuid' => 'vendor-uuid'], true);
1321
+
1322
+ $contactCustomer = new Contact();
1323
+ $contactCustomer->setRawAttributes(['uuid' => 'contact-uuid'], true);
1324
+
1325
+ $vendorEntity = new Entity();
1326
+ $vendorEntity->setCustomer($vendorCustomer);
1327
+
1328
+ $contactEntity = new Entity();
1329
+ $contactEntity->setCustomer($contactCustomer);
1330
+
1331
+ $defaultPhoto = new Entity();
1332
+ $defaultPhoto->setRelation('photo', null);
1333
+
1334
+ $pickup = new Place();
1335
+ $pickup->setRawAttributes(['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'place_pickup'], true);
1336
+
1337
+ $dropoff = new Place();
1338
+ $dropoff->setRawAttributes(['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'place_dropoff'], true);
1339
+
1340
+ $waypoint = new Place();
1341
+ $waypoint->setRawAttributes(['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'place_waypoint'], true);
1342
+
1343
+ $payload = new Payload();
1344
+ $payload->setRelation('pickup', $pickup);
1345
+ $payload->setRelation('dropoff', $dropoff);
1346
+ $payload->setRelation('waypoints', collect([$waypoint]));
1347
+
1348
+ $numericDestination = (new Entity())->setDestination(0, $payload, false);
1349
+ $pickupDestination = (new Entity())->setDestination('pickup', $payload, false);
1350
+ $dropoffDestination = (new Entity())->setDestination('dropoff', $payload, false);
1351
+ $publicDestination = (new Entity())->setDestination('place_waypoint', $payload, false);
1352
+ $uuidDestination = (new Entity())->setDestination('22222222-2222-4222-8222-222222222222', $payload, false);
1353
+
1354
+ expect($entity->type)->toBe('fragile_parcel')
1355
+ ->and($entity->photo())->toBeInstanceOf(BelongsTo::class)
1356
+ ->and($entity->files())->toBeInstanceOf(HasMany::class)
1357
+ ->and($entity->proofs())->toBeInstanceOf(HasMany::class)
1358
+ ->and($entity->destination())->toBeInstanceOf(BelongsTo::class)
1359
+ ->and($entity->payload())->toBeInstanceOf(BelongsTo::class)
1360
+ ->and($entity->supplier())->toBeInstanceOf(BelongsTo::class)
1361
+ ->and($entity->driver())->toBeInstanceOf(BelongsTo::class)
1362
+ ->and($entity->company())->toBeInstanceOf(BelongsTo::class)
1363
+ ->and($entity->trackingNumber())->toBeInstanceOf(BelongsTo::class)
1364
+ ->and($entity->customer())->toBeInstanceOf(MorphTo::class)
1365
+ ->and($entity->price)->toBe(1299)
1366
+ ->and($entity->sale_price)->toBe(1050)
1367
+ ->and($entity->declared_value)->toBe(4275)
1368
+ ->and($entity->photo_url)->toBe('https://cdn.test/entity.png')
1369
+ ->and($defaultPhoto->photo_url)->toBe('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/parcels/medium.png')
1370
+ ->and($entity->tracking)->toBe('TRK-ENTITY')
1371
+ ->and($entity->status)->toBe('Packed')
1372
+ ->and($entity->length_unit->getOriginalValue())->toBe(10)
1373
+ ->and($entity->width_unit->getOriginalValue())->toBe(20)
1374
+ ->and($entity->height_unit->getOriginalValue())->toBe(30)
1375
+ ->and($entity->mass_unit->getOriginalValue())->toBe(2500)
1376
+ ->and($vendorEntity->customer_uuid)->toBe('vendor-uuid')
1377
+ ->and($vendorEntity->customer_is_vendor)->toBeTrue()
1378
+ ->and($vendorEntity->customer_is_contact)->toBeFalse()
1379
+ ->and($contactEntity->customer_uuid)->toBe('contact-uuid')
1380
+ ->and($contactEntity->customer_is_contact)->toBeTrue()
1381
+ ->and($contactEntity->customer_is_vendor)->toBeFalse()
1382
+ ->and($numericDestination->destination_uuid)->toBe('33333333-3333-4333-8333-333333333333')
1383
+ ->and($pickupDestination->destination_uuid)->toBe('11111111-1111-4111-8111-111111111111')
1384
+ ->and($dropoffDestination->destination_uuid)->toBe('22222222-2222-4222-8222-222222222222')
1385
+ ->and($publicDestination->destination_uuid)->toBe('33333333-3333-4333-8333-333333333333')
1386
+ ->and($uuidDestination->destination_uuid)->toBe('22222222-2222-4222-8222-222222222222');
1387
+ });
1388
+
1389
+ test('route position and vehicle device accessors use loaded relation data', function () {
1390
+ $payload = new Payload(['public_id' => 'payload_public']);
1391
+ $driver = new Vehicle(['display_name' => 'Driver vehicle']);
1392
+ $order = (object) [
1393
+ 'status' => 'dispatched',
1394
+ 'public_id' => 'order_public',
1395
+ 'internal_id' => 'ORD-1',
1396
+ 'dispatched_at' => '2026-01-01 10:00:00',
1397
+ 'payload' => $payload,
1398
+ 'driverAssigned' => $driver,
1399
+ ];
1400
+
1401
+ $route = new Route();
1402
+ $route->setRelation('order', $order);
1403
+
1404
+ expect($route->payload)->toBe($payload)
1405
+ ->and($route->driver)->toBe($driver)
1406
+ ->and($route->order_status)->toBe('dispatched')
1407
+ ->and($route->order_public_id)->toBe('order_public')
1408
+ ->and($route->order_internal_id)->toBe('ORD-1')
1409
+ ->and($route->order_dispatched_at)->toBe('2026-01-01 10:00:00');
1410
+
1411
+ $position = new Position();
1412
+ expect($position->latitude)->toBe(0.0)
1413
+ ->and($position->longitude)->toBe(0.0);
1414
+
1415
+ $position->coordinates = new Point(1.3521, 103.8198);
1416
+ expect($position->latitude)->toBe(1.3521)
1417
+ ->and($position->longitude)->toBe(103.8198);
1418
+
1419
+ $vehicle = new Vehicle();
1420
+ $vehicle->setRawAttributes(['uuid' => 'vehicle-uuid'], true);
1421
+
1422
+ $device = new FleetOpsLoadedVehicleDeviceFake();
1423
+ $device->setRelation('vehicle', $vehicle);
1424
+
1425
+ $callbackVehicle = null;
1426
+ expect($device->getVehicle(function (Vehicle $resolved) use (&$callbackVehicle) {
1427
+ $callbackVehicle = $resolved;
1428
+ }))->toBe($vehicle)
1429
+ ->and($callbackVehicle)->toBe($vehicle);
1430
+ });
1431
+
1432
+ test('fuel report accessors mutators and meta helpers are stable', function () {
1433
+ $report = new FuelReport([
1434
+ 'amount' => '$45.67',
1435
+ 'meta' => [
1436
+ 'source' => 'provider',
1437
+ 'provider' => 'fuelx',
1438
+ 'fuel_provider_transaction_uuid' => 'transaction_uuid',
1439
+ ],
1440
+ ]);
1441
+
1442
+ $report->setRelation('driver', (object) ['name' => 'Driver One']);
1443
+ $report->setRelation('vehicle', (object) ['display_name' => 'Truck 8']);
1444
+ $report->setRelation('reportedBy', (object) ['name' => 'Dispatcher One']);
1445
+
1446
+ expect($report->amount)->toBe(4567)
1447
+ ->and($report->driver_name)->toBe('Driver One')
1448
+ ->and($report->vehicle_name)->toBe('Truck 8')
1449
+ ->and($report->reporter_name)->toBe('Dispatcher One')
1450
+ ->and($report->source)->toBe('provider')
1451
+ ->and($report->provider)->toBe('fuelx')
1452
+ ->and($report->fuel_provider_transaction_uuid)->toBe('transaction_uuid');
1453
+
1454
+ $container = app();
1455
+ $hadDbBinding = $container->bound('db');
1456
+ $originalDb = $hadDbBinding ? $container->make('db') : null;
1457
+
1458
+ try {
1459
+ $container->instance('db', new class {
1460
+ public function raw($value): Illuminate\Database\Query\Expression
1461
+ {
1462
+ return new Illuminate\Database\Query\Expression($value);
1463
+ }
1464
+ });
1465
+
1466
+ $imported = FuelReport::createFromImport([
1467
+ 'fuel_report' => 'Imported fill-up',
1468
+ 'usage' => '12034',
1469
+ 'cost' => '$88.90',
1470
+ 'amount_currency' => 'sgd',
1471
+ 'gas_volume' => '45.5',
1472
+ 'gas_unit' => 'gal',
1473
+ 'fuel_status' => 'approved',
1474
+ 'lat' => 1.3521,
1475
+ 'lng' => 103.8198,
1476
+ ]);
1477
+ } finally {
1478
+ if ($hadDbBinding) {
1479
+ $container->instance('db', $originalDb);
1480
+ } else {
1481
+ $container->forgetInstance('db');
1482
+ }
1483
+ }
1484
+
1485
+ expect($imported)->toBeInstanceOf(FuelReport::class)
1486
+ ->and($imported->company_uuid)->toBe(session('company'))
1487
+ ->and($imported->report)->toBe('Imported fill-up')
1488
+ ->and($imported->odometer)->toBe('12034')
1489
+ ->and($imported->amount)->toBe(8890)
1490
+ ->and($imported->currency)->toBe('sgd')
1491
+ ->and($imported->volume)->toBe('45.5')
1492
+ ->and($imported->metric_unit)->toBe('gal')
1493
+ ->and($imported->status)->toBe('approved')
1494
+ ->and($imported->location->getValue(new Illuminate\Database\Query\Grammars\Grammar()))->toContain('POINT');
1495
+ });
1496
+
1497
+ test('fuel provider transaction accessors expose related names and station points', function () {
1498
+ $transaction = new FuelProviderTransaction([
1499
+ 'station_latitude' => 1.3521,
1500
+ 'station_longitude' => 103.8198,
1501
+ ]);
1502
+ $transaction->setRelation('vehicle', (object) ['display_name' => 'Truck 8']);
1503
+ $transaction->setRelation('driver', (object) ['name' => 'Driver One']);
1504
+ $transaction->setRelation('fuelReport', (object) ['public_id' => 'fuel_report_123']);
1505
+
1506
+ expect($transaction->vehicle_name)->toBe('Truck 8')
1507
+ ->and($transaction->driver_name)->toBe('Driver One')
1508
+ ->and($transaction->fuel_report_id)->toBe('fuel_report_123')
1509
+ ->and($transaction->station_location)->toBeInstanceOf(Point::class)
1510
+ ->and($transaction->station_location->getLat())->toBe(1.3521)
1511
+ ->and($transaction->station_location->getLng())->toBe(103.8198);
1512
+
1513
+ $transaction->station_latitude = null;
1514
+ $transaction->station_longitude = 103.8198;
1515
+
1516
+ expect($transaction->station_location)->toBeNull();
1517
+ });
1518
+
1519
+ test('vendor accessors mutators options notifications and import mapping are stable', function () {
1520
+ $vendor = new Vendor([
1521
+ 'name' => 'Vendor One',
1522
+ 'phone' => '+1 (555) 444-3333',
1523
+ 'type' => null,
1524
+ 'status' => null,
1525
+ ]);
1526
+
1527
+ $vendor->setRelation('logo', (object) ['url' => 'https://cdn.example/vendor.png']);
1528
+ $vendor->setRelation('place', (object) [
1529
+ 'address_html' => '1 Vendor Way',
1530
+ 'street1' => '1 Vendor Way',
1531
+ ]);
1532
+
1533
+ expect($vendor->type)->toBe('vendor')
1534
+ ->and($vendor->status)->toBe('active')
1535
+ ->and($vendor->logo_url)->toBe('https://cdn.example/vendor.png')
1536
+ ->and($vendor->address)->toBe('1 Vendor Way')
1537
+ ->and($vendor->address_street)->toBe('1 Vendor Way')
1538
+ ->and($vendor->routeNotificationForTwilio())->toContain('555');
1539
+
1540
+ $vendor->type = null;
1541
+ $vendor->status = null;
1542
+
1543
+ expect($vendor->type)->toBe('vendor')
1544
+ ->and($vendor->status)->toBe('active');
1545
+
1546
+ $slugOptions = $vendor->getSlugOptions();
1547
+ $logOptions = $vendor->getActivitylogOptions();
1548
+
1549
+ expect($slugOptions->generateSlugFrom)->toBe(['name'])
1550
+ ->and($slugOptions->slugField)->toBe('slug')
1551
+ ->and($logOptions->logAttributes)->toContain('name', 'email', 'company_uuid')
1552
+ ->and($logOptions->logOnlyDirty)->toBeTrue();
1553
+
1554
+ $imported = Vendor::createFromImport([
1555
+ 'full_name' => 'Imported Vendor',
1556
+ 'mobile_number' => '+1 555 222 1111',
1557
+ 'email_address' => 'vendor@example.com',
1558
+ 'website_url' => 'https://vendor.example',
1559
+ 'country_name' => 'United States',
1560
+ ]);
1561
+
1562
+ expect($imported)->toBeInstanceOf(Vendor::class)
1563
+ ->and($imported->name)->toBe('Imported Vendor')
1564
+ ->and($imported->phone)->toContain('555')
1565
+ ->and($imported->email)->toBe('vendor@example.com')
1566
+ ->and($imported->type)->toBe('vendor')
1567
+ ->and($imported->status)->toBe('active')
1568
+ ->and($imported->country)->toBe('US');
1569
+ });
1570
+
1571
+ test('service rate accessors flags fee normalization and quote helpers are stable', function () {
1572
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1573
+
1574
+ $rate = new ServiceRate([
1575
+ 'service_name' => 'Express',
1576
+ 'rate_calculation_method' => 'fixed_meter',
1577
+ 'has_peak_hours_fee' => true,
1578
+ 'peak_hours_calculation_method' => 'flat',
1579
+ 'peak_hours_start' => '00:00',
1580
+ 'peak_hours_end' => '23:59',
1581
+ 'has_cod_fee' => true,
1582
+ 'cod_calculation_method' => 'percentage',
1583
+ 'base_fee' => 500,
1584
+ 'currency' => 'USD',
1585
+ ]);
1586
+
1587
+ $rate->setRelation('serviceArea', (object) ['name' => 'Central']);
1588
+ $rate->setRelation('zone', (object) ['name' => 'Zone A']);
1589
+
1590
+ expect($rate->rateFees())->toBeInstanceOf(HasMany::class)
1591
+ ->and($rate->parcelFees())->toBeInstanceOf(HasMany::class)
1592
+ ->and($rate->orderConfig())->toBeInstanceOf(BelongsTo::class)
1593
+ ->and($rate->serviceArea())->toBeInstanceOf(BelongsTo::class)
1594
+ ->and($rate->zone())->toBeInstanceOf(BelongsTo::class)
1595
+ ->and($rate->service_area_name)->toBe('Central')
1596
+ ->and($rate->zone_name)->toBe('Zone A')
1597
+ ->and($rate->isRateCalculationMethod('fixed_meter'))->toBeTrue()
1598
+ ->and($rate->isRateCalculationMethod(['per_meter', 'fixed_meter']))->toBeTrue()
1599
+ ->and($rate->isFixedMeter())->toBeTrue()
1600
+ ->and($rate->isFixedRate())->toBeTrue()
1601
+ ->and($rate->isPerMeter())->toBeFalse()
1602
+ ->and($rate->isMultiZoneDistance())->toBeFalse()
1603
+ ->and($rate->isPerDrop())->toBeFalse()
1604
+ ->and($rate->isAlgorithm())->toBeFalse()
1605
+ ->and($rate->isParcelService())->toBeFalse()
1606
+ ->and($rate->hasPeakHoursFee())->toBeTrue()
1607
+ ->and($rate->isWithinPeakHours())->toBeTrue()
1608
+ ->and($rate->hasPeakHoursFlatFee())->toBeTrue()
1609
+ ->and($rate->hasPeakHoursPercentageFee())->toBeFalse()
1610
+ ->and($rate->hasCodFee())->toBeTrue()
1611
+ ->and($rate->hasCodFlatFee())->toBeFalse()
1612
+ ->and($rate->hasCodPercentageFee())->toBeTrue();
1613
+
1614
+ $rate->setEstimatedDaysAttribute(null);
1615
+ expect($rate->estimated_days)->toBe(0);
1616
+
1617
+ expect($rate->normalizeServiceRateFeePayload([
1618
+ 'uuid' => 'fee_uuid',
1619
+ 'service_area' => ['uuid' => 'area_uuid'],
1620
+ 'zone' => ['uuid' => 'zone_uuid'],
1621
+ 'is_fallback' => true,
1622
+ 'label' => 'Fallback',
1623
+ 'ignored_field' => 'ignored',
1624
+ ]))->toMatchArray([
1625
+ 'uuid' => 'fee_uuid',
1626
+ 'service_area_uuid' => null,
1627
+ 'zone_uuid' => null,
1628
+ 'label' => 'Fallback',
1629
+ 'is_fallback' => true,
1630
+ ]);
1631
+
1632
+ expect($rate->normalizeServiceRateFeePayload('bad payload'))->toBeNull();
1633
+ });
1634
+
1635
+ test('service rate alternate calculation branches and relation predicates are stable', function () {
1636
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1637
+
1638
+ $area = new ServiceArea();
1639
+ $area->setRawAttributes(['name' => 'Central Area'], true);
1640
+ $zone = new Zone();
1641
+ $zone->setRawAttributes(['name' => 'Downtown Zone'], true);
1642
+
1643
+ $relationRate = new FleetOpsLoadedServiceRateFake([
1644
+ 'rate_calculation_method' => 'algorithm',
1645
+ 'has_peak_hours_fee' => false,
1646
+ 'peak_hours_calculation_method' => 'percentage',
1647
+ 'has_cod_fee' => false,
1648
+ 'cod_calculation_method' => 'flat',
1649
+ 'base_fee' => 100,
1650
+ 'currency' => 'USD',
1651
+ ]);
1652
+ $relationRate->setRelation('serviceArea', $area);
1653
+ $relationRate->setRelation('zone', $zone);
1654
+
1655
+ expect($relationRate->hasServiceArea())->toBeTrue()
1656
+ ->and($relationRate->hasZone())->toBeTrue()
1657
+ ->and($relationRate->isAlgorithm())->toBeTrue()
1658
+ ->and($relationRate->isRateCalculationMethod(['fixed_meter', 'algorithm']))->toBeTrue()
1659
+ ->and($relationRate->hasPeakHoursFee())->toBeFalse()
1660
+ ->and($relationRate->hasPeakHoursFlatFee())->toBeFalse()
1661
+ ->and($relationRate->hasPeakHoursPercentageFee())->toBeTrue()
1662
+ ->and($relationRate->hasCodFee())->toBeFalse()
1663
+ ->and($relationRate->hasCodFlatFee())->toBeTrue()
1664
+ ->and($relationRate->hasCodPercentageFee())->toBeFalse();
1665
+
1666
+ $fixedRate = new FleetOpsLoadedServiceRateFake([
1667
+ 'rate_calculation_method' => 'fixed_rate',
1668
+ 'base_fee' => 100,
1669
+ 'currency' => 'USD',
1670
+ ]);
1671
+ $fixedFee = new ServiceRateFee(['distance' => 5, 'fee' => 250]);
1672
+ $fixedRate->setRelation('rateFees', collect([$fixedFee]));
1673
+
1674
+ [$fixedTotal, $fixedLines] = $fixedRate->quoteFromPreliminaryData([], [], 3000, 0);
1675
+
1676
+ $dropRate = new FleetOpsLoadedServiceRateFake([
1677
+ 'rate_calculation_method' => 'per_drop',
1678
+ 'base_fee' => 100,
1679
+ 'currency' => 'USD',
1680
+ ]);
1681
+ $dropFee = new ServiceRateFee(['min' => 3, 'max' => 5, 'fee' => 175]);
1682
+ $dropRate->setRelation('rateFees', collect([$dropFee]));
1683
+
1684
+ [$dropTotal, $dropLines] = $dropRate->quoteFromPreliminaryData([], [new Place(), new Place(), new Place()], 0, 0);
1685
+
1686
+ $algorithmRate = new FleetOpsLoadedServiceRateFake([
1687
+ 'rate_calculation_method' => 'algo',
1688
+ 'algorithm' => '{base_fee} + {distance_km} + {stops}',
1689
+ 'base_fee' => 100,
1690
+ 'currency' => 'USD',
1691
+ ]);
1692
+
1693
+ [$algorithmTotal, $algorithmLines] = $algorithmRate->quoteFromPreliminaryData([], [new Place(), new Place(), new Place()], 2500, 0, false, 2);
1694
+
1695
+ $reflection = new ReflectionClass(ServiceRate::class);
1696
+ $normalizer = $reflection->getMethod('normalizeDistanceForUnit');
1697
+ $weightNormalizer = $reflection->getMethod('normalizeEntityWeightToKilograms');
1698
+ $multiZoneQuote = $reflection->getMethod('quoteMultiZoneDistance');
1699
+ $multiZoneCalc = $reflection->getMethod('calculateMultiZoneDistances');
1700
+ $geometryReader = $reflection->getMethod('readRateRuleGeometry');
1701
+ $placePoint = $reflection->getMethod('getLngLatFromPlace');
1702
+
1703
+ $emptyMultiZoneRate = new FleetOpsLoadedServiceRateFake([
1704
+ 'rate_calculation_method' => 'multi_zone_distance',
1705
+ 'base_fee' => 100,
1706
+ 'currency' => 'USD',
1707
+ ]);
1708
+ $emptyMultiZoneRate->setRelation('rateFees', collect());
1709
+
1710
+ [$emptyMultiZoneTotal, $emptyMultiZoneLines] = $multiZoneQuote->invoke($emptyMultiZoneRate, [], 1500);
1711
+
1712
+ $fallbackFee = new ServiceRateFee([
1713
+ 'uuid' => 'fallback-fee',
1714
+ 'is_fallback' => true,
1715
+ 'priority' => 10,
1716
+ 'distance_unit' => 'km',
1717
+ 'fee' => 2,
1718
+ ]);
1719
+ $fallbackMultiZoneRate = new FleetOpsLoadedServiceRateFake([
1720
+ 'rate_calculation_method' => 'multi_zone_distance',
1721
+ 'base_fee' => 100,
1722
+ 'currency' => 'USD',
1723
+ ]);
1724
+ $fallbackMultiZoneRate->setRelation('rateFees', collect([$fallbackFee]));
1725
+
1726
+ [$fallbackMultiZoneTotal, $fallbackMultiZoneLines] = $multiZoneQuote->invoke($fallbackMultiZoneRate, [], 1500);
1727
+ $fallbackDistances = $multiZoneCalc->invoke($fallbackMultiZoneRate, [new Place()], collect(), $fallbackFee, 1500);
1728
+ $invalidGeometry = $geometryReader->invoke($fallbackMultiZoneRate, new ServiceRateFee(['zone' => ['border' => 'not-json']]), new Brick\Geo\IO\GeoJSONReader());
1729
+
1730
+ expect($fixedRate->isFixedMeter())->toBeTrue()
1731
+ ->and($fixedRate->isFixedRate())->toBeTrue()
1732
+ ->and($fixedTotal)->toBe(350)
1733
+ ->and($fixedLines)->toHaveCount(2)
1734
+ ->and($dropRate->isPerDrop())->toBeTrue()
1735
+ ->and($dropTotal)->toBe(275)
1736
+ ->and($dropLines)->toHaveCount(2)
1737
+ ->and($algorithmRate->isAlgorithm())->toBeTrue()
1738
+ ->and($algorithmTotal)->toBe(206)
1739
+ ->and($algorithmLines)->toHaveCount(2)
1740
+ ->and(round($normalizer->invoke($relationRate, 3.048, 'ft'), 1))->toBe(9.8)
1741
+ ->and(round($normalizer->invoke($relationRate, 9.144, 'yd'), 1))->toBe(9.8)
1742
+ ->and($normalizer->invoke($relationRate, 123, 'm'))->toBe(123.0)
1743
+ ->and($normalizer->invoke($relationRate, null, 'km'))->toBe(0.0)
1744
+ ->and($weightNormalizer->invoke($relationRate, ['weight' => null]))->toBe(0.0)
1745
+ ->and($weightNormalizer->invoke($relationRate, ['weight' => 'heavy']))->toBe(0.0)
1746
+ ->and($weightNormalizer->invoke($relationRate, ['weight' => 2, 'weight_unit' => 'tonnes']))->toBe(2000.0)
1747
+ ->and($weightNormalizer->invoke($relationRate, ['weight' => 3, 'weight_unit' => 'kg']))->toBe(3.0)
1748
+ ->and($emptyMultiZoneRate->isMultiZoneDistance())->toBeTrue()
1749
+ ->and($emptyMultiZoneTotal)->toBe(0)
1750
+ ->and($emptyMultiZoneLines)->toHaveCount(0)
1751
+ ->and($fallbackMultiZoneTotal)->toBe(3)
1752
+ ->and($fallbackMultiZoneLines)->toHaveCount(1)
1753
+ ->and($fallbackMultiZoneLines->first()['code'])->toBe('MULTI_ZONE_DISTANCE_FEE')
1754
+ ->and($fallbackDistances[0]['rule'])->toBe($fallbackFee)
1755
+ ->and($fallbackDistances[0]['distance_m'])->toBe(1500.0)
1756
+ ->and($invalidGeometry)->toBeNull()
1757
+ ->and($placePoint->invoke($relationRate, null))->toBeNull();
1758
+ });
1759
+
1760
+ test('service rate multi zone geometry helpers match rule and fallback distances', function () {
1761
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1762
+
1763
+ $rate = new FleetOpsLoadedServiceRateFake([
1764
+ 'rate_calculation_method' => 'multi_zone_distance',
1765
+ 'base_fee' => 0,
1766
+ 'currency' => 'USD',
1767
+ ]);
1768
+
1769
+ $zonePolygon = json_encode([
1770
+ 'type' => 'Polygon',
1771
+ 'coordinates' => [[
1772
+ [103.70, 1.20],
1773
+ [103.95, 1.20],
1774
+ [103.95, 1.45],
1775
+ [103.70, 1.45],
1776
+ [103.70, 1.20],
1777
+ ]],
1778
+ ]);
1779
+ $serviceAreaPolygon = [
1780
+ 'type' => 'Polygon',
1781
+ 'coordinates' => [[
1782
+ [103.00, 1.00],
1783
+ [104.20, 1.00],
1784
+ [104.20, 1.80],
1785
+ [103.00, 1.80],
1786
+ [103.00, 1.00],
1787
+ ]],
1788
+ ];
1789
+
1790
+ $zoneRule = new ServiceRateFee([
1791
+ 'uuid' => 'zone-rule',
1792
+ 'label' => 'Downtown',
1793
+ 'priority' => 20,
1794
+ 'is_fallback' => false,
1795
+ 'distance_unit' => 'km',
1796
+ 'fee' => 3,
1797
+ ]);
1798
+ $zoneRule->setRelation('zone', (object) [
1799
+ 'name' => 'Downtown',
1800
+ 'border' => $zonePolygon,
1801
+ ]);
1802
+
1803
+ $serviceAreaRule = new ServiceRateFee([
1804
+ 'uuid' => 'area-rule',
1805
+ 'label' => 'Metro distance charge',
1806
+ 'priority' => 10,
1807
+ 'is_fallback' => false,
1808
+ 'distance_unit' => 'km',
1809
+ 'fee' => 2,
1810
+ ]);
1811
+ $serviceAreaRule->setRelation('serviceArea', (object) [
1812
+ 'name' => 'Metro',
1813
+ 'border' => $serviceAreaPolygon,
1814
+ ]);
1815
+
1816
+ $fallbackRule = new ServiceRateFee([
1817
+ 'uuid' => 'fallback-rule',
1818
+ 'priority' => 1,
1819
+ 'is_fallback' => true,
1820
+ 'distance_unit' => 'km',
1821
+ 'fee' => 5,
1822
+ ]);
1823
+
1824
+ $rate->setRelation('rateFees', collect([$fallbackRule, $serviceAreaRule, $zoneRule]));
1825
+
1826
+ $insideStart = new Place(['location' => new Point(1.30, 103.80)]);
1827
+ $outsideEnd = new Place(['location' => new Point(2.10, 104.80)]);
1828
+
1829
+ $reflection = new ReflectionClass(ServiceRate::class);
1830
+ $multiZoneQuote = $reflection->getMethod('quoteMultiZoneDistance');
1831
+ $multiZoneCalc = $reflection->getMethod('calculateMultiZoneDistances');
1832
+ $geometryReader = $reflection->getMethod('readRateRuleGeometry');
1833
+ $placePoint = $reflection->getMethod('getLngLatFromPlace');
1834
+
1835
+ $reader = new Brick\Geo\IO\GeoJSONReader();
1836
+ $zoneGeometry = $geometryReader->invoke($rate, $zoneRule, $reader);
1837
+ $areaGeometry = $geometryReader->invoke($rate, $serviceAreaRule, $reader);
1838
+ $emptyGeometry = $geometryReader->invoke($rate, new ServiceRateFee(), $reader);
1839
+
1840
+ $rate->setRelation('rateFees', collect([$fallbackRule]));
1841
+ [$fallbackTotal, $fallbackLines] = $multiZoneQuote->invoke($rate, [$insideStart, $outsideEnd], 2400);
1842
+
1843
+ $fallbackDistances = $multiZoneCalc->invoke($rate, [$insideStart, $outsideEnd], collect(), $fallbackRule, 2400);
1844
+
1845
+ expect($zoneGeometry)->not->toBeNull()
1846
+ ->and($areaGeometry)->not->toBeNull()
1847
+ ->and($emptyGeometry)->toBeNull()
1848
+ ->and($fallbackTotal)->toBe(12)
1849
+ ->and($fallbackLines)->toHaveCount(1)
1850
+ ->and($fallbackLines->first()['details'])->toContain('Out-of-zone distance charge')
1851
+ ->and($fallbackDistances[0]['rule'])->toBe($fallbackRule)
1852
+ ->and($fallbackDistances[0]['distance_m'])->toBe(2400.0)
1853
+ ->and($placePoint->invoke($rate, $insideStart))->toBe(['lat' => 1.3, 'lng' => 103.8]);
1854
+ });
1855
+
1856
+ test('fleet accessors expose photo fallback and online asset counts', function () {
1857
+ $fleet = new FleetOpsFleetAccessorFake();
1858
+ $fleet->setRelation('photo', null);
1859
+
1860
+ expect($fleet->photo_url)->toBe('https://s3.ap-northeast-2.amazonaws.com/fleetbase/public/default-fleet.png')
1861
+ ->and($fleet->drivers_count)->toBe(5)
1862
+ ->and($fleet->drivers_online_count)->toBe(2)
1863
+ ->and($fleet->vehicles_count)->toBe(7)
1864
+ ->and($fleet->vehicles_online_count)->toBe(3)
1865
+ ->and($fleet->getActivitylogOptions()->logAttributes)->toBe(['name', 'task', 'service_area_uuid', 'zone_uuid'])
1866
+ ->and($fleet->getSlugOptions()->slugField)->toBe('slug');
1867
+ });
1868
+
1869
+ test('driver accessors notification routes and assignment helpers are stable', function () {
1870
+ $driver = new FleetOpsDriverAccessorFake();
1871
+ $driver->setRawAttributes([
1872
+ 'uuid' => 'driver-uuid',
1873
+ 'public_id' => 'driver_public',
1874
+ 'company_uuid' => 'company-uuid',
1875
+ 'heading' => 90,
1876
+ 'vehicle_uuid' => null,
1877
+ 'drivers_license_number' => 'DL-123',
1878
+ ], true);
1879
+ $driver->setRelation('user', (object) [
1880
+ 'avatar' => 'avatar-object',
1881
+ 'avatarUrl' => 'https://cdn.test/driver.png',
1882
+ 'name' => 'Dana Driver',
1883
+ 'phone' => '+15551234567',
1884
+ 'email' => 'dana@example.test',
1885
+ ]);
1886
+ $driver->setRelation('devices', collect([
1887
+ (object) ['platform' => 'android', 'token' => 'fcm-a'],
1888
+ (object) ['platform' => 'ios', 'token' => 'apn-a'],
1889
+ (object) ['platform' => 'android', 'token' => 'fcm-b'],
1890
+ (object) ['platform' => 'web', 'token' => 'web-a'],
1891
+ ]));
1892
+
1893
+ $vehicle = new Vehicle();
1894
+ $vehicle->setRawAttributes([
1895
+ 'uuid' => 'vehicle-uuid',
1896
+ 'avatar_url' => 'https://cdn.test/vehicle.png',
1897
+ ], true);
1898
+
1899
+ expect($driver->photo)->toBe('avatar-object')
1900
+ ->and($driver->photo_url)->toBe('https://cdn.test/driver.png')
1901
+ ->and($driver->name)->toBe('Dana Driver')
1902
+ ->and($driver->phone)->toBe('+15551234567')
1903
+ ->and($driver->email)->toBe('dana@example.test')
1904
+ ->and($driver->rotation)->toBe(180.0)
1905
+ ->and($driver->routeNotificationForFcm())->toBe([0 => 'fcm-a', 2 => 'fcm-b'])
1906
+ ->and($driver->routeNotificationForApn())->toBe([1 => 'apn-a'])
1907
+ ->and((string) $driver->receivesBroadcastNotificationsOn(new stdClass()))->toBe('driver.driver_public')
1908
+ ->and($driver->isVehicleNotAssigned())->toBeTrue()
1909
+ ->and($driver->isVehicleAssigned())->toBeFalse()
1910
+ ->and($driver->setVehicle($vehicle))->toBe($driver)
1911
+ ->and($driver->vehicle_uuid)->toBe('vehicle-uuid')
1912
+ ->and($driver->vehicle)->toBe($vehicle)
1913
+ ->and($driver->isVehicleAssigned())->toBeTrue()
1914
+ ->and($driver->unassignCurrentJob())->toBeTrue()
1915
+ ->and($driver->updates)->toBe([['current_job_uuid' => null]])
1916
+ ->and($driver->unassignCurrentOrder())->toBeTrue()
1917
+ ->and($driver->updates[1])->toBe(['current_job_uuid' => null]);
1918
+ });
1919
+
1920
+ test('driver license expiry status avatar and current order helpers handle edge cases', function () {
1921
+ Carbon::setTestNow(Carbon::parse('2026-07-26 10:00:00'));
1922
+
1923
+ $fresh = new FleetOpsDriverAccessorFake();
1924
+ $fresh->setLicenseExpiryAttribute(null);
1925
+
1926
+ $existing = new FleetOpsDriverAccessorFake();
1927
+ $existing->exists = true;
1928
+ $existing->setRawAttributes(['license_expiry' => '2026-12-31'], true);
1929
+ $existing->setLicenseExpiryAttribute('');
1930
+
1931
+ $parsed = new FleetOpsDriverAccessorFake();
1932
+ $parsed->setLicenseExpiryAttribute('July 30 2026');
1933
+ $parsed->status = 'active';
1934
+ $parsed->status = null;
1935
+ $parsed->status = 'off-duty';
1936
+
1937
+ $vehicle = new Vehicle();
1938
+ $vehicle->setRawAttributes(['avatar_url' => 'https://cdn.test/vehicle-avatar.png'], true);
1939
+
1940
+ $avatarDriver = new FleetOpsDriverAccessorFake();
1941
+ $avatarDriver->setRelation('vehicle', $vehicle);
1942
+
1943
+ $order = new FleetOpsDriverOrderAccessorFake();
1944
+ $order->setRawAttributes(['uuid' => 'order-uuid'], true);
1945
+
1946
+ $orderDriver = new FleetOpsDriverAccessorFake();
1947
+ $orderDriver->setRelation('currentOrder', $order);
1948
+
1949
+ $emptyDriver = new FleetOpsDriverAccessorFake();
1950
+ $emptyDriver->setRelation('currentOrder', null);
1951
+
1952
+ expect($fresh->getAttributes()['license_expiry'])->toBeNull()
1953
+ ->and($existing->getAttributes()['license_expiry'])->toBe('2026-12-31')
1954
+ ->and($parsed->getAttributes()['license_expiry'])->toBe('2026-07-30')
1955
+ ->and($parsed->status)->toBe('off-duty')
1956
+ ->and($avatarDriver->getAvatarUrlAttribute(null))->toBe('https://cdn.test/vehicle-avatar.png')
1957
+ ->and($avatarDriver->loadedMissing)->toBe(['vehicle'])
1958
+ ->and($orderDriver->getCurrentOrder())->toBe($order)
1959
+ ->and($orderDriver->loadedMissing)->toBe(['currentOrder'])
1960
+ ->and($order->loadedMissing)->toBe(['payload'])
1961
+ ->and($emptyDriver->getCurrentOrder())->toBeNull()
1962
+ ->and($emptyDriver->loadedMissing)->toBe(['currentOrder'])
1963
+ ->and($parsed->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class)
1964
+ ->and($parsed->getSlugOptions()->slugField)->toBe('slug');
1965
+
1966
+ Carbon::setTestNow();
1967
+ });
1968
+
1969
+ test('driver relationships expose fleet ops relation contracts', function () {
1970
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
1971
+ Illuminate\Support\Facades\DB::swap(new class {
1972
+ public function raw(string $value): Illuminate\Database\Query\Expression
1973
+ {
1974
+ return new Illuminate\Database\Query\Expression($value);
1975
+ }
1976
+ });
1977
+ Carbon::setTestNow(Carbon::parse('2026-07-26 10:00:00'));
1978
+
1979
+ $driver = new Driver();
1980
+ $driver->setRawAttributes([
1981
+ 'uuid' => 'driver-uuid',
1982
+ 'user_uuid' => 'user-uuid',
1983
+ 'company_uuid' => 'company-uuid',
1984
+ ], true);
1985
+
1986
+ $user = $driver->user();
1987
+ $company = $driver->company();
1988
+ $vehicle = $driver->vehicle();
1989
+ $vendor = $driver->vendor();
1990
+ $currentJob = $driver->currentJob();
1991
+ $currentOrder = $driver->currentOrder();
1992
+ $jobs = $driver->jobs();
1993
+ $orders = $driver->orders();
1994
+ $positions = $driver->positions();
1995
+ $fleets = $driver->fleets();
1996
+ $devices = $driver->devices();
1997
+ $schedules = $driver->schedules();
1998
+ $scheduleItems = $driver->scheduleItems();
1999
+ $currentShift = $driver->currentShift();
2000
+ $availabilities = $driver->availabilities();
2001
+
2002
+ expect($user)->toBeInstanceOf(BelongsTo::class)
2003
+ ->and($user->getRelated())->toBeInstanceOf(Fleetbase\Models\User::class)
2004
+ ->and($user->getForeignKeyName())->toBe('user_uuid')
2005
+ ->and($company)->toBeInstanceOf(BelongsTo::class)
2006
+ ->and($company->getRelated())->toBeInstanceOf(Fleetbase\Models\Company::class)
2007
+ ->and($vehicle)->toBeInstanceOf(BelongsTo::class)
2008
+ ->and($vehicle->getRelated())->toBeInstanceOf(Vehicle::class)
2009
+ ->and($vendor)->toBeInstanceOf(BelongsTo::class)
2010
+ ->and($vendor->getRelated())->toBeInstanceOf(Vendor::class)
2011
+ ->and($currentJob)->toBeInstanceOf(BelongsTo::class)
2012
+ ->and($currentJob->getRelated())->toBeInstanceOf(Order::class)
2013
+ ->and($currentOrder)->toBeInstanceOf(BelongsTo::class)
2014
+ ->and($currentOrder->getForeignKeyName())->toBe('current_job_uuid')
2015
+ ->and($currentOrder->getRelated())->toBeInstanceOf(Order::class)
2016
+ ->and($jobs)->toBeInstanceOf(HasMany::class)
2017
+ ->and($jobs->getForeignKeyName())->toBe('driver_assigned_uuid')
2018
+ ->and($orders)->toBeInstanceOf(HasMany::class)
2019
+ ->and($orders->getForeignKeyName())->toBe('driver_assigned_uuid')
2020
+ ->and($positions)->toBeInstanceOf(HasMany::class)
2021
+ ->and($positions->getForeignKeyName())->toBe('subject_uuid')
2022
+ ->and($fleets)->toBeInstanceOf(HasManyThrough::class)
2023
+ ->and($fleets->getRelated())->toBeInstanceOf(Fleet::class)
2024
+ ->and($devices)->toBeInstanceOf(HasMany::class)
2025
+ ->and($devices->getForeignKeyName())->toBe('user_uuid')
2026
+ ->and($devices->getLocalKeyName())->toBe('user_uuid')
2027
+ ->and($schedules)->toBeInstanceOf(MorphMany::class)
2028
+ ->and($schedules->getMorphType())->toBe('subject_type')
2029
+ ->and($schedules->getForeignKeyName())->toBe('subject_uuid')
2030
+ ->and($scheduleItems)->toBeInstanceOf(MorphMany::class)
2031
+ ->and($scheduleItems->getMorphType())->toBe('assignee_type')
2032
+ ->and($scheduleItems->getForeignKeyName())->toBe('assignee_uuid')
2033
+ ->and($currentShift)->toBeInstanceOf(MorphOne::class)
2034
+ ->and($currentShift->getMorphType())->toBe('assignee_type')
2035
+ ->and($currentShift->getForeignKeyName())->toBe('assignee_uuid')
2036
+ ->and($availabilities)->toBeInstanceOf(MorphMany::class)
2037
+ ->and($availabilities->getMorphType())->toBe('subject_type')
2038
+ ->and($availabilities->getForeignKeyName())->toBe('subject_uuid');
2039
+
2040
+ Carbon::setTestNow();
2041
+ });
2042
+
2043
+ test('vehicle accessors import mapping and mutable json helpers are stable', function () {
2044
+ session(['company' => 'company-uuid']);
2045
+
2046
+ $vehicle = new Vehicle([
2047
+ 'year' => 2026,
2048
+ 'make' => 'Ford',
2049
+ 'model' => 'Transit',
2050
+ 'trim' => 'XL',
2051
+ 'model_type' => 'cargo',
2052
+ 'details' => ['engine' => ['hours' => 100]],
2053
+ 'specs' => ['battery' => 'standard'],
2054
+ 'vin_data' => ['plant' => 'A'],
2055
+ 'status' => 'active',
2056
+ 'avatar_url' => 'https://cdn.test/vehicle.png',
2057
+ ]);
2058
+ $vehicle->setRelation('driver', (object) [
2059
+ 'name' => 'Driver One',
2060
+ 'public_id' => 'driver_public',
2061
+ 'uuid' => 'driver-uuid',
2062
+ ]);
2063
+ $vehicle->setRelation('vendor', (object) [
2064
+ 'name' => 'Vendor One',
2065
+ 'public_id' => 'vendor_public',
2066
+ ]);
2067
+ $vehicle->setRelation('photo', (object) [
2068
+ 'url' => 'https://cdn.test/photo.png',
2069
+ ]);
2070
+
2071
+ expect($vehicle->status)->toBe('available')
2072
+ ->and($vehicle->photo_url)->toBe('https://cdn.test/photo.png')
2073
+ ->and($vehicle->display_name)->toBe('2026 Ford Transit XL')
2074
+ ->and($vehicle->driver_name)->toBe('Driver One')
2075
+ ->and($vehicle->driver_id)->toBe('driver_public')
2076
+ ->and($vehicle->driver_uuid)->toBe('driver-uuid')
2077
+ ->and($vehicle->vendor_id)->toBe('vendor_public')
2078
+ ->and($vehicle->vendor_name)->toBe('Vendor One')
2079
+ ->and($vehicle->model_data)->toBe(['type' => 'cargo'])
2080
+ ->and($vehicle->getAvatarUrlAttribute('https://cdn.test/direct.png'))->toBe('https://cdn.test/direct.png')
2081
+ ->and($vehicle->setDetail('engine.hours', 125))->toMatchArray(['engine' => ['hours' => 125]])
2082
+ ->and($vehicle->setDetails(['doors' => 4]))->toMatchArray(['engine' => ['hours' => 125], 'doors' => 4])
2083
+ ->and($vehicle->setSpec('battery', 'extended'))->toMatchArray(['battery' => 'extended'])
2084
+ ->and($vehicle->setSpecs(['range' => '300km']))->toMatchArray(['battery' => 'extended', 'range' => '300km'])
2085
+ ->and($vehicle->setVinData('plant', 'B'))->toMatchArray(['plant' => 'B'])
2086
+ ->and($vehicle->setVinDatas(['sequence' => '1001']))->toMatchArray(['plant' => 'B', 'sequence' => '1001']);
2087
+
2088
+ $imported = Vehicle::createFromImport([
2089
+ 'vehicle_make' => 'Mercedes',
2090
+ 'vehicle_model' => 'Sprinter',
2091
+ 'vehicle_year' => 2025,
2092
+ 'vehicle_trim' => 'Crew',
2093
+ 'internal_number' => 'INT-9',
2094
+ 'vehicle_plate' => 'ABC-123',
2095
+ 'vin_number' => 'VIN-9',
2096
+ 'serial' => 'SER-9',
2097
+ 'callsign' => 'CALL-9',
2098
+ 'vehicle_card_id' => 'CARD-9',
2099
+ 'vehicle_type' => 'van',
2100
+ ]);
2101
+
2102
+ expect($imported->company_uuid)->toBe('company-uuid')
2103
+ ->and($imported->make)->toBe('Mercedes')
2104
+ ->and($imported->model)->toBe('Sprinter')
2105
+ ->and($imported->year)->toBe(2025)
2106
+ ->and($imported->trim)->toBe('Crew')
2107
+ ->and($imported->internal_id)->toBe('INT-9')
2108
+ ->and($imported->plate_number)->toBe('ABC-123')
2109
+ ->and($imported->vin)->toBe('VIN-9')
2110
+ ->and($imported->serial_number)->toBe('SER-9')
2111
+ ->and($imported->call_sign)->toBe('CALL-9')
2112
+ ->and($imported->fuel_card_number)->toBe('CARD-9')
2113
+ ->and($imported->type)->toBe('van')
2114
+ ->and($imported->status)->toBe('available')
2115
+ ->and($imported->online)->toBeFalse();
2116
+ });
2117
+
2118
+ test('work order accessors scopes checklist helpers and imports are stable', function () {
2119
+ Carbon::setTestNow(Carbon::parse('2026-04-10 09:00:00'));
2120
+ session(['company' => 'company-uuid']);
2121
+
2122
+ $workOrder = new FleetOpsUpdatingWorkOrderFake();
2123
+ $workOrder->setRawAttributes([
2124
+ 'status' => 'open',
2125
+ 'priority' => 'high',
2126
+ 'opened_at' => '2026-04-09 09:00:00',
2127
+ 'due_at' => '2026-04-11 09:00:00',
2128
+ 'closed_at' => '2026-04-10 15:00:00',
2129
+ 'checklist' => [
2130
+ ['label' => 'Inspect tires', 'completed' => true],
2131
+ ['label' => 'Replace filter', 'completed' => false],
2132
+ ],
2133
+ 'meta' => ['estimated_duration_hours' => 3.5],
2134
+ ], true);
2135
+ $workOrder->setRelation('target', (object) ['display_name' => 'Truck 20']);
2136
+ $workOrder->setRelation('assignee', (object) ['name' => 'Vendor Crew']);
2137
+
2138
+ expect($workOrder->target_name)->toBe('Truck 20')
2139
+ ->and($workOrder->assignee_name)->toBe('Vendor Crew')
2140
+ ->and($workOrder->is_overdue)->toBeFalse()
2141
+ ->and($workOrder->days_until_due)->toBe(1)
2142
+ ->and($workOrder->completion_percentage)->toBe(50.0)
2143
+ ->and($workOrder->estimated_duration)->toBe(3.5)
2144
+ ->and($workOrder->getActualDuration())->toBe(30.0)
2145
+ ->and($workOrder->isOnSchedule())->toBeTrue()
2146
+ ->and($workOrder->getPriorityLevel())->toBe(4);
2147
+
2148
+ $closed = new FleetOpsUpdatingWorkOrderFake();
2149
+ $closed->setRawAttributes(['status' => 'closed', 'checklist' => []], true);
2150
+ expect($closed->completion_percentage)->toBe(100.0)
2151
+ ->and($closed->is_overdue)->toBeFalse()
2152
+ ->and($closed->days_until_due)->toBeNull();
2153
+
2154
+ $scopeQuery = new FleetOpsManifestScopeQueryFake();
2155
+ expect($workOrder->scopeByStatus($scopeQuery, 'open'))->toBe($scopeQuery)
2156
+ ->and($workOrder->scopeOpen($scopeQuery))->toBe($scopeQuery)
2157
+ ->and($workOrder->scopeOverdue($scopeQuery))->toBe($scopeQuery)
2158
+ ->and($workOrder->scopeByPriority($scopeQuery, 'high'))->toBe($scopeQuery)
2159
+ ->and($workOrder->scopeAssignedTo($scopeQuery, Vendor::class, 'vendor-uuid'))->toBe($scopeQuery)
2160
+ ->and($scopeQuery->calls[0])->toBe(['where', 'status', 'open'])
2161
+ ->and($scopeQuery->calls[1])->toBe(['whereIn', 'status', ['open', 'in_progress']])
2162
+ ->and($scopeQuery->calls[2][0])->toBe('where')
2163
+ ->and($scopeQuery->calls[2][1])->toBe('due_at')
2164
+ ->and($scopeQuery->calls[2][2])->toBe('<')
2165
+ ->and($scopeQuery->calls[3])->toBe(['whereNotIn', 'status', ['closed', 'canceled']])
2166
+ ->and($scopeQuery->calls[4])->toBe(['where', 'priority', 'high'])
2167
+ ->and($scopeQuery->calls[5])->toBe(['where', 'assignee_type', Vendor::class])
2168
+ ->and($scopeQuery->calls[6])->toBe(['where', 'assignee_uuid', 'vendor-uuid']);
2169
+
2170
+ expect($workOrder->updateChecklistItem(1, ['completed' => true]))->toBeTrue()
2171
+ ->and($workOrder->updates[0]['checklist'][1]['completed'])->toBeTrue()
2172
+ ->and($workOrder->updateChecklistItem(99, ['completed' => true]))->toBeFalse()
2173
+ ->and($workOrder->completeChecklistItem(0, 'user-uuid'))->toBeTrue()
2174
+ ->and($workOrder->updates[1]['checklist'][0]['completed_by'])->toBe('user-uuid')
2175
+ ->and($workOrder->addChecklistItem(['label' => 'Road test']))->toBeTrue()
2176
+ ->and($workOrder->updates[2]['checklist'][2]['completed'])->toBeFalse();
2177
+
2178
+ $imported = FleetOpsUpdatingWorkOrderFake::createFromImport([
2179
+ 'title' => 'Oil service',
2180
+ 'work_type' => 'maintenance',
2181
+ 'status' => 'open',
2182
+ 'priority' => 'critical',
2183
+ 'description' => 'Change oil',
2184
+ 'open_date' => '2026-04-01',
2185
+ 'due_date' => '2026-04-15',
2186
+ 'estimated_cost' => 12000,
2187
+ 'budget' => 15000,
2188
+ 'actual_cost' => 0,
2189
+ 'currency' => 'sgd',
2190
+ 'cost_center' => 'OPS',
2191
+ 'budget_code' => 'BUD-1',
2192
+ ]);
2193
+
2194
+ expect($imported->company_uuid)->toBe('company-uuid')
2195
+ ->and($imported->subject)->toBe('Oil service')
2196
+ ->and($imported->category)->toBe('maintenance')
2197
+ ->and($imported->priority)->toBe('critical')
2198
+ ->and($imported->instructions)->toBe('Change oil')
2199
+ ->and($imported->opened_at->toDateString())->toBe('2026-04-01')
2200
+ ->and($imported->due_at->toDateString())->toBe('2026-04-15')
2201
+ ->and($imported->currency)->toBe('SGD')
2202
+ ->and($imported->cost_center)->toBe('OPS')
2203
+ ->and($imported->budget_code)->toBe('BUD-1');
2204
+
2205
+ Carbon::setTestNow();
2206
+ });
2207
+
2208
+ test('manifest metadata relations scopes and status transitions are stable', function () {
2209
+ Carbon::setTestNow(Carbon::parse('2026-04-05 09:30:00'));
2210
+
2211
+ $manifest = new FleetOpsUpdatingManifestFake([
2212
+ 'company_uuid' => 'company-uuid',
2213
+ 'driver_uuid' => 'driver-uuid',
2214
+ 'vehicle_uuid' => 'vehicle-uuid',
2215
+ 'status' => 'active',
2216
+ 'scheduled_date' => '2026-04-05',
2217
+ 'total_distance_m' => 12345,
2218
+ 'total_duration_s' => 3600,
2219
+ 'stop_count' => 4,
2220
+ 'meta' => ['route' => 'A'],
2221
+ ]);
2222
+ $manifest->setRelation('driver', (object) ['name' => 'Jane Driver']);
2223
+ $manifest->setRelation('vehicle', (object) ['display_name' => 'Truck 12']);
2224
+
2225
+ expect($manifest->getTable())->toBe('manifests')
2226
+ ->and($manifest->getFillable())->toContain(
2227
+ 'company_uuid',
2228
+ 'driver_uuid',
2229
+ 'vehicle_uuid',
2230
+ 'status',
2231
+ 'scheduled_date',
2232
+ 'started_at',
2233
+ 'completed_at',
2234
+ 'total_distance_m',
2235
+ 'total_duration_s',
2236
+ 'stop_count',
2237
+ 'notes',
2238
+ 'meta'
2239
+ )
2240
+ ->and($manifest->getCasts())->toHaveKeys(['meta', 'scheduled_date', 'started_at', 'completed_at'])
2241
+ ->and($manifest->getAppends())->toBe(['driver_name', 'vehicle_name', 'completed_stops', 'pending_stops'])
2242
+ ->and($manifest->driver_name)->toBe('Jane Driver')
2243
+ ->and($manifest->vehicle_name)->toBe('Truck 12');
2244
+
2245
+ $scopeQuery = new FleetOpsManifestScopeQueryFake();
2246
+
2247
+ expect($manifest->scopeForCompany($scopeQuery, 'company-uuid'))->toBe($scopeQuery)
2248
+ ->and($manifest->scopeActive($scopeQuery))->toBe($scopeQuery)
2249
+ ->and($manifest->scopeForDriver($scopeQuery, 'driver-uuid'))->toBe($scopeQuery)
2250
+ ->and($manifest->scopeForVehicle($scopeQuery, 'vehicle-uuid'))->toBe($scopeQuery)
2251
+ ->and($scopeQuery->calls)->toBe([
2252
+ ['where', 'company_uuid', 'company-uuid'],
2253
+ ['whereIn', 'status', ['active', 'in_progress']],
2254
+ ['where', 'driver_uuid', 'driver-uuid'],
2255
+ ['where', 'vehicle_uuid', 'vehicle-uuid'],
2256
+ ]);
2257
+
2258
+ expect($manifest->start())->toBe($manifest)
2259
+ ->and($manifest->status)->toBe('in_progress')
2260
+ ->and($manifest->updates[0]['status'])->toBe('in_progress')
2261
+ ->and($manifest->updates[0]['started_at']->toDateTimeString())->toBe('2026-04-05 09:30:00')
2262
+ ->and($manifest->complete())->toBe($manifest)
2263
+ ->and($manifest->status)->toBe('completed')
2264
+ ->and($manifest->updates[1]['completed_at']->toDateTimeString())->toBe('2026-04-05 09:30:00')
2265
+ ->and($manifest->cancel())->toBe($manifest)
2266
+ ->and($manifest->status)->toBe('cancelled')
2267
+ ->and($manifest->updates[2])->toBe(['status' => 'cancelled']);
2268
+
2269
+ Carbon::setTestNow();
2270
+ });
2271
+
2272
+ test('manifest stop accessors and status transitions are stable', function () {
2273
+ Carbon::setTestNow(Carbon::parse('2026-04-05 10:45:00'));
2274
+
2275
+ $manifest = new FleetOpsManifestStopManifestFake();
2276
+ $stop = new FleetOpsUpdatingManifestStopFake([
2277
+ 'status' => 'pending',
2278
+ 'sequence' => 2,
2279
+ 'distance_from_prev_m' => 1200,
2280
+ 'duration_from_prev_s' => 300,
2281
+ ]);
2282
+ $stop->setRelation('manifest', $manifest);
2283
+ $stop->setRelation('order', (object) [
2284
+ 'trackingNumber' => (object) ['tracking_number' => 'TRK-456'],
2285
+ 'payload' => (object) [
2286
+ 'dropoff' => (object) ['address' => 'Fallback dropoff'],
2287
+ ],
2288
+ ]);
2289
+ $stop->setRelation('place', (object) ['address' => '123 Stop Street']);
2290
+
2291
+ expect($stop->getTable())->toBe('manifest_stops')
2292
+ ->and($stop->getFillable())->toContain('manifest_uuid', 'order_uuid', 'place_uuid', 'waypoint_uuid', 'status', 'sequence', 'meta')
2293
+ ->and($stop->getCasts())->toHaveKeys(['meta', 'estimated_arrival', 'actual_arrival', 'sequence', 'distance_from_prev_m', 'duration_from_prev_s'])
2294
+ ->and($stop->getAppends())->toBe(['tracking_number', 'address'])
2295
+ ->and($stop->tracking_number)->toBe('TRK-456')
2296
+ ->and($stop->address)->toBe('123 Stop Street')
2297
+ ->and($stop->markArrived())->toBe($stop)
2298
+ ->and($stop->status)->toBe('arrived')
2299
+ ->and($stop->updates[0]['actual_arrival']->toDateTimeString())->toBe('2026-04-05 10:45:00')
2300
+ ->and($stop->markCompleted())->toBe($stop)
2301
+ ->and($stop->status)->toBe('completed')
2302
+ ->and($manifest->autoCompleteChecks)->toBe(1)
2303
+ ->and($stop->markSkipped())->toBe($stop)
2304
+ ->and($stop->status)->toBe('skipped')
2305
+ ->and($manifest->autoCompleteChecks)->toBe(2);
2306
+
2307
+ $stop->setRelation('place', null);
2308
+
2309
+ expect($stop->address)->toBe('Fallback dropoff');
2310
+
2311
+ Carbon::setTestNow();
2312
+ });
2313
+
2314
+ test('payload accessors return loaded payloads and associated orders without querying', function () {
2315
+ $order = new Order([
2316
+ 'uuid' => 'order-uuid',
2317
+ 'public_id' => 'order_public',
2318
+ ]);
2319
+ $payload = new Payload([
2320
+ 'uuid' => 'payload-uuid',
2321
+ ]);
2322
+ $payload->setRelation('order', $order);
2323
+
2324
+ $host = new FleetOpsPayloadAccessorHostFake([
2325
+ 'payload_uuid' => 'payload-uuid',
2326
+ ]);
2327
+ $host->setRelation('payload', $payload);
2328
+
2329
+ expect($host->getPayload())->toBe($payload)
2330
+ ->and($host->getOrder())->toBe($order);
2331
+ });
2332
+
2333
+ test('order accessors mutators and payload association helpers are stable', function () {
2334
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
2335
+ Order::boot();
2336
+
2337
+ Carbon::setTestNow(Carbon::parse('2026-02-03 08:00:00'));
2338
+
2339
+ $payload = new FleetOpsLoadedPayloadFake([
2340
+ 'uuid' => 'payload_uuid',
2341
+ ]);
2342
+ $payload->setAttribute('public_id', 'payload_public_id');
2343
+ $payload->setRelation('pickup', new FleetOpsPlainPlaceFake(['name' => 'Pickup dock']));
2344
+ $payload->setRelation('dropoff', new FleetOpsPlainPlaceFake(['street1' => 'Dropoff street']));
2345
+ $payload->setRelation('return', new FleetOpsPlainPlaceFake(['street1' => 'Return counter']));
2346
+
2347
+ $order = new FleetOpsSavingOrderFake([
2348
+ 'driver_assigned_uuid' => 'driver_uuid',
2349
+ 'customer_type' => Contact::class,
2350
+ 'facilitator_type' => Vendor::class,
2351
+ 'scheduled_at' => '2026-02-04 09:30:00',
2352
+ 'dispatched_at' => null,
2353
+ 'adhoc' => false,
2354
+ 'orchestrator_priority' => '7',
2355
+ 'type' => 'Express Delivery',
2356
+ 'status' => 'Driver Assigned',
2357
+ ]);
2358
+
2359
+ $order->setRelation('driverAssigned', (object) ['name' => 'Driver One']);
2360
+ $order->setRelation('vehicleAssigned', (object) ['display_name' => 'Van 4']);
2361
+ $order->setRelation('trackingNumber', (object) [
2362
+ 'tracking_number' => 'TN123',
2363
+ 'qr_code' => 'qr-data',
2364
+ ]);
2365
+ $order->setRelation('transaction', (object) ['amount' => 1200, 'currency' => 'USD']);
2366
+ $order->setRelation('customer', (object) ['name' => 'Customer One', 'phone' => '+1555000']);
2367
+ $order->setRelation('facilitator', (object) ['name' => 'Vendor One']);
2368
+ $order->setRelation('payload', $payload);
2369
+ $order->setRelation('purchaseRate', (object) ['public_id' => 'purchase_rate_id']);
2370
+ $order->setRelation('createdBy', (object) ['name' => 'Creator']);
2371
+ $order->setRelation('updatedBy', (object) ['name' => 'Updater']);
2372
+
2373
+ $order->time_window_start = '09:00:00';
2374
+ $order->time_window_end = '2026-02-05 17:00:00';
2375
+
2376
+ $timeOnlyOrder = new FleetOpsSavingOrderFake();
2377
+ $timeOnlyOrder->setRawAttributes(['created_at' => '2026-02-06 10:00:00'], true);
2378
+ $timeOnlyOrder->time_window_start = '10:15:00';
2379
+ $timeOnlyOrder->time_window_end = '';
2380
+
2381
+ expect($order->driver_name)->toBe('Driver One')
2382
+ ->and(Relation::getMorphedModel('Fleetbase\\Models\\Contact'))->toBe(Contact::class)
2383
+ ->and(Relation::getMorphedModel('\\Fleetbase\\Models\\Driver'))->toBe(Driver::class)
2384
+ ->and(Relation::getMorphedModel('Fleetbase\\Models\\Vendor'))->toBe(Vendor::class)
2385
+ ->and($order->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class)
2386
+ ->and($order->attachFiles([]))->toBe($order)
2387
+ ->and($order->orderConfig())->toBeInstanceOf(BelongsTo::class)
2388
+ ->and($order->transaction())->toBeInstanceOf(BelongsTo::class)
2389
+ ->and($order->route())->toBeInstanceOf(BelongsTo::class)
2390
+ ->and($order->payload())->toBeInstanceOf(BelongsTo::class)
2391
+ ->and($order->company())->toBeInstanceOf(BelongsTo::class)
2392
+ ->and($order->createdBy())->toBeInstanceOf(BelongsTo::class)
2393
+ ->and($order->updatedBy())->toBeInstanceOf(BelongsTo::class)
2394
+ ->and($order->driverAssigned())->toBeInstanceOf(BelongsTo::class)
2395
+ ->and($order->driver())->toBeInstanceOf(BelongsTo::class)
2396
+ ->and($order->vehicleAssigned())->toBeInstanceOf(BelongsTo::class)
2397
+ ->and($order->vehicle())->toBeInstanceOf(BelongsTo::class)
2398
+ ->and($order->comments())->toBeInstanceOf(HasMany::class)
2399
+ ->and($order->files())->toBeInstanceOf(HasMany::class)
2400
+ ->and($order->drivers())->toBeInstanceOf(HasManyThrough::class)
2401
+ ->and($order->trackingNumber())->toBeInstanceOf(BelongsTo::class)
2402
+ ->and($order->trackingStatuses())->toBeInstanceOf(HasMany::class)
2403
+ ->and($order->proofs())->toBeInstanceOf(HasMany::class)
2404
+ ->and($order->purchaseRate())->toBeInstanceOf(BelongsTo::class)
2405
+ ->and($order->facilitator())->toBeInstanceOf(MorphTo::class)
2406
+ ->and($order->customer())->toBeInstanceOf(MorphTo::class)
2407
+ ->and($order->authenticatableCustomer())->toBeInstanceOf(BelongsTo::class)
2408
+ ->and($order->getAdhocDistance())->toBe(6000)
2409
+ ->and($order->vehicle_name)->toBe('Van 4')
2410
+ ->and($order->tracking)->toBe('TN123')
2411
+ ->and($order->transaction_amount)->toBe(1200)
2412
+ ->and($order->transaction_currency)->toBe('USD')
2413
+ ->and($order->customer_name)->toBe('Customer One')
2414
+ ->and($order->customer_phone)->toBe('+1555000')
2415
+ ->and($order->facilitator_name)->toBe('Vendor One')
2416
+ ->and($order->customer_is_contact)->toBeTrue()
2417
+ ->and($order->customer_is_vendor)->toBeFalse()
2418
+ ->and($order->facilitator_is_vendor)->toBeTrue()
2419
+ ->and($order->facilitator_is_contact)->toBeFalse()
2420
+ ->and($order->pickup_name)->toBe('Pickup dock')
2421
+ ->and($order->dropoff_name)->toBe('Dropoff street')
2422
+ ->and($order->return_name)->toBe('Return counter')
2423
+ ->and($order->payload_id)->toBe('payload_public_id')
2424
+ ->and($order->purchase_rate_id)->toBe('purchase_rate_id')
2425
+ ->and($order->qr_code)->toBe('qr-data')
2426
+ ->and($order->created_by_name)->toBe('Creator')
2427
+ ->and($order->updated_by_name)->toBe('Updater')
2428
+ ->and($order->has_driver_assigned)->toBeTrue()
2429
+ ->and($order->is_scheduled)->toBeTrue()
2430
+ ->and($order->is_assigned_not_dispatched)->toBeTrue()
2431
+ ->and($order->is_not_dispatched)->toBeTrue()
2432
+ ->and($order->orchestrator_priority)->toBe(7)
2433
+ ->and($order->type)->toBe('express-delivery')
2434
+ ->and($order->status)->toBe('driver_assigned')
2435
+ ->and($order->time_window_start->toDateTimeString())->toBe('2026-02-03 09:00:00')
2436
+ ->and($order->time_window_end->toDateTimeString())->toBe('2026-02-05 17:00:00')
2437
+ ->and($timeOnlyOrder->time_window_start->toDateTimeString())->toBe('2026-02-03 10:15:00')
2438
+ ->and($timeOnlyOrder->time_window_end)->toBeNull()
2439
+ ->and($order->setRoute())->toBe($order);
2440
+
2441
+ $order->orchestrator_priority = 'not numeric';
2442
+ $order->type = null;
2443
+ $order->status = null;
2444
+
2445
+ expect($order->orchestrator_priority)->toBe(50)
2446
+ ->and($order->type)->toBe('default')
2447
+ ->and($order->status)->toBe('created');
2448
+
2449
+ $newPayload = new FleetOpsLoadedPayloadFake();
2450
+ $newPayload->uuidFake = 'new_payload_uuid';
2451
+ expect($order->setPayload($newPayload))->toBe($order)
2452
+ ->and($order->payload_uuid)->toBe('new_payload_uuid')
2453
+ ->and($order->payload)->toBe($newPayload)
2454
+ ->and($order->saved)->toBeTrue();
2455
+
2456
+ Carbon::setTestNow();
2457
+ });
2458
+
2459
+ test('order location driver and customer helper branches prefer loaded relations', function () {
2460
+ $pickup = new FleetOpsPlainPlaceFake();
2461
+ $pickup->location = new Point(1.1, 2.2);
2462
+
2463
+ $dropoff = new FleetOpsPlainPlaceFake();
2464
+ $dropoff->location = new Point(3.3, 4.4);
2465
+
2466
+ $currentWaypoint = new FleetOpsPlainPlaceFake();
2467
+ $currentWaypoint->setRawAttributes(['uuid' => 'current-waypoint'], true);
2468
+ $currentWaypoint->location = new Point(5.5, 6.6);
2469
+
2470
+ $firstWaypoint = new FleetOpsPlainPlaceFake();
2471
+ $firstWaypoint->setRawAttributes(['uuid' => 'first-waypoint'], true);
2472
+ $firstWaypoint->location = new Point(7.7, 8.8);
2473
+
2474
+ $payload = new FleetOpsLoadedPayloadFake();
2475
+ $payload->setRawAttributes(['current_waypoint_uuid' => 'current-waypoint'], true);
2476
+ $payload->setRelation('pickup', $pickup);
2477
+ $payload->setRelation('dropoff', $dropoff);
2478
+ $payload->setRelation('waypoints', collect([$firstWaypoint, $currentWaypoint]));
2479
+
2480
+ $driver = new Driver();
2481
+ $driver->setRawAttributes([
2482
+ 'uuid' => 'driver-uuid',
2483
+ 'public_id' => 'driver-public',
2484
+ ], true);
2485
+ $driver->location = new Point(9.9, 10.1);
2486
+
2487
+ $order = new Order([
2488
+ 'driver_assigned_uuid' => 'driver-uuid',
2489
+ 'adhoc' => true,
2490
+ ]);
2491
+ $order->setRelation('payload', $payload);
2492
+ $order->setRelation('driverAssigned', $driver);
2493
+
2494
+ $waypointOnlyPayload = new FleetOpsLoadedPayloadFake();
2495
+ $waypointOnlyPayload->setRawAttributes(['current_waypoint_uuid' => 'current-waypoint'], true);
2496
+ $waypointOnlyPayload->setRelation('dropoff', null);
2497
+ $waypointOnlyPayload->setRelation('pickup', null);
2498
+ $waypointOnlyPayload->setRelation('waypoints', collect([$firstWaypoint, $currentWaypoint]));
2499
+
2500
+ $waypointOnlyOrder = new Order();
2501
+ $waypointOnlyOrder->setRelation('payload', $waypointOnlyPayload);
2502
+ $waypointOnlyOrder->setRelation('driverAssigned', null);
2503
+
2504
+ $firstWaypointPayload = new FleetOpsLoadedPayloadFake();
2505
+ $firstWaypointPayload->setRelation('dropoff', null);
2506
+ $firstWaypointPayload->setRelation('pickup', null);
2507
+ $firstWaypointPayload->setRelation('waypoints', collect([$firstWaypoint]));
2508
+
2509
+ $firstWaypointOrder = new Order();
2510
+ $firstWaypointOrder->setRelation('payload', $firstWaypointPayload);
2511
+ $firstWaypointOrder->setRelation('driverAssigned', null);
2512
+
2513
+ $emptyPayload = new FleetOpsLoadedPayloadFake();
2514
+ $emptyPayload->setRelation('dropoff', null);
2515
+ $emptyPayload->setRelation('pickup', null);
2516
+ $emptyPayload->setRelation('waypoints', collect());
2517
+
2518
+ $emptyOrder = new Order();
2519
+ $emptyOrder->setRelation('payload', $emptyPayload);
2520
+ $emptyOrder->setRelation('driverAssigned', null);
2521
+
2522
+ $customer = new Contact();
2523
+ $customer->setRawAttributes(['uuid' => 'contact-uuid'], true);
2524
+
2525
+ $customerOrder = new Order();
2526
+ $customerOrder->setCustomer($customer);
2527
+ $customerOrder->customer_type = 'vendor';
2528
+ $customerOrder->facilitator_type = 'contact';
2529
+
2530
+ expect($order->getCurrentDestinationLocation())->toBe($dropoff->location)
2531
+ ->and($order->getLastLocation())->toBe($driver->location)
2532
+ ->and($order->isDriver($driver))->toBeTrue()
2533
+ ->and($order->isDriver('driver-uuid'))->toBeTrue()
2534
+ ->and($order->isDriver('driver-public'))->toBeTrue()
2535
+ ->and($order->isDriver($order->driverAssigned))->toBeTrue()
2536
+ ->and($order->isDriver('other-driver'))->toBeFalse()
2537
+ ->and($order->is_ready_for_dispatch)->toBeTrue()
2538
+ ->and($waypointOnlyOrder->getCurrentDestinationLocation())->toBe($currentWaypoint->location)
2539
+ ->and($waypointOnlyOrder->getLastLocation())->toBe($currentWaypoint->location)
2540
+ ->and($firstWaypointOrder->getCurrentDestinationLocation())->toBe($firstWaypoint->location)
2541
+ ->and($firstWaypointOrder->getLastLocation())->toBe($firstWaypoint->location)
2542
+ ->and($emptyOrder->getCurrentDestinationLocation())->toBeInstanceOf(Point::class)
2543
+ ->and($emptyOrder->getCurrentDestinationLocation()->getLat())->toBe(0.0)
2544
+ ->and($emptyOrder->getLastLocation())->toBeInstanceOf(Point::class)
2545
+ ->and($emptyOrder->getLastLocation()->getLng())->toBe(0.0)
2546
+ ->and($customerOrder->customer_uuid)->toBe('contact-uuid')
2547
+ ->and($customerOrder->customer_type)->toBe(Vendor::class)
2548
+ ->and($customerOrder->facilitator_type)->toBe(Contact::class);
2549
+ });
2550
+
2551
+ test('order helper accessors expose cache integrated vendor and dispatch branch contracts', function () {
2552
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
2553
+
2554
+ $order = new FleetOpsSavingOrderFake([
2555
+ 'uuid' => 'order-uuid',
2556
+ 'payload_uuid' => null,
2557
+ 'driver_assigned_uuid' => null,
2558
+ 'dispatched_at' => '2026-02-03 10:00:00',
2559
+ 'scheduled_at' => null,
2560
+ 'adhoc' => false,
2561
+ 'adhoc_distance' => 1250,
2562
+ 'facilitator_type' => IntegratedVendor::class,
2563
+ ]);
2564
+ $order->cacheValues['payload.total_entities'] = '8';
2565
+
2566
+ $company = (object) [
2567
+ 'options' => [
2568
+ 'fleetops' => [
2569
+ 'adhoc_distance' => 4321,
2570
+ ],
2571
+ ],
2572
+ ];
2573
+ $order->setRelation('company', $company);
2574
+
2575
+ $companyBackedOrder = new FleetOpsSavingOrderFake([
2576
+ 'driver_assigned_uuid' => null,
2577
+ 'dispatched_at' => '2026-02-03 10:00:00',
2578
+ 'scheduled_at' => null,
2579
+ 'adhoc' => false,
2580
+ 'facilitator_type' => Contact::class,
2581
+ ]);
2582
+ $companyBackedOrder->setRelation('company', $company);
2583
+
2584
+ $readyByAdhocOrder = new FleetOpsSavingOrderFake([
2585
+ 'driver_assigned_uuid' => null,
2586
+ 'dispatched_at' => null,
2587
+ 'scheduled_at' => null,
2588
+ 'adhoc' => true,
2589
+ 'facilitator_type' => Vendor::class,
2590
+ ]);
2591
+
2592
+ $payload = new FleetOpsLoadedPayloadFake();
2593
+ $payload->uuidFake = 'payload-loaded-uuid';
2594
+
2595
+ $payloadBackedOrder = new FleetOpsSavingOrderFake([
2596
+ 'payload_uuid' => null,
2597
+ ]);
2598
+ $payloadBackedOrder->setRelation('payload', $payload);
2599
+
2600
+ $payloadCallbackValue = null;
2601
+ $missingCallbackValue = 'unchanged';
2602
+
2603
+ expect($order->total_entities)->toBe(8)
2604
+ ->and($order->facilitator_is_integrated_vendor)->toBeTrue()
2605
+ ->and($order->is_integrated_vendor_order)->toBeTrue()
2606
+ ->and($order->isIntegratedVendorOrder())->toBeTrue()
2607
+ ->and($order->getAdhocDistance())->toBe(1250)
2608
+ ->and($order->getAdhocPingDistance())->toBe(1250)
2609
+ ->and($order->has_driver_assigned)->toBeFalse()
2610
+ ->and($order->is_ready_for_dispatch)->toBeFalse()
2611
+ ->and($order->is_scheduled)->toBeFalse()
2612
+ ->and($order->is_assigned_not_dispatched)->toBeFalse()
2613
+ ->and($order->is_not_dispatched)->toBeFalse()
2614
+ ->and($order->getPayload(function ($payload) use (&$missingCallbackValue) {
2615
+ $missingCallbackValue = $payload;
2616
+ }))->toBeNull()
2617
+ ->and($missingCallbackValue)->toBeNull()
2618
+ ->and($order->loadedMissing)->toBe(['payload'])
2619
+ ->and($companyBackedOrder->getAdhocDistance())->toBe(4321)
2620
+ ->and($companyBackedOrder->getAdhocPingDistance())->toBe(4321)
2621
+ ->and($companyBackedOrder->facilitator_is_integrated_vendor)->toBeFalse()
2622
+ ->and($companyBackedOrder->is_integrated_vendor_order)->toBeFalse()
2623
+ ->and($readyByAdhocOrder->is_ready_for_dispatch)->toBeTrue()
2624
+ ->and($readyByAdhocOrder->is_not_dispatched)->toBeTrue()
2625
+ ->and($payloadBackedOrder->getPayload(function ($payload) use (&$payloadCallbackValue) {
2626
+ $payloadCallbackValue = $payload;
2627
+ }))->toBe($payload)
2628
+ ->and($payloadCallbackValue)->toBe($payload)
2629
+ ->and($payloadBackedOrder->loadedMissing)->toBe(['payload'])
2630
+ ->and($order->driverAssigned()->getForeignKeyName())->toBe('driver_assigned_uuid')
2631
+ ->and($order->vehicleAssigned()->getForeignKeyName())->toBe('vehicle_assigned_uuid')
2632
+ ->and($order->trackingStatuses()->getForeignKeyName())->toBe('tracking_number_uuid')
2633
+ ->and($order->authenticatableCustomer()->getForeignKeyName())->toBe('customer_uuid')
2634
+ ->and($order->comments()->getForeignKeyName())->toBe('subject_uuid')
2635
+ ->and($order->proofs()->getForeignKeyName())->toBe('subject_uuid');
2636
+ });
2637
+
2638
+ test('order assignment payload position and activity branches remain stable without persistence', function () {
2639
+ Carbon::setTestNow(Carbon::parse('2026-02-03 12:00:00'));
2640
+
2641
+ $driver = new Driver();
2642
+ $driver->setRawAttributes([
2643
+ 'uuid' => 'driver-uuid',
2644
+ 'public_id' => 'driver_public',
2645
+ ], true);
2646
+ $driver->location = new Point(12.34, 56.78);
2647
+
2648
+ $payload = new FleetOpsOrderPayloadPositionFake();
2649
+ $payload->pickupFromDriver = true;
2650
+ $payload->origin = new FleetOpsPlainPlaceFake();
2651
+ $payload->origin->location = new Point(1.23, 4.56);
2652
+ $payload->destination = new FleetOpsPlainPlaceFake();
2653
+ $payload->destination->location = new Point(7.89, 0.12);
2654
+
2655
+ $order = new FleetOpsActivityOrderFake([
2656
+ 'uuid' => 'order-uuid',
2657
+ 'driver_assigned_uuid' => 'driver-uuid',
2658
+ 'dispatched' => false,
2659
+ ]);
2660
+ $order->setRelation('driverAssigned', $driver);
2661
+ $order->setRelation('payload', $payload);
2662
+
2663
+ expect($order->assignDriver($driver, true))->toBe($order)
2664
+ ->and($order->saved)->toBeFalse();
2665
+
2666
+ $replacement = new Driver();
2667
+ $replacement->setRawAttributes(['uuid' => 'driver-two'], true);
2668
+ $replacement->location = new Point(9.87, 6.54);
2669
+
2670
+ expect($order->assignDriver($replacement, true))->toBe($order)
2671
+ ->and($order->driver_assigned_uuid)->toBe('driver-two')
2672
+ ->and($order->driverAssigned)->toBe($replacement)
2673
+ ->and($order->saved)->toBeTrue();
2674
+
2675
+ $uuidOrder = new FleetOpsActivityOrderFake();
2676
+ expect($uuidOrder->assignDriver('driver-uuid-string', true))->toBe($uuidOrder)
2677
+ ->and($uuidOrder->driver_assigned_uuid)->toBe('driver-uuid-string')
2678
+ ->and($uuidOrder->saved)->toBeTrue();
2679
+
2680
+ $order->setDriverLocationAsPickup(true);
2681
+ $order->syncOriginalAttribute('driver_assigned_uuid');
2682
+ $order->driver_assigned_uuid = 'driver-three';
2683
+ $order->setRelation('driverAssigned', $replacement);
2684
+ $order->setDriverLocationAsPickup();
2685
+
2686
+ expect($payload->pickupAssignments)->toHaveCount(3)
2687
+ ->and($payload->pickupAssignments[0])->toBe([$replacement->location, ['save' => true]])
2688
+ ->and($payload->pickupAssignments[1])->toBe([$replacement->location, ['save' => true]])
2689
+ ->and($payload->pickupAssignments[2])->toBe([$replacement->location, ['save' => true]])
2690
+ ->and($order->isPickupIsFromDriverLocation())->toBeTrue()
2691
+ ->and($order->getCurrentOriginPosition())->toBe($replacement->location)
2692
+ ->and($order->getDestinationPosition())->toBe($payload->destination->location);
2693
+
2694
+ $noDriverOrder = new FleetOpsActivityOrderFake(['driver_assigned_uuid' => null]);
2695
+ $noDriverOrder->setRelation('payload', $payload);
2696
+
2697
+ expect($noDriverOrder->getCurrentOriginPosition())->toBe($payload->origin->location)
2698
+ ->and($noDriverOrder->getDestinationPosition())->toBe($payload->destination->location);
2699
+
2700
+ $activity = new Activity(['code' => 'arrived', 'events' => []]);
2701
+
2702
+ expect($order->updateActivity(null))->toBe($order)
2703
+ ->and($order->insertedActivities)->toBe([]);
2704
+
2705
+ expect($order->updateActivity($activity, 'proof-public'))->toBe($order)
2706
+ ->and($order->insertedActivities[0][0])->toBe('arrived')
2707
+ ->and($order->insertedActivities[0][2])->toBe('proof-public')
2708
+ ->and($order->statuses[0])->toBe(['arrived', true]);
2709
+
2710
+ Carbon::setTestNow();
2711
+ });
2712
+
2713
+ test('order status updates use configured activities and dispatch readiness branches', function () {
2714
+ $config = new FleetOpsActivityOrderConfigFake();
2715
+ $created = new Activity(['code' => 'created', 'events' => []]);
2716
+ $dispatched = new Activity(['code' => 'dispatched', 'events' => []]);
2717
+ $config->flow = [$created, $dispatched];
2718
+ $config->next = $created;
2719
+
2720
+ $order = new FleetOpsActivityOrderFake(['adhoc' => true]);
2721
+ $order->fakeConfig = $config;
2722
+ $payload = new FleetOpsLoadedPayloadFake();
2723
+ $payload->setRelation('pickup', null);
2724
+ $payload->setRelation('waypoints', collect());
2725
+ $order->setRelation('payload', $payload);
2726
+ $order->setRelation('driverAssigned', null);
2727
+
2728
+ expect($order->updateStatus())->toBeFalse()
2729
+ ->and($order->updateStatus('dispatched'))->toBeTrue()
2730
+ ->and($order->dispatches)->toBe(1)
2731
+ ->and($order->statuses[0])->toBe(['dispatched', true])
2732
+ ->and($order->insertedActivities[0][0])->toBe('dispatched')
2733
+ ->and($order->updateStatus(['created', 'missing']))->toBeFalse();
2734
+
2735
+ $singleConfig = new FleetOpsActivityOrderConfigFake();
2736
+ $singleConfig->flow = [$created];
2737
+
2738
+ $singleFlowOrder = new FleetOpsActivityOrderFake();
2739
+ $singleFlowOrder->fakeConfig = $singleConfig;
2740
+ $singlePayload = new FleetOpsLoadedPayloadFake();
2741
+ $singlePayload->setRelation('pickup', null);
2742
+ $singlePayload->setRelation('waypoints', collect());
2743
+ $singleFlowOrder->setRelation('payload', $singlePayload);
2744
+ $singleFlowOrder->setRelation('driverAssigned', null);
2745
+
2746
+ expect($singleFlowOrder->updateStatus())->toBeTrue()
2747
+ ->and($singleFlowOrder->statuses[0])->toBe(['created', true]);
2748
+ });
2749
+
2750
+ test('payload and place pure accessors normalize fallback data', function () {
2751
+ $pickup = new FleetOpsPlainPlaceFake();
2752
+ $pickup->setRawAttributes(['name' => 'Pickup name', 'country' => 'SG', 'uuid' => '11111111-1111-4111-8111-111111111111'], true);
2753
+
2754
+ $dropoff = new FleetOpsPlainPlaceFake();
2755
+ $dropoff->setRawAttributes(['street1' => 'Dropoff street', 'country' => 'MY', 'uuid' => '22222222-2222-4222-8222-222222222222'], true);
2756
+
2757
+ $return = new FleetOpsPlainPlaceFake(['street1' => 'Return address']);
2758
+
2759
+ $waypoint = new Place();
2760
+ $waypoint->setRawAttributes(['name' => 'Waypoint one', 'uuid' => '33333333-3333-4333-8333-333333333333'], true);
2761
+
2762
+ $payload = new FleetOpsLoadedPayloadFake(['cod_amount' => '$12.34']);
2763
+ $payload->setRelation('pickup', $pickup);
2764
+ $payload->setRelation('dropoff', $dropoff);
2765
+ $payload->setRelation('return', $return);
2766
+ $payload->setRelation('waypoints', collect([
2767
+ $waypoint,
2768
+ ['name' => 'Waypoint array'],
2769
+ (object) ['name' => 'Ignored object'],
2770
+ ]));
2771
+
2772
+ expect($payload->cod_amount)->toBe(1234)
2773
+ ->and($payload->pickup_name)->toBe('Pickup name')
2774
+ ->and($payload->dropoff_name)->toBe('Dropoff street')
2775
+ ->and($payload->return_name)->toBe('Return address')
2776
+ ->and($payload->getPickupRegion())->toBe('SG')
2777
+ ->and($payload->getCountryCode())->toBe('SG')
2778
+ ->and($payload->index_pickup_place)->toBe($pickup)
2779
+ ->and($payload->index_dropoff_place)->toBe($dropoff)
2780
+ ->and($payload->is_multiple_drop_order)->toBeFalse()
2781
+ ->and($payload->getAllStops())->toHaveCount(4)
2782
+ ->and($payload->getPickupLocation())->toBeInstanceOf(Point::class)
2783
+ ->and($payload->findDestinationFromKey())->toBeNull()
2784
+ ->and($payload->findDestinationFromKey('0'))->toBe($waypoint)
2785
+ ->and($payload->findDestinationFromKey('pickup'))->toBe($pickup)
2786
+ ->and($payload->findDestinationFromKey('dropoff'))->toBe($dropoff)
2787
+ ->and($payload->findDestinationFromKey('33333333-3333-4333-8333-333333333333'))->toBe($waypoint)
2788
+ ->and($payload->findDestinationFromKey('22222222-2222-4222-8222-222222222222'))->toBe($dropoff);
2789
+
2790
+ $place = new Place([
2791
+ 'street1' => ' 1 Main Street ',
2792
+ 'street2' => '',
2793
+ 'city' => ' Singapore ',
2794
+ 'country' => ' SG ',
2795
+ 'postal_code' => null,
2796
+ ]);
2797
+
2798
+ expect(Place::composeGeocodingQuery($place->getAttributes()))->toBe('1 Main Street, Singapore, SG')
2799
+ ->and(Place::normalizePlaceValue(['bad']))->toBeNull()
2800
+ ->and(Place::normalizePlaceValue(' ok '))->toBe('ok')
2801
+ ->and(Place::mergeStructuredPlaceAttributes([
2802
+ 'street1' => ' Explicit ',
2803
+ 'street2' => '',
2804
+ 'city' => ' City ',
2805
+ 'phone' => ' +1555 ',
2806
+ ], [
2807
+ 'street1' => 'Geocoded',
2808
+ 'country' => 'US',
2809
+ ]))->toMatchArray([
2810
+ 'street1' => 'Explicit',
2811
+ 'city' => 'City',
2812
+ 'country' => 'US',
2813
+ 'phone' => '+1555',
2814
+ ]);
2815
+ });
2816
+
2817
+ test('shared place hydration fills only safe missing fields and zero locations', function () {
2818
+ $existing = new FleetOpsHydratablePlaceFake();
2819
+ $existing->setRawAttributes([
2820
+ 'name' => null,
2821
+ 'street1' => '1 Existing Street',
2822
+ 'street2' => null,
2823
+ 'province' => '',
2824
+ 'postal_code' => null,
2825
+ 'phone' => ' +1555000 ',
2826
+ 'location' => new Point(0, 0),
2827
+ ], true);
2828
+
2829
+ $location = new Point(1.3521, 103.8198);
2830
+
2831
+ $hydrated = Place::hydrateSharedPlace($existing, [
2832
+ 'name' => ' Warehouse A ',
2833
+ 'street2' => ' Unit 4 ',
2834
+ 'province' => ' Central ',
2835
+ 'postal_code' => ' 018956 ',
2836
+ 'phone' => '+1555999',
2837
+ 'location' => $location,
2838
+ ]);
2839
+
2840
+ expect($hydrated)->toBe($existing)
2841
+ ->and($existing->name)->toBe('Warehouse A')
2842
+ ->and($existing->street2)->toBe('Unit 4')
2843
+ ->and($existing->province)->toBe('Central')
2844
+ ->and($existing->postal_code)->toBe('018956')
2845
+ ->and($existing->phone)->toBe(' +1555000 ')
2846
+ ->and($existing->location)->toBe($location)
2847
+ ->and($existing->saved)->toBeTrue();
2848
+
2849
+ $complete = new FleetOpsHydratablePlaceFake();
2850
+ $complete->setRawAttributes([
2851
+ 'name' => 'Complete Place',
2852
+ 'street2' => 'Level 2',
2853
+ 'location' => new Point(1, 2),
2854
+ ], true);
2855
+
2856
+ Place::hydrateSharedPlace($complete, [
2857
+ 'name' => 'Ignored',
2858
+ 'street2' => 'Ignored',
2859
+ 'location' => new Point(3, 4),
2860
+ ]);
2861
+
2862
+ expect($complete->name)->toBe('Complete Place')
2863
+ ->and($complete->street2)->toBe('Level 2')
2864
+ ->and($complete->saved)->toBeFalse();
2865
+ });
2866
+
2867
+ test('place import rows use aliases coordinates metadata and save options without geocoding', function () {
2868
+ FleetOpsImportablePlaceFake::$lastGeocodedAddress = null;
2869
+
2870
+ $imported = FleetOpsImportablePlaceFake::createFromImportRow([
2871
+ 'house_number' => '42',
2872
+ 'street' => 'Depot Road',
2873
+ 'unit_number' => 'Dock 5',
2874
+ 'town' => 'Singapore',
2875
+ 'district' => 'Central',
2876
+ 'state' => 'SG',
2877
+ 'zip' => '018956',
2878
+ 'lat' => '1.3001',
2879
+ 'lng' => '103.8002',
2880
+ 'mobile_number' => '+6512345678',
2881
+ 'custom_context' => 'fragile',
2882
+ ], 'import-uuid', 'SG');
2883
+
2884
+ expect(FleetOpsImportablePlaceFake::$lastGeocodedAddress)->toBe('1.3001, 103.8002')
2885
+ ->and($imported)->toBeInstanceOf(FleetOpsImportablePlaceFake::class)
2886
+ ->and($imported->street1)->toBe('Depot Road')
2887
+ ->and($imported->street2)->toBe('Dock 5')
2888
+ ->and($imported->city)->toBe('Singapore')
2889
+ ->and($imported->neighborhood)->toBe('Central')
2890
+ ->and($imported->province)->toBe('SG')
2891
+ ->and($imported->postal_code)->toBe('018956')
2892
+ ->and($imported->phone)->toBe('+6512345678')
2893
+ ->and($imported->location)->not->toBeNull()
2894
+ ->and($imported->getAttribute('_import_id'))->toBe('import-uuid')
2895
+ ->and(data_get($imported->meta, 'custom_context'))->toBe('fragile');
2896
+
2897
+ FleetOpsImportablePlaceFake::$lastGeocodedAddress = null;
2898
+
2899
+ $saved = FleetOpsImportablePlaceFake::createFromImport([
2900
+ 'street' => '55 Warehouse Way',
2901
+ 'city' => 'Singapore',
2902
+ 'phone' => '+6500000000',
2903
+ ], true);
2904
+
2905
+ expect(FleetOpsImportablePlaceFake::$lastGeocodedAddress)->toBe('55 Warehouse Way Singapore')
2906
+ ->and($saved)->toBeInstanceOf(FleetOpsImportablePlaceFake::class)
2907
+ ->and($saved->company_uuid)->toBe(session('company'))
2908
+ ->and($saved->saved)->toBeTrue();
2909
+ });
2910
+
2911
+ test('telematic model accessors relationships scopes and heartbeat contracts are stable', function () {
2912
+ fleetopsModelAccessorsUseInMemoryRelationConnection();
2913
+ Carbon::setTestNow(Carbon::parse('2026-07-26 12:00:00'));
2914
+
2915
+ app()->instance(TelematicProviderRegistry::class, new FleetOpsModelAccessorTelematicRegistryFake(new class {
2916
+ public function toArray(): array
2917
+ {
2918
+ return [
2919
+ 'key' => 'safee',
2920
+ 'label' => 'Safee',
2921
+ 'supports_webhooks' => true,
2922
+ 'supports_discovery' => true,
2923
+ 'metadata' => ['region' => 'sg'],
2924
+ ];
2925
+ }
2926
+ }));
2927
+
2928
+ $telematic = new FleetOpsTelematicUpdatingFake();
2929
+ $telematic->setRawAttributes([
2930
+ 'uuid' => 'telematic-uuid',
2931
+ 'provider' => 'safee',
2932
+ 'firmware_version' => '1.2.0',
2933
+ 'last_seen_at' => Carbon::parse('2026-07-26 11:58:00'),
2934
+ 'last_metrics' => ['signal_strength' => 87, 'lat' => 1.31, 'lng' => 103.82],
2935
+ 'config' => [
2936
+ 'supported_features' => ['ignition', 'fuel'],
2937
+ 'latest_firmware' => '1.3.0',
2938
+ ],
2939
+ ], true);
2940
+ $telematic->setRelation('warranty', (object) ['name' => 'Gold Coverage']);
2941
+
2942
+ expect($telematic->warranty())->toBeInstanceOf(BelongsTo::class)
2943
+ ->and($telematic->createdBy()->getForeignKeyName())->toBe('created_by_uuid')
2944
+ ->and($telematic->updatedBy()->getForeignKeyName())->toBe('updated_by_uuid')
2945
+ ->and($telematic->device()->getForeignKeyName())->toBe('telematic_uuid')
2946
+ ->and($telematic->assets()->getForeignKeyName())->toBe('telematic_uuid')
2947
+ ->and($telematic->provider_descriptor)->toMatchArray([
2948
+ 'key' => 'safee',
2949
+ 'label' => 'Safee',
2950
+ 'supports_webhooks' => true,
2951
+ 'supports_discovery' => true,
2952
+ 'metadata' => ['region' => 'sg'],
2953
+ ])
2954
+ ->and($telematic->warranty_name)->toBe('Gold Coverage')
2955
+ ->and($telematic->is_online)->toBeTrue()
2956
+ ->and($telematic->signal_strength)->toBe(87)
2957
+ ->and($telematic->last_location)->toBe([
2958
+ 'latitude' => 1.31,
2959
+ 'longitude' => 103.82,
2960
+ 'timestamp' => '2026-07-26T11:58:00.000000Z',
2961
+ ])
2962
+ ->and($telematic->getConnectionStatus())->toBe('online')
2963
+ ->and($telematic->supportsFeature('fuel'))->toBeTrue()
2964
+ ->and($telematic->supportsFeature('doors'))->toBeFalse()
2965
+ ->and($telematic->getFirmwareStatus())->toBe([
2966
+ 'current_version' => '1.2.0',
2967
+ 'latest_version' => '1.3.0',
2968
+ 'update_available' => true,
2969
+ ])
2970
+ ->and($telematic->getActivitylogOptions())->toBeInstanceOf(Spatie\Activitylog\LogOptions::class);
2971
+
2972
+ $offline = new Telematic();
2973
+ $offline->setRawAttributes(['last_seen_at' => Carbon::parse('2026-07-26 11:30:00')], true);
2974
+ $old = new Telematic();
2975
+ $old->setRawAttributes(['last_seen_at' => Carbon::parse('2026-07-26 05:00:00')], true);
2976
+ $longOffline = new Telematic();
2977
+ $longOffline->setRawAttributes(['last_seen_at' => Carbon::parse('2026-07-24 05:00:00')], true);
2978
+
2979
+ expect((new Telematic())->is_online)->toBeFalse()
2980
+ ->and((new Telematic())->last_location)->toBeNull()
2981
+ ->and((new Telematic())->getProviderDescriptorAttribute())->toBe([])
2982
+ ->and((new Telematic())->getConnectionStatus())->toBe('never_connected')
2983
+ ->and($offline->getConnectionStatus())->toBe('recently_offline')
2984
+ ->and($old->getConnectionStatus())->toBe('offline')
2985
+ ->and($longOffline->getConnectionStatus())->toBe('long_offline');
2986
+
2987
+ $telematic->updateHeartbeat(['battery' => 92]);
2988
+
2989
+ expect($telematic->updated['last_seen_at']->toDateTimeString())->toBe('2026-07-26 12:00:00')
2990
+ ->and($telematic->updated['last_metrics'])->toMatchArray([
2991
+ 'signal_strength' => 87,
2992
+ 'lat' => 1.31,
2993
+ 'lng' => 103.82,
2994
+ 'battery' => 92,
2995
+ ]);
2996
+
2997
+ $onlineQuery = new FleetOpsTelematicQueryFake();
2998
+ (new Telematic())->scopeOnline($onlineQuery);
2999
+
3000
+ $providerQuery = new FleetOpsTelematicQueryFake();
3001
+ (new Telematic())->scopeByProvider($providerQuery, 'safee');
3002
+
3003
+ $offlineQuery = new FleetOpsTelematicQueryFake();
3004
+ (new Telematic())->scopeOffline($offlineQuery);
3005
+ $offlineCallback = $offlineQuery->calls[0][1][0];
3006
+ $nestedQuery = new FleetOpsTelematicQueryFake();
3007
+ $offlineCallback($nestedQuery);
3008
+
3009
+ expect($onlineQuery->calls[0][0])->toBe('where')
3010
+ ->and($onlineQuery->calls[0][1][0])->toBe('last_seen_at')
3011
+ ->and($onlineQuery->calls[0][1][1])->toBe('>=')
3012
+ ->and($onlineQuery->calls[0][1][2]->toDateTimeString())->toBe('2026-07-26 11:55:00')
3013
+ ->and($providerQuery->calls)->toBe([
3014
+ ['where', ['provider', 'safee']],
3015
+ ])
3016
+ ->and($nestedQuery->calls[0])->toBe(['whereNull', ['last_seen_at']])
3017
+ ->and($nestedQuery->calls[1][0])->toBe('orWhere')
3018
+ ->and($nestedQuery->calls[1][1][0])->toBe('last_seen_at')
3019
+ ->and($nestedQuery->calls[1][1][1])->toBe('<')
3020
+ ->and($nestedQuery->calls[1][1][2]->toDateTimeString())->toBe('2026-07-26 11:55:00');
3021
+
3022
+ Carbon::setTestNow();
3023
+ });
3024
+
3025
+ test('service rate quote math and fee selection helpers are stable', function () {
3026
+ $rate = new FleetOpsLoadedServiceRateFake([
3027
+ 'uuid' => 'rate_uuid',
3028
+ 'base_fee' => 500,
3029
+ 'currency' => 'USD',
3030
+ 'rate_calculation_method' => 'per_meter',
3031
+ 'per_meter_flat_rate_fee' => 2,
3032
+ 'per_meter_unit' => 'km',
3033
+ 'has_cod_fee' => true,
3034
+ 'cod_calculation_method' => 'flat',
3035
+ 'cod_flat_fee' => 125,
3036
+ 'has_peak_hours_fee' => true,
3037
+ 'peak_hours_calculation_method' => 'percentage',
3038
+ 'peak_hours_percent' => 10,
3039
+ 'peak_hours_start' => '00:00',
3040
+ 'peak_hours_end' => '23:59',
3041
+ ]);
3042
+
3043
+ $shortFee = new ServiceRateFee(['distance' => 5, 'min' => 1, 'max' => 2, 'fee' => 100]);
3044
+ $shortFee->setAttribute('uuid', 'short');
3045
+ $longFee = new ServiceRateFee(['distance' => 10, 'min' => 3, 'max' => 6, 'fee' => 200]);
3046
+ $longFee->setAttribute('uuid', 'long');
3047
+ $rate->setRelation('rateFees', collect([$shortFee, $longFee]));
3048
+
3049
+ [$total, $lines] = $rate->quoteFromPreliminaryData(
3050
+ [(object) ['type' => 'parcel', 'weight' => 2, 'weight_unit' => 'lb']],
3051
+ [new Place(['name' => 'A']), new Place(['name' => 'B']), new Place(['name' => 'C'])],
3052
+ 2500,
3053
+ 600,
3054
+ true
3055
+ );
3056
+
3057
+ $reflection = new ReflectionClass(ServiceRate::class);
3058
+ $distanceNormalizer = $reflection->getMethod('normalizeDistanceForUnit');
3059
+ $moneyNormalizer = $reflection->getMethod('normalizeCalculatedMoney');
3060
+ $variableBuilder = $reflection->getMethod('buildAlgorithmVariables');
3061
+ $endpointInferrer = $reflection->getMethod('inferEndpointCountFromStops');
3062
+ $weightNormalizer = $reflection->getMethod('normalizeEntityWeightToKilograms');
3063
+ $haversine = $reflection->getMethod('haversineDistanceInMeters');
3064
+ $interpolator = $reflection->getMethod('interpolateLngLat');
3065
+
3066
+ expect($total)->toBe(681)
3067
+ ->and($lines)->toHaveCount(4)
3068
+ ->and($rate->findServiceRateFeeByDistance(7500)->uuid)->toBe('long')
3069
+ ->and($rate->findServiceRateFeeByDistance(12000)->uuid)->toBe('long')
3070
+ ->and($rate->findServiceRateFeeByMinMax(4)->uuid)->toBe('long')
3071
+ ->and($rate->findServiceRateFeeByMinMax(99)->uuid)->toBe('long')
3072
+ ->and($distanceNormalizer->invoke($rate, 1000, 'km'))->toBe(1.0)
3073
+ ->and(round($distanceNormalizer->invoke($rate, 1609.344, 'mi'), 3))->toBe(1.0)
3074
+ ->and($moneyNormalizer->invoke($rate, 10.6))->toBe(11)
3075
+ ->and($endpointInferrer->invoke($rate, [1, 2, 3]))->toBe(2)
3076
+ ->and($endpointInferrer->invoke($rate, [1]))->toBe(1)
3077
+ ->and(round($weightNormalizer->invoke($rate, ['weight' => 1000, 'weight_unit' => 'g']), 2))->toBe(1.0)
3078
+ ->and(round($weightNormalizer->invoke($rate, ['weight' => 16, 'weight_unit' => 'oz']), 4))->toBe(0.4536)
3079
+ ->and($interpolator->invoke($rate, ['lat' => 0, 'lng' => 0], ['lat' => 10, 'lng' => 20], 0.25))->toBe(['lat' => 2.5, 'lng' => 5.0])
3080
+ ->and((int) round($haversine->invoke($rate, 0, 0, 0, 1)))->toBe(111195);
3081
+
3082
+ $variables = $variableBuilder->invoke($rate, [
3083
+ ['type' => 'parcel', 'weight' => 1000, 'weight_unit' => 'g'],
3084
+ ['type' => 'item', 'weight' => 2, 'weight_unit' => 'kg'],
3085
+ ], [new Place(), new Place(), new Place()], 1200, 300, 2);
3086
+
3087
+ expect($variables)->toMatchArray([
3088
+ 'distance_m' => 1200,
3089
+ 'time_s' => 300,
3090
+ 'stops' => 3,
3091
+ 'waypoints' => 1,
3092
+ 'parcels' => 1,
3093
+ 'entities' => 2,
3094
+ 'base_fee' => 500,
3095
+ 'weight_kg' => 3.0,
3096
+ ]);
3097
+ });
3098
+
3099
+ test('purchase rate accessors expose relation identifiers and customer type flags', function () {
3100
+ $rate = new PurchaseRate();
3101
+ $rate->setRawAttributes([
3102
+ 'customer_type' => Vendor::class,
3103
+ ], true);
3104
+
3105
+ expect($rate->getCustomerIsVendorAttribute())->toBeTrue()
3106
+ ->and($rate->getCustomerIsContactAttribute())->toBeFalse()
3107
+ ->and($rate->getAmountAttribute())->toBe(0)
3108
+ ->and($rate->getCurrencyAttribute())->toBeNull()
3109
+ ->and($rate->getServiceQuoteIdAttribute())->toBeNull()
3110
+ ->and($rate->getOrderIdAttribute())->toBeNull()
3111
+ ->and($rate->getCustomerIdAttribute())->toBeNull()
3112
+ ->and($rate->getTransactionIdAttribute())->toBeNull();
3113
+
3114
+ $rate->setRawAttributes([
3115
+ 'customer_type' => Contact::class,
3116
+ ], true);
3117
+ $rate->setRelation('serviceQuote', (object) [
3118
+ 'amount' => 1299,
3119
+ 'currency' => 'USD',
3120
+ 'public_id' => 'quote_public',
3121
+ ]);
3122
+ $rate->setRelation('order', (object) [
3123
+ 'public_id' => 'order_public',
3124
+ ]);
3125
+ $rate->setRelation('customer', (object) [
3126
+ 'public_id' => 'contact_public',
3127
+ ]);
3128
+ $rate->setRelation('transaction', (object) [
3129
+ 'public_id' => 'transaction_public',
3130
+ ]);
3131
+
3132
+ expect($rate->getCustomerIsVendorAttribute())->toBeFalse()
3133
+ ->and($rate->getCustomerIsContactAttribute())->toBeTrue()
3134
+ ->and($rate->getAmountAttribute())->toBe(1299)
3135
+ ->and($rate->getCurrencyAttribute())->toBe('USD')
3136
+ ->and($rate->getServiceQuoteIdAttribute())->toBe('quote_public')
3137
+ ->and($rate->getOrderIdAttribute())->toBe('order_public')
3138
+ ->and($rate->getCustomerIdAttribute())->toBe('contact_public')
3139
+ ->and($rate->getTransactionIdAttribute())->toBe('transaction_public');
3140
+ });
3141
+
3142
+ test('tracking number and status accessors normalize status contracts', function () {
3143
+ $createdAt = Carbon::parse('2026-08-01 12:30:00');
3144
+ $status = new TrackingStatus();
3145
+ $status->setRawAttributes([
3146
+ 'status' => 'Out for Delivery',
3147
+ 'code' => 'OUT_FOR_DELIVERY',
3148
+ 'complete' => true,
3149
+ 'created_at' => $createdAt,
3150
+ ], true);
3151
+
3152
+ $trackingNumber = new FleetOpsTrackingNumberAccessorFake();
3153
+ $trackingNumber->setRawAttributes([
3154
+ 'owner_type' => Order::class,
3155
+ ], true);
3156
+ $trackingNumber->setRelation('status', $status);
3157
+
3158
+ expect($trackingNumber->getLastStatusAttribute())->toBe('Out for Delivery')
3159
+ ->and($trackingNumber->getLastStatusCodeAttribute())->toBe('OUT_FOR_DELIVERY')
3160
+ ->and($trackingNumber->getLastStatusUpdatedAtAttribute()->toDateTimeString())->toBe($createdAt->toDateTimeString())
3161
+ ->and($trackingNumber->getLastStatusCompleteAttribute())->toBeTrue()
3162
+ ->and($trackingNumber->getTypeAttribute())->toBe('order')
3163
+ ->and($trackingNumber->loaded)->toBe([
3164
+ 'status',
3165
+ 'status',
3166
+ 'status',
3167
+ 'status',
3168
+ ]);
3169
+
3170
+ $normalized = new TrackingStatus();
3171
+ $normalized->setCodeAttribute(' Out for delivery! ');
3172
+ $normalized->setStatusAttribute('out for delivery');
3173
+
3174
+ expect($normalized->getAttribute('code'))->toBe('_OUT_FOR_DELIVERY_')
3175
+ ->and($normalized->getAttribute('status'))->toBe('Out For Delivery')
3176
+ ->and(TrackingStatus::prepareCode('arrived @ hub'))->toBe('ARRIVED__HUB')
3177
+ ->and($status->isComplete())->toBeTrue();
3178
+ });
3179
+
3180
+ test('tracking number updates fillable owner status when status changes', function () {
3181
+ $trackingStatus = new TrackingStatus();
3182
+ $trackingStatus->setRawAttributes([
3183
+ 'code' => 'IN_TRANSIT',
3184
+ ], true);
3185
+
3186
+ $owner = new FleetOpsTrackingNumberOwnerFake();
3187
+ $owner->setRawAttributes([
3188
+ 'uuid' => 'owner-uuid',
3189
+ 'status' => 'created',
3190
+ ], true);
3191
+
3192
+ $trackingNumber = new FleetOpsTrackingNumberAccessorFake();
3193
+ $trackingNumber->setRelation('owner', $owner);
3194
+
3195
+ expect($trackingNumber->updateOwnerStatus($trackingStatus))->toBe($trackingNumber)
3196
+ ->and($owner->status)->toBe('in_transit')
3197
+ ->and($owner->saved)->toBeTrue()
3198
+ ->and($trackingNumber->loaded)->toBe([
3199
+ ['owner'],
3200
+ ]);
3201
+ });