@fleetbase/fleetops-engine 0.6.48 → 0.6.50

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 (266) hide show
  1. package/addon/components/customer/create-order-form.hbs +2 -2
  2. package/addon/components/customer/form.hbs +2 -2
  3. package/addon/components/customer/order-form.hbs +2 -2
  4. package/addon/components/customer/orders.hbs +2 -2
  5. package/addon/components/display-place.hbs +2 -2
  6. package/addon/components/driver/details.hbs +5 -1
  7. package/addon/components/driver/form.hbs +11 -1
  8. package/addon/components/fleet-listing-panel.hbs +2 -2
  9. package/addon/components/fleet-listing-panel.js +10 -0
  10. package/addon/components/map/google-live-map.js +18 -75
  11. package/addon/components/map/leaflet-live-map.hbs +187 -37
  12. package/addon/components/map/leaflet-live-map.js +184 -20
  13. package/addon/components/modals/order-change-destination.hbs +46 -0
  14. package/addon/components/modals/order-change-destination.js +106 -0
  15. package/addon/components/modals/stop-activity.hbs +28 -0
  16. package/addon/components/modals/stop-activity.js +52 -0
  17. package/addon/components/modals/update-order-activity.hbs +45 -3
  18. package/addon/components/modals/update-order-activity.js +143 -5
  19. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  20. package/addon/components/orchestrator/phase-builder.js +13 -0
  21. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  22. package/addon/components/orchestrator/resource-panel.js +2 -4
  23. package/addon/components/orchestrator-workbench.js +1 -0
  24. package/addon/components/order/activity-list.hbs +2 -2
  25. package/addon/components/order/activity-timeline.hbs +31 -29
  26. package/addon/components/order/details/activity.hbs +3 -3
  27. package/addon/components/order/details/activity.js +11 -0
  28. package/addon/components/order/details/documents.js +2 -0
  29. package/addon/components/order/details/purchase-rate.hbs +2 -2
  30. package/addon/components/order/details/route.hbs +11 -3
  31. package/addon/components/order/details/route.js +91 -14
  32. package/addon/components/order/details/tracking.hbs +101 -72
  33. package/addon/components/order/details/tracking.js +149 -2
  34. package/addon/components/order/form/details.hbs +3 -3
  35. package/addon/components/order/form/documents.js +11 -4
  36. package/addon/components/order/form/route.hbs +108 -104
  37. package/addon/components/order/form/route.js +37 -1
  38. package/addon/components/order/form/service-rate.hbs +2 -2
  39. package/addon/components/order/form.hbs +2 -2
  40. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  41. package/addon/components/order-config-manager/activity-flow.js +178 -12
  42. package/addon/components/place/address.js +1 -1
  43. package/addon/components/route-list.hbs +17 -17
  44. package/addon/components/route-list.js +42 -10
  45. package/addon/components/service-rate/details.hbs +41 -0
  46. package/addon/components/service-rate/form.hbs +118 -0
  47. package/addon/components/service-rate/form.js +55 -0
  48. package/addon/components/vehicle/form.js +1 -1
  49. package/addon/components/vehicle/pill.hbs +1 -1
  50. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  51. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  52. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  53. package/addon/components/widget/fuel-efficiency.js +43 -0
  54. package/addon/components/widget/geofence-violations.hbs +44 -0
  55. package/addon/components/widget/geofence-violations.js +55 -0
  56. package/addon/components/widget/issues-insights.hbs +43 -0
  57. package/addon/components/widget/issues-insights.js +62 -0
  58. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  59. package/addon/components/widget/kpi-active-orders.js +5 -0
  60. package/addon/components/widget/kpi-aov.hbs +10 -0
  61. package/addon/components/widget/kpi-aov.js +5 -0
  62. package/addon/components/widget/kpi-distance.hbs +10 -0
  63. package/addon/components/widget/kpi-distance.js +5 -0
  64. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  65. package/addon/components/widget/kpi-drivers-online.js +5 -0
  66. package/addon/components/widget/kpi-earnings.hbs +10 -0
  67. package/addon/components/widget/kpi-earnings.js +5 -0
  68. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  69. package/addon/components/widget/kpi-open-issues.js +5 -0
  70. package/addon/components/widget/kpi-tile.hbs +59 -0
  71. package/addon/components/widget/kpi-tile.js +168 -0
  72. package/addon/components/widget/live-fleet.hbs +122 -0
  73. package/addon/components/widget/live-fleet.js +365 -0
  74. package/addon/components/widget/maintenance-overview.hbs +45 -0
  75. package/addon/components/widget/maintenance-overview.js +40 -0
  76. package/addon/components/widget/on-time-delivery.hbs +27 -0
  77. package/addon/components/widget/on-time-delivery.js +73 -0
  78. package/addon/components/widget/operations-pulse.hbs +51 -0
  79. package/addon/components/widget/operations-pulse.js +173 -0
  80. package/addon/components/widget/orders-by-status.hbs +26 -0
  81. package/addon/components/widget/orders-by-status.js +54 -0
  82. package/addon/components/widget/revenue-trend.hbs +34 -0
  83. package/addon/components/widget/revenue-trend.js +67 -0
  84. package/addon/components/widget/top-drivers.hbs +44 -0
  85. package/addon/components/widget/top-drivers.js +54 -0
  86. package/addon/controllers/management/vehicles/index/new.js +1 -1
  87. package/addon/controllers/operations/orders/index/details.js +4 -4
  88. package/addon/controllers/operations/orders/index.js +14 -0
  89. package/addon/controllers/settings/routing.js +26 -0
  90. package/addon/extension.js +172 -3
  91. package/addon/helpers/place-address-html.js +2 -2
  92. package/addon/routes/operations/service-rates/index.js +1 -1
  93. package/addon/services/geofence.js +163 -19
  94. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  95. package/addon/services/map-adapter/google.js +13 -2
  96. package/addon/services/vehicle-actions.js +1 -1
  97. package/addon/styles/fleetops-engine.css +205 -88
  98. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  99. package/addon/templates/maintenance/parts/index.hbs +1 -1
  100. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  101. package/addon/templates/management/vehicles/index.hbs +1 -1
  102. package/addon/templates/settings/avatars.hbs +1 -1
  103. package/addon/templates/settings/custom-fields.hbs +1 -1
  104. package/addon/templates/settings/map.hbs +1 -1
  105. package/addon/templates/settings/navigator-app.hbs +1 -1
  106. package/addon/templates/settings/notifications.hbs +1 -1
  107. package/addon/templates/settings/orchestrator.hbs +1 -1
  108. package/addon/templates/settings/payments/index.hbs +1 -1
  109. package/addon/templates/settings/payments/onboard.hbs +1 -1
  110. package/addon/templates/settings/routing.hbs +22 -2
  111. package/addon/templates/settings/scheduling.hbs +1 -1
  112. package/addon/utils/fleet-ops-options.js +2 -1
  113. package/addon/utils/live-map-card-content.js +171 -0
  114. package/addon/utils/normalize-order-config-flow.js +41 -0
  115. package/addon/utils/place-address-html.js +3 -2
  116. package/addon/utils/route-visualization.js +54 -7
  117. package/app/components/modals/order-change-destination.js +1 -0
  118. package/app/components/modals/stop-activity.js +1 -0
  119. package/app/components/widget/fuel-efficiency.js +1 -0
  120. package/app/components/widget/geofence-violations.js +1 -0
  121. package/app/components/widget/issues-insights.js +1 -0
  122. package/app/components/widget/kpi-active-orders.js +1 -0
  123. package/app/components/widget/kpi-aov.js +1 -0
  124. package/app/components/widget/kpi-distance.js +1 -0
  125. package/app/components/widget/kpi-drivers-online.js +1 -0
  126. package/app/components/widget/kpi-earnings.js +1 -0
  127. package/app/components/widget/kpi-open-issues.js +1 -0
  128. package/app/components/widget/kpi-tile.js +1 -0
  129. package/app/components/widget/live-fleet.js +1 -0
  130. package/app/components/widget/maintenance-overview.js +1 -0
  131. package/app/components/widget/on-time-delivery.js +1 -0
  132. package/app/components/widget/operations-pulse.js +1 -0
  133. package/app/components/widget/orders-by-status.js +1 -0
  134. package/app/components/widget/revenue-trend.js +1 -0
  135. package/app/components/widget/top-drivers.js +1 -0
  136. package/app/utils/live-map-card-content.js +1 -0
  137. package/composer.json +1 -1
  138. package/extension.json +1 -1
  139. package/package.json +3 -3
  140. package/pnpm-workspace.yaml +1 -0
  141. package/server/config/telematics.php +111 -1
  142. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  143. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  144. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  145. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  146. package/server/resources/views/labels/default.php +57 -49
  147. package/server/resources/views/labels/waypoint-label.php +27 -20
  148. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  149. package/server/seeders/Testing/ConnectivitySeeder.php +213 -0
  150. package/server/seeders/Testing/FleetSeeder.php +341 -0
  151. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  152. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  153. package/server/seeders/Testing/OrdersSeeder.php +198 -0
  154. package/server/seeders/Testing/TestingSeeder.php +40 -0
  155. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +29 -22
  156. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  157. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  158. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  159. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  160. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  161. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  162. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  163. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  164. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  165. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  166. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  167. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  168. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  169. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  170. package/server/src/Http/Controllers/Internal/v1/OrderController.php +441 -32
  171. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  172. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  173. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  174. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  175. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  176. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  177. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  178. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  179. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  180. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  181. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  182. package/server/src/Http/Resources/v1/Customer.php +79 -0
  183. package/server/src/Http/Resources/v1/Driver.php +2 -0
  184. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  185. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  186. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  187. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  188. package/server/src/Http/Resources/v1/Payload.php +23 -20
  189. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  190. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  191. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  192. package/server/src/Models/Contact.php +92 -15
  193. package/server/src/Models/Customer.php +64 -0
  194. package/server/src/Models/Driver.php +14 -3
  195. package/server/src/Models/Entity.php +2 -2
  196. package/server/src/Models/Order.php +29 -4
  197. package/server/src/Models/OrderConfig.php +14 -11
  198. package/server/src/Models/Payload.php +17 -1
  199. package/server/src/Models/ServiceRate.php +273 -1
  200. package/server/src/Models/ServiceRateFee.php +39 -9
  201. package/server/src/Models/TrackingNumber.php +8 -2
  202. package/server/src/Models/Vehicle.php +18 -2
  203. package/server/src/Models/Waypoint.php +2 -2
  204. package/server/src/Notifications/LateDeparture.php +48 -0
  205. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  206. package/server/src/Notifications/RouteDeviation.php +48 -0
  207. package/server/src/Observers/ContactObserver.php +2 -0
  208. package/server/src/Observers/ServiceRateObserver.php +6 -6
  209. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +29 -2
  210. package/server/src/Providers/FleetOpsServiceProvider.php +6 -0
  211. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  212. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  213. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  214. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  215. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  216. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  217. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  218. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  219. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  220. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  221. package/server/src/Support/Analytics/RevenueTrend.php +78 -0
  222. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  223. package/server/src/Support/CustomerAuth.php +98 -0
  224. package/server/src/Support/LabelPdf.php +52 -0
  225. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  226. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  227. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  228. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  229. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  230. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  231. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  232. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  233. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  234. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  235. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  236. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  237. package/server/src/Support/Metrics/Registry.php +46 -0
  238. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  239. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  240. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  241. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  242. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  243. package/server/src/Support/Metrics.php +46 -249
  244. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  245. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  246. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  247. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  248. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  249. package/server/src/Tracking/TrackingStop.php +13 -11
  250. package/server/src/routes.php +50 -0
  251. package/server/tests/AnalyticsRoutesTest.php +41 -0
  252. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  253. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  254. package/server/tests/CustomerEndpointTest.php +199 -0
  255. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  256. package/server/tests/LabelPdfRenderingTest.php +23 -0
  257. package/server/tests/LiveControllerViewportTest.php +60 -0
  258. package/server/tests/MetricsRegistryTest.php +84 -0
  259. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  260. package/server/tests/OrderConfigEndpointTest.php +86 -0
  261. package/server/tests/OrderFileAttachmentTest.php +20 -0
  262. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  263. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  264. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  265. package/translations/en-us.yaml +38 -0
  266. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -14,10 +14,10 @@ class ServiceRateObserver
14
14
  */
15
15
  public function created(ServiceRate $serviceRate)
16
16
  {
17
- $serviceRateFees = request()->input('serviceRate.rate_fees');
18
- $serviceRateParcelFees = request()->input('serviceRate.parcel_fees');
17
+ $serviceRateFees = request()->input('serviceRate.rate_fees', request()->input('service_rate.rate_fees'));
18
+ $serviceRateParcelFees = request()->input('serviceRate.parcel_fees', request()->input('service_rate.parcel_fees'));
19
19
 
20
- if ($serviceRate->isFixedMeter() || $serviceRate->isPerDrop()) {
20
+ if ($serviceRate->isFixedMeter() || $serviceRate->isPerDrop() || $serviceRate->isMultiZoneDistance()) {
21
21
  $serviceRate->setServiceRateFees($serviceRateFees);
22
22
  }
23
23
 
@@ -33,10 +33,10 @@ class ServiceRateObserver
33
33
  */
34
34
  public function updated(ServiceRate $serviceRate)
35
35
  {
36
- $serviceRateFees = request()->input('serviceRate.rate_fees');
37
- $serviceRateParcelFees = request()->input('serviceRate.parcel_fees');
36
+ $serviceRateFees = request()->input('serviceRate.rate_fees', request()->input('service_rate.rate_fees'));
37
+ $serviceRateParcelFees = request()->input('serviceRate.parcel_fees', request()->input('service_rate.parcel_fees'));
38
38
 
39
- if ($serviceRate->isFixedMeter() || $serviceRate->isPerDrop()) {
39
+ if ($serviceRate->isFixedMeter() || $serviceRate->isPerDrop() || $serviceRate->isMultiZoneDistance()) {
40
40
  $serviceRate->setServiceRateFees($serviceRateFees);
41
41
  }
42
42
 
@@ -85,7 +85,7 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
85
85
  $vroomPayload = [
86
86
  'jobs' => [],
87
87
  'shipments' => [],
88
- 'vehicles' => array_map(function (array $v) use ($profile) {
88
+ 'vehicles' => array_map(function (array $v) use ($profile) {
89
89
  $vehicle = [
90
90
  'id' => crc32($v['id']), // VROOM requires integer IDs
91
91
  'description' => json_encode(['vehicle_id' => $v['id'], 'driver_id' => $v['driver_id']]),
@@ -171,12 +171,17 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
171
171
  $vroomPayload = $this->buildCapacityOnlyPayload($capacityTasks, $vroomVehicles, $options, $jobIdReverse);
172
172
 
173
173
  if (empty($vroomPayload['vehicles'])) {
174
+ $vehiclePacking = $this->resolveVehiclePacking($options);
175
+ $vehicleFixedCost = $this->resolveVehicleFixedCost($options);
176
+
174
177
  return $this->mergeInvalidTasks([
175
178
  'assignments' => [],
176
179
  'unassigned' => array_column($capacityTasks, 'id'),
177
180
  'summary' => [
178
181
  'engine' => 'vroom',
179
182
  'allocation_strategy' => 'capacity_only',
183
+ 'vehicle_packing' => $vehiclePacking,
184
+ 'vehicle_fixed_cost' => $vehiclePacking === 'minimize_vehicles' ? $vehicleFixedCost : null,
180
185
  'assigned' => 0,
181
186
  'unassigned' => count($capacityTasks),
182
187
  'unassigned_reasons' => array_map(fn (array $task) => [
@@ -192,6 +197,8 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
192
197
  $result['summary'] = array_merge($result['summary'] ?? [], [
193
198
  'engine' => 'vroom',
194
199
  'allocation_strategy' => 'capacity_only',
200
+ 'vehicle_packing' => $this->resolveVehiclePacking($options),
201
+ 'vehicle_fixed_cost' => $this->resolveVehiclePacking($options) === 'minimize_vehicles' ? $this->resolveVehicleFixedCost($options) : null,
195
202
  ]);
196
203
 
197
204
  return $this->mergeInvalidTasks($result, $invalidTasks);
@@ -203,10 +210,12 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
203
210
  $profile = $options['profile'] ?? 'capacity_only';
204
211
  $respectCapacity = (bool) ($options['respect_capacity'] ?? true);
205
212
  $respectSkills = (bool) ($options['respect_skills'] ?? true);
213
+ $vehiclePacking = $this->resolveVehiclePacking($options);
214
+ $fixedCost = $this->resolveVehicleFixedCost($options);
206
215
 
207
216
  $vroomPayload = [
208
217
  'jobs' => [],
209
- 'vehicles' => array_map(function (array $v) use ($profile, $respectCapacity, $respectSkills) {
218
+ 'vehicles' => array_map(function (array $v) use ($profile, $respectCapacity, $respectSkills, $vehiclePacking, $fixedCost) {
210
219
  $vehicle = [
211
220
  'id' => crc32($v['id']),
212
221
  'description' => json_encode(['vehicle_id' => $v['id'], 'driver_id' => $v['driver_id'] ?? null]),
@@ -214,6 +223,10 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
214
223
  'start_index' => 0,
215
224
  ];
216
225
 
226
+ if ($vehiclePacking === 'minimize_vehicles') {
227
+ $vehicle['costs'] = ['fixed' => $fixedCost];
228
+ }
229
+
217
230
  if ($respectCapacity) {
218
231
  $vehicle['capacity'] = $v['capacity'];
219
232
  }
@@ -265,6 +278,20 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
265
278
  return $vroomPayload;
266
279
  }
267
280
 
281
+ protected function resolveVehiclePacking(array $options): string
282
+ {
283
+ $packing = strtolower((string) ($options['vehicle_packing'] ?? 'minimize_vehicles'));
284
+
285
+ return in_array($packing, ['minimize_vehicles', 'balanced', 'none'], true)
286
+ ? $packing
287
+ : 'minimize_vehicles';
288
+ }
289
+
290
+ protected function resolveVehicleFixedCost(array $options): int
291
+ {
292
+ return max(0, (int) ($options['vehicle_fixed_cost'] ?? 100000));
293
+ }
294
+
268
295
  protected function buildUniformMatrix(int $size): array
269
296
  {
270
297
  $matrix = [];
@@ -56,6 +56,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
56
56
  \Fleetbase\FleetOps\Console\Commands\FixInvalidPolymorphicRelationTypeNamespaces::class,
57
57
  \Fleetbase\FleetOps\Console\Commands\AssignDriverRoles::class,
58
58
  \Fleetbase\FleetOps\Console\Commands\AssignCustomerRoles::class,
59
+ \Fleetbase\FleetOps\Console\Commands\AuditCustomerUserConflicts::class,
59
60
  \Fleetbase\FleetOps\Console\Commands\SimulateOrderRouteNavigation::class,
60
61
  \Fleetbase\FleetOps\Console\Commands\DebugOrderTracker::class,
61
62
  \Fleetbase\FleetOps\Console\Commands\PurgeUnpurchasedServiceQuotes::class,
@@ -65,6 +66,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
65
66
  \Fleetbase\FleetOps\Console\Commands\TestEmail::class,
66
67
  \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class,
67
68
  \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class,
69
+ \Fleetbase\FleetOps\Console\Commands\ProcessOperationalAlerts::class,
68
70
  ];
69
71
 
70
72
  /**
@@ -126,6 +128,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
126
128
  $schedule->command('fleetops:purge-service-quotes')->daily()->withoutOverlapping();
127
129
  $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
128
130
  $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
131
+ $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
129
132
  });
130
133
  $this->registerNotifications();
131
134
  $this->registerExpansionsFrom(__DIR__ . '/../Expansions');
@@ -192,6 +195,9 @@ class FleetOpsServiceProvider extends CoreServiceProvider
192
195
  \Fleetbase\FleetOps\Notifications\OrderFailed::class,
193
196
  \Fleetbase\FleetOps\Notifications\OrderCompleted::class,
194
197
  \Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence::class,
198
+ \Fleetbase\FleetOps\Notifications\LateDeparture::class,
199
+ \Fleetbase\FleetOps\Notifications\RouteDeviation::class,
200
+ \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class,
195
201
  ]);
196
202
 
197
203
  // Register Notifiables
@@ -31,6 +31,9 @@ class NotificationServiceProvider extends CoreServiceProvider
31
31
  \Fleetbase\FleetOps\Notifications\OrderDispatched::class,
32
32
  \Fleetbase\FleetOps\Notifications\OrderDispatchFailed::class,
33
33
  \Fleetbase\FleetOps\Notifications\OrderPing::class,
34
+ \Fleetbase\FleetOps\Notifications\LateDeparture::class,
35
+ \Fleetbase\FleetOps\Notifications\RouteDeviation::class,
36
+ \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class,
34
37
  ]);
35
38
 
36
39
  // Register Notifiables
@@ -0,0 +1,77 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\Models\Company;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ /**
9
+ * Base for analytics-widget data providers. Unlike scalar Metrics, each
10
+ * subclass returns a widget-specific JSON shape (Chart.js datasets, ranked
11
+ * rows, scalar tiles, map points, etc.) so the base just provides company
12
+ * scoping + period parsing.
13
+ */
14
+ abstract class AbstractAnalytics
15
+ {
16
+ protected Company $company;
17
+ protected ?\DateTimeInterface $start = null;
18
+ protected ?\DateTimeInterface $end = null;
19
+
20
+ abstract public function get(): array;
21
+
22
+ public static function forCompany(Company $company): static
23
+ {
24
+ return (new static())->setCompany($company);
25
+ }
26
+
27
+ public function between(?\DateTimeInterface $start, ?\DateTimeInterface $end): static
28
+ {
29
+ $this->start = $start;
30
+ $this->end = $end;
31
+
32
+ return $this;
33
+ }
34
+
35
+ /**
36
+ * Resolve a [start, end] tuple from a `period` shorthand or explicit dates,
37
+ * falling back to a 30-day window. Shared by every analytics call.
38
+ */
39
+ public static function resolvePeriod(?string $period, ?\DateTimeInterface $start, ?\DateTimeInterface $end, int $defaultDays = 30): array
40
+ {
41
+ if ($period !== null && $period !== '') {
42
+ $days = match ($period) {
43
+ '7d' => 7,
44
+ '14d' => 14,
45
+ '30d' => 30,
46
+ '90d' => 90,
47
+ '180d' => 180,
48
+ '365d' => 365,
49
+ default => null,
50
+ };
51
+
52
+ if ($days !== null) {
53
+ return [
54
+ Carbon::now()->subDays($days)->toDateTime(),
55
+ Carbon::now()->toDateTime(),
56
+ ];
57
+ }
58
+ }
59
+
60
+ return [
61
+ $start ?? Carbon::now()->subDays($defaultDays)->toDateTime(),
62
+ $end ?? Carbon::now()->toDateTime(),
63
+ ];
64
+ }
65
+
66
+ protected function setCompany(Company $company): static
67
+ {
68
+ $this->company = $company;
69
+
70
+ return $this;
71
+ }
72
+
73
+ protected function companyCurrency(): string
74
+ {
75
+ return $this->company->currency ?? 'USD';
76
+ }
77
+ }
@@ -0,0 +1,83 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\FleetOps\Models\FuelReport;
6
+ use Fleetbase\FleetOps\Models\Order;
7
+ use Illuminate\Support\Carbon;
8
+
9
+ /**
10
+ * Dual-axis fuel chart: weekly total cost (bars) + cost-per-km (line).
11
+ * Cost-per-km is sum(FuelReport.amount in primary currency) divided by
12
+ * sum(Order.distance, in km) for that bucket.
13
+ */
14
+ class FuelEfficiency extends AbstractAnalytics
15
+ {
16
+ public function get(): array
17
+ {
18
+ $currency = $this->companyCurrency();
19
+ $start = $this->start ?? Carbon::now()->subDays(90)->toDateTime();
20
+ $end = $this->end ?? Carbon::now()->toDateTime();
21
+
22
+ $fuelByWeek = FuelReport::where('company_uuid', $this->company->uuid)
23
+ ->where('currency', $currency)
24
+ ->whereBetween('created_at', [$start, $end])
25
+ ->selectRaw('YEARWEEK(created_at, 1) as wk, MIN(DATE(created_at)) as wk_start, SUM(amount) as total_cost')
26
+ ->groupBy('wk')
27
+ ->orderBy('wk')
28
+ ->get();
29
+
30
+ $distanceByWeek = Order::where('company_uuid', $this->company->uuid)
31
+ ->where('status', 'completed')
32
+ ->whereBetween('updated_at', [$start, $end])
33
+ ->selectRaw('YEARWEEK(updated_at, 1) as wk, SUM(distance) as total_distance')
34
+ ->groupBy('wk')
35
+ ->orderBy('wk')
36
+ ->pluck('total_distance', 'wk');
37
+
38
+ $labels = [];
39
+ $costData = [];
40
+ $cpkmData = [];
41
+
42
+ foreach ($fuelByWeek as $row) {
43
+ $labels[] = Carbon::parse($row->wk_start)->format('M j');
44
+ $cost = (float) $row->total_cost;
45
+ $distanceM = (float) ($distanceByWeek[$row->wk] ?? 0);
46
+ $distanceKm = $distanceM / 1000.0;
47
+ $costData[] = round($cost, 2);
48
+ $cpkmData[] = $distanceKm > 0 ? round($cost / $distanceKm, 3) : null;
49
+ }
50
+
51
+ $totalCost = array_sum($costData);
52
+ $totalDistanceM = array_sum($distanceByWeek->all());
53
+ $avgCostPerKm = $totalDistanceM > 0
54
+ ? round($totalCost / ($totalDistanceM / 1000.0), 3)
55
+ : 0.0;
56
+
57
+ return [
58
+ 'labels' => $labels,
59
+ 'datasets' => [
60
+ [
61
+ 'type' => 'bar',
62
+ 'label' => 'Fuel Cost',
63
+ 'data' => $costData,
64
+ 'yAxisID' => 'y1',
65
+ 'backgroundColor' => '#3485e2',
66
+ ],
67
+ [
68
+ 'type' => 'line',
69
+ 'label' => 'Cost per km',
70
+ 'data' => $cpkmData,
71
+ 'yAxisID' => 'y2',
72
+ 'borderColor' => '#f59e0b',
73
+ 'tension' => 0.3,
74
+ ],
75
+ ],
76
+ 'summary' => [
77
+ 'total_cost' => round($totalCost, 2),
78
+ 'currency' => $currency,
79
+ 'avg_cost_per_km' => $avgCostPerKm,
80
+ ],
81
+ ];
82
+ }
83
+ }
@@ -0,0 +1,63 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\FleetOps\Models\GeofenceEventLog;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ /**
9
+ * Geofence dwell + violation summary. Today's violation count, period total,
10
+ * top-N dwell outliers (driver × zone × duration), and a bar chart of events
11
+ * by zone.
12
+ */
13
+ class GeofenceViolations extends AbstractAnalytics
14
+ {
15
+ public function get(): array
16
+ {
17
+ $companyUuid = $this->company->uuid;
18
+ $start = $this->start ?? Carbon::now()->subDays(7)->toDateTime();
19
+ $end = $this->end ?? Carbon::now()->toDateTime();
20
+
21
+ $violationsToday = GeofenceEventLog::where('company_uuid', $companyUuid)
22
+ ->where('event_type', 'dwelled')
23
+ ->whereDate('occurred_at', Carbon::today())
24
+ ->count();
25
+
26
+ $violationsPeriod = GeofenceEventLog::where('company_uuid', $companyUuid)
27
+ ->where('event_type', 'dwelled')
28
+ ->whereBetween('occurred_at', [$start, $end])
29
+ ->count();
30
+
31
+ $topDwells = GeofenceEventLog::where('company_uuid', $companyUuid)
32
+ ->where('event_type', 'dwelled')
33
+ ->whereBetween('occurred_at', [$start, $end])
34
+ ->whereNotNull('dwell_duration_minutes')
35
+ ->orderByDesc('dwell_duration_minutes')
36
+ ->limit(8)
37
+ ->get(['subject_name', 'driver_uuid', 'geofence_name', 'dwell_duration_minutes', 'occurred_at']);
38
+
39
+ $byZoneRows = GeofenceEventLog::where('company_uuid', $companyUuid)
40
+ ->whereBetween('occurred_at', [$start, $end])
41
+ ->selectRaw('geofence_name, COUNT(*) as total')
42
+ ->groupBy('geofence_name')
43
+ ->orderByRaw('total DESC')
44
+ ->limit(8)
45
+ ->get();
46
+
47
+ return [
48
+ 'violations_today' => $violationsToday,
49
+ 'violations_period' => $violationsPeriod,
50
+ 'top_dwells' => $topDwells->map(fn ($e) => [
51
+ 'driver_uuid' => $e->driver_uuid,
52
+ 'driver_name' => $e->subject_name,
53
+ 'zone_name' => $e->geofence_name,
54
+ 'duration_minutes' => (int) $e->dwell_duration_minutes,
55
+ 'occurred_at' => $e->occurred_at,
56
+ ])->all(),
57
+ 'by_zone' => [
58
+ 'labels' => $byZoneRows->pluck('geofence_name')->map(fn ($n) => $n ?: 'Unnamed')->all(),
59
+ 'data' => $byZoneRows->pluck('total')->map(fn ($v) => (int) $v)->all(),
60
+ ],
61
+ ];
62
+ }
63
+ }
@@ -0,0 +1,67 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\FleetOps\Models\Issue;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ /**
9
+ * Issues breakdown: category pie, priority histogram, open vs resolved counts,
10
+ * and average resolution time (hours).
11
+ */
12
+ class IssuesInsights extends AbstractAnalytics
13
+ {
14
+ public function get(): array
15
+ {
16
+ $companyUuid = $this->company->uuid;
17
+ $start = $this->start ?? Carbon::now()->subDays(30)->toDateTime();
18
+ $end = $this->end ?? Carbon::now()->toDateTime();
19
+
20
+ $byCategory = Issue::where('company_uuid', $companyUuid)
21
+ ->whereBetween('created_at', [$start, $end])
22
+ ->selectRaw('category, COUNT(*) as total')
23
+ ->groupBy('category')
24
+ ->orderByRaw('total DESC')
25
+ ->get();
26
+
27
+ $byPriority = Issue::where('company_uuid', $companyUuid)
28
+ ->whereBetween('created_at', [$start, $end])
29
+ ->selectRaw('priority, COUNT(*) as total')
30
+ ->groupBy('priority')
31
+ ->pluck('total', 'priority');
32
+
33
+ $open = Issue::where('company_uuid', $companyUuid)
34
+ ->where('status', 'pending')
35
+ ->count();
36
+
37
+ $resolvedInWindow = Issue::where('company_uuid', $companyUuid)
38
+ ->whereNotNull('resolved_at')
39
+ ->whereBetween('resolved_at', [$start, $end])
40
+ ->get(['created_at', 'resolved_at']);
41
+
42
+ $totalHours = 0.0;
43
+ foreach ($resolvedInWindow as $issue) {
44
+ $totalHours += (Carbon::parse($issue->resolved_at)->getTimestamp()
45
+ - Carbon::parse($issue->created_at)->getTimestamp()) / 3600;
46
+ }
47
+
48
+ $avgResolutionHours = $resolvedInWindow->count() > 0
49
+ ? round($totalHours / $resolvedInWindow->count(), 1)
50
+ : null;
51
+
52
+ return [
53
+ 'by_category' => [
54
+ 'labels' => $byCategory->pluck('category')->map(fn ($c) => $c ?: 'Uncategorized')->all(),
55
+ 'data' => $byCategory->pluck('total')->map(fn ($v) => (int) $v)->all(),
56
+ ],
57
+ 'by_priority' => [
58
+ 'high' => (int) ($byPriority['high'] ?? 0),
59
+ 'medium' => (int) ($byPriority['medium'] ?? 0),
60
+ 'low' => (int) ($byPriority['low'] ?? 0),
61
+ ],
62
+ 'open' => $open,
63
+ 'resolved_this_period' => $resolvedInWindow->count(),
64
+ 'avg_resolution_hours' => $avgResolutionHours,
65
+ ];
66
+ }
67
+ }
@@ -0,0 +1,127 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Order;
7
+ use Fleetbase\FleetOps\Models\Vehicle;
8
+ use Fleetbase\FleetOps\Support\Metrics\OrdersInProgressMetric;
9
+
10
+ /**
11
+ * Real-time fleet snapshot used as the initial-state payload for Live Fleet Map.
12
+ * After this single GET the frontend subscribes to `company.{uuid}` and applies
13
+ * `driver.location_changed` / `order.*` events incrementally instead of polling.
14
+ *
15
+ * Reads from `Driver.location` and `Vehicle.location` directly — does NOT scan the
16
+ * high-frequency Position table.
17
+ */
18
+ class LiveFleet extends AbstractAnalytics
19
+ {
20
+ public function get(): array
21
+ {
22
+ $companyUuid = $this->company->uuid;
23
+
24
+ // Driver.name is a virtual attribute backed by users.name — eager-load the
25
+ // user so the accessor resolves cleanly without a per-record query.
26
+ $drivers = Driver::with(['user:uuid,name,avatar_uuid'])
27
+ ->where('drivers.company_uuid', $companyUuid)
28
+ ->whereNotNull('drivers.location')
29
+ ->where(function ($q) {
30
+ $q->where('drivers.online', true)->orWhereNotNull('drivers.current_job_uuid');
31
+ })
32
+ ->get();
33
+
34
+ // Vehicles tracked independently of drivers (telematics-connected rigs may
35
+ // report position without an active driver session).
36
+ $vehicles = Vehicle::where('company_uuid', $companyUuid)
37
+ ->whereNotNull('location')
38
+ ->get();
39
+
40
+ $activeOrderUuids = $drivers->pluck('current_job_uuid')->filter()->unique()->values();
41
+
42
+ $activeOrders = Order::whereIn('uuid', $activeOrderUuids)
43
+ ->where('company_uuid', $companyUuid)
44
+ ->whereIn('status', OrdersInProgressMetric::IN_PROGRESS_STATUSES)
45
+ ->get(['uuid', 'driver_assigned_uuid', 'status', 'tracking_number_uuid']);
46
+
47
+ return [
48
+ 'drivers' => $drivers->map(fn ($d) => $this->driverPayload($d))
49
+ ->filter(fn ($d) => $d['lat'] !== null && $d['lng'] !== null)
50
+ ->values()
51
+ ->all(),
52
+ 'vehicles' => $vehicles->map(fn ($v) => $this->vehiclePayload($v))
53
+ ->filter(fn ($v) => $v['lat'] !== null && $v['lng'] !== null)
54
+ ->values()
55
+ ->all(),
56
+ 'active_orders' => $activeOrders->map(fn ($o) => [
57
+ 'uuid' => $o->uuid,
58
+ 'driver_uuid' => $o->driver_assigned_uuid,
59
+ 'status' => $o->status,
60
+ ])->all(),
61
+ ];
62
+ }
63
+
64
+ private function driverPayload(Driver $d): array
65
+ {
66
+ [$lat, $lng] = $this->extractLatLng($d->location);
67
+
68
+ return [
69
+ 'uuid' => $d->uuid,
70
+ 'public_id' => $d->public_id,
71
+ 'name' => $d->name,
72
+ // avatar_url falls back to vehicle avatar then a default;
73
+ // vehicle_avatar is what the operational live-map renders.
74
+ 'avatar_url' => $d->avatar_url,
75
+ 'vehicle_avatar' => $d->vehicle_avatar,
76
+ 'online' => (bool) $d->online,
77
+ 'heading' => (float) ($d->heading ?? 0),
78
+ 'current_order_uuid' => $d->current_job_uuid,
79
+ 'lat' => $lat,
80
+ 'lng' => $lng,
81
+ 'updated_at' => $d->last_location_update_at,
82
+ ];
83
+ }
84
+
85
+ private function vehiclePayload(Vehicle $v): array
86
+ {
87
+ [$lat, $lng] = $this->extractLatLng($v->location);
88
+
89
+ return [
90
+ 'uuid' => $v->uuid,
91
+ 'public_id' => $v->public_id,
92
+ 'name' => $v->display_name,
93
+ 'plate_number' => $v->plate_number,
94
+ 'avatar_url' => $v->avatar_url,
95
+ 'photo_url' => $v->photo_url,
96
+ 'online' => (bool) ($v->online ?? false),
97
+ 'driver_name' => $v->driver_name,
98
+ 'heading' => (float) ($v->heading ?? 0),
99
+ 'lat' => $lat,
100
+ 'lng' => $lng,
101
+ ];
102
+ }
103
+
104
+ /**
105
+ * Pull [lat, lng] out of whatever shape the spatial accessor handed us.
106
+ * Supports Point objects, [lat, lng] arrays, and {lat, lng} hashes.
107
+ *
108
+ * @return array{0:float|null,1:float|null}
109
+ */
110
+ private function extractLatLng($location): array
111
+ {
112
+ if (is_object($location) && method_exists($location, 'getLat')) {
113
+ return [$location->getLat(), $location->getLng()];
114
+ }
115
+
116
+ if (is_array($location)) {
117
+ // Point::toArray usually returns ['lat' => ..., 'lng' => ...] but
118
+ // GeoJSON-shape `coordinates` is [lng, lat]; handle both.
119
+ return [
120
+ $location['lat'] ?? $location[1] ?? null,
121
+ $location['lng'] ?? $location[0] ?? null,
122
+ ];
123
+ }
124
+
125
+ return [null, null];
126
+ }
127
+ }
@@ -0,0 +1,70 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Analytics;
4
+
5
+ use Fleetbase\FleetOps\Models\Maintenance;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ /**
9
+ * Maintenance summary: overdue count, next-7-day schedule, in-progress count,
10
+ * month-to-date and year-to-date spend, plus a short list of upcoming items.
11
+ */
12
+ class MaintenanceOverview extends AbstractAnalytics
13
+ {
14
+ public function get(): array
15
+ {
16
+ $companyUuid = $this->company->uuid;
17
+ $currency = $this->companyCurrency();
18
+ $now = Carbon::now();
19
+
20
+ $overdue = Maintenance::where('company_uuid', $companyUuid)
21
+ ->where('scheduled_at', '<', $now)
22
+ ->where('status', '!=', 'completed')
23
+ ->where('status', '!=', 'canceled')
24
+ ->count();
25
+
26
+ $next7d = Maintenance::where('company_uuid', $companyUuid)
27
+ ->whereBetween('scheduled_at', [$now, $now->copy()->addDays(7)])
28
+ ->whereIn('status', ['scheduled', 'pending'])
29
+ ->count();
30
+
31
+ $inProgress = Maintenance::where('company_uuid', $companyUuid)
32
+ ->where('status', 'in_progress')
33
+ ->count();
34
+
35
+ $costThisMonth = (float) Maintenance::where('company_uuid', $companyUuid)
36
+ ->where('currency', $currency)
37
+ ->where('status', 'completed')
38
+ ->whereBetween('completed_at', [$now->copy()->startOfMonth(), $now])
39
+ ->sum('total_cost');
40
+
41
+ $costYtd = (float) Maintenance::where('company_uuid', $companyUuid)
42
+ ->where('currency', $currency)
43
+ ->where('status', 'completed')
44
+ ->whereBetween('completed_at', [$now->copy()->startOfYear(), $now])
45
+ ->sum('total_cost');
46
+
47
+ $upcoming = Maintenance::where('company_uuid', $companyUuid)
48
+ ->whereIn('status', ['scheduled', 'pending'])
49
+ ->whereNotNull('scheduled_at')
50
+ ->where('scheduled_at', '>=', $now)
51
+ ->orderBy('scheduled_at', 'asc')
52
+ ->limit(8)
53
+ ->get(['uuid', 'maintainable_uuid', 'maintainable_type', 'type', 'priority', 'scheduled_at']);
54
+
55
+ return [
56
+ 'overdue' => $overdue,
57
+ 'scheduled_next_7d' => $next7d,
58
+ 'in_progress' => $inProgress,
59
+ 'cost_this_month' => round($costThisMonth, 2),
60
+ 'cost_ytd' => round($costYtd, 2),
61
+ 'currency' => $currency,
62
+ 'upcoming' => $upcoming->map(fn ($m) => [
63
+ 'uuid' => $m->uuid,
64
+ 'type' => $m->type,
65
+ 'priority' => $m->priority,
66
+ 'scheduled_at' => $m->scheduled_at,
67
+ ])->all(),
68
+ ];
69
+ }
70
+ }