@fleetbase/fleetops-engine 0.6.47 → 0.6.49

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 (223) hide show
  1. package/addon/components/admin/routing-settings.hbs +71 -0
  2. package/addon/components/admin/routing-settings.js +124 -0
  3. package/addon/components/customer/orders.js +1 -1
  4. package/addon/components/display-place.hbs +1 -1
  5. package/addon/components/driver/details.hbs +5 -1
  6. package/addon/components/driver/form.hbs +11 -1
  7. package/addon/components/fleet-listing-panel.hbs +2 -2
  8. package/addon/components/fleet-listing-panel.js +10 -0
  9. package/addon/components/map/google-live-map.js +12 -73
  10. package/addon/components/map/leaflet-live-map.hbs +149 -33
  11. package/addon/components/map/order-list-overlay/order.hbs +18 -3
  12. package/addon/components/map/toolbar.hbs +2 -2
  13. package/addon/components/map/toolbar.js +10 -2
  14. package/addon/components/orchestrator/phase-builder.hbs +49 -5
  15. package/addon/components/orchestrator/phase-builder.js +34 -1
  16. package/addon/components/orchestrator/resource-panel.hbs +19 -1
  17. package/addon/components/orchestrator/resource-panel.js +4 -0
  18. package/addon/components/orchestrator-workbench.js +4 -1
  19. package/addon/components/order/details/purchase-rate.hbs +19 -22
  20. package/addon/components/order/details/route.hbs +26 -1
  21. package/addon/components/order/details/route.js +50 -0
  22. package/addon/components/order/details/tracking.hbs +171 -27
  23. package/addon/components/order/details/tracking.js +457 -0
  24. package/addon/components/order/form.hbs +2 -2
  25. package/addon/components/order/kanban-card.hbs +18 -3
  26. package/addon/components/order-list-overlay/order.hbs +18 -3
  27. package/addon/components/order-progress-card.hbs +21 -6
  28. package/addon/components/order-tracking-lookup.hbs +7 -7
  29. package/addon/components/order-tracking-lookup.js +1 -1
  30. package/addon/components/route-list.hbs +78 -8
  31. package/addon/components/route-list.js +48 -6
  32. package/addon/components/tracking-stop-progress.hbs +45 -0
  33. package/addon/components/tracking-stop-progress.js +79 -0
  34. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  35. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  36. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  37. package/addon/components/widget/fuel-efficiency.js +43 -0
  38. package/addon/components/widget/geofence-violations.hbs +44 -0
  39. package/addon/components/widget/geofence-violations.js +55 -0
  40. package/addon/components/widget/issues-insights.hbs +43 -0
  41. package/addon/components/widget/issues-insights.js +62 -0
  42. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  43. package/addon/components/widget/kpi-active-orders.js +5 -0
  44. package/addon/components/widget/kpi-aov.hbs +10 -0
  45. package/addon/components/widget/kpi-aov.js +5 -0
  46. package/addon/components/widget/kpi-distance.hbs +10 -0
  47. package/addon/components/widget/kpi-distance.js +5 -0
  48. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  49. package/addon/components/widget/kpi-drivers-online.js +5 -0
  50. package/addon/components/widget/kpi-earnings.hbs +10 -0
  51. package/addon/components/widget/kpi-earnings.js +5 -0
  52. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  53. package/addon/components/widget/kpi-open-issues.js +5 -0
  54. package/addon/components/widget/kpi-tile.hbs +59 -0
  55. package/addon/components/widget/kpi-tile.js +168 -0
  56. package/addon/components/widget/live-fleet.hbs +122 -0
  57. package/addon/components/widget/live-fleet.js +365 -0
  58. package/addon/components/widget/maintenance-overview.hbs +45 -0
  59. package/addon/components/widget/maintenance-overview.js +40 -0
  60. package/addon/components/widget/on-time-delivery.hbs +27 -0
  61. package/addon/components/widget/on-time-delivery.js +73 -0
  62. package/addon/components/widget/operations-pulse.hbs +51 -0
  63. package/addon/components/widget/operations-pulse.js +173 -0
  64. package/addon/components/widget/orders-by-status.hbs +26 -0
  65. package/addon/components/widget/orders-by-status.js +54 -0
  66. package/addon/components/widget/revenue-trend.hbs +34 -0
  67. package/addon/components/widget/revenue-trend.js +67 -0
  68. package/addon/components/widget/top-drivers.hbs +44 -0
  69. package/addon/components/widget/top-drivers.js +54 -0
  70. package/addon/controllers/settings/routing.js +124 -1
  71. package/addon/extension.js +172 -3
  72. package/addon/helpers/format-duration.js +38 -0
  73. package/addon/routes/application.js +6 -0
  74. package/addon/services/map-adapter/google.js +13 -2
  75. package/addon/services/order-list-overlay.js +2 -2
  76. package/addon/styles/fleetops-engine.css +806 -46
  77. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  78. package/addon/templates/maintenance/parts/index.hbs +1 -1
  79. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  80. package/addon/templates/management/vehicles/index.hbs +1 -1
  81. package/addon/templates/settings/avatars.hbs +1 -1
  82. package/addon/templates/settings/custom-fields.hbs +1 -1
  83. package/addon/templates/settings/map.hbs +1 -1
  84. package/addon/templates/settings/navigator-app.hbs +1 -1
  85. package/addon/templates/settings/notifications.hbs +1 -1
  86. package/addon/templates/settings/orchestrator.hbs +1 -1
  87. package/addon/templates/settings/payments/index.hbs +1 -1
  88. package/addon/templates/settings/payments/onboard.hbs +1 -1
  89. package/addon/templates/settings/routing.hbs +82 -4
  90. package/addon/templates/settings/scheduling.hbs +1 -1
  91. package/addon/utils/live-map-card-content.js +171 -0
  92. package/app/components/tracking-stop-progress.js +1 -0
  93. package/app/components/widget/fuel-efficiency.js +1 -0
  94. package/app/components/widget/geofence-violations.js +1 -0
  95. package/app/components/widget/issues-insights.js +1 -0
  96. package/app/components/widget/kpi-active-orders.js +1 -0
  97. package/app/components/widget/kpi-aov.js +1 -0
  98. package/app/components/widget/kpi-distance.js +1 -0
  99. package/app/components/widget/kpi-drivers-online.js +1 -0
  100. package/app/components/widget/kpi-earnings.js +1 -0
  101. package/app/components/widget/kpi-open-issues.js +1 -0
  102. package/app/components/widget/kpi-tile.js +1 -0
  103. package/app/components/widget/live-fleet.js +1 -0
  104. package/app/components/widget/maintenance-overview.js +1 -0
  105. package/app/components/widget/on-time-delivery.js +1 -0
  106. package/app/components/widget/operations-pulse.js +1 -0
  107. package/app/components/widget/orders-by-status.js +1 -0
  108. package/app/components/widget/revenue-trend.js +1 -0
  109. package/app/components/widget/top-drivers.js +1 -0
  110. package/app/helpers/format-duration.js +1 -0
  111. package/app/utils/live-map-card-content.js +1 -0
  112. package/composer.json +1 -1
  113. package/extension.json +1 -1
  114. package/package.json +3 -3
  115. package/pnpm-workspace.yaml +1 -0
  116. package/server/config/fleetops.php +32 -11
  117. package/server/config/telematics.php +111 -1
  118. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  119. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  120. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  121. package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
  122. package/server/seeders/Testing/FleetSeeder.php +341 -0
  123. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  124. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  125. package/server/seeders/Testing/OrdersSeeder.php +214 -0
  126. package/server/seeders/Testing/TestingSeeder.php +40 -0
  127. package/server/seeders/Testing/VroomOrchestrationSeeder.php +409 -0
  128. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  129. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  130. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  131. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  132. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  133. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  134. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  135. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  136. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  137. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  138. package/server/src/Http/Controllers/Internal/v1/OrderController.php +52 -21
  139. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  140. package/server/src/Http/Controllers/Internal/v1/SettingController.php +164 -12
  141. package/server/src/Http/Resources/v1/Driver.php +2 -0
  142. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  143. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  144. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  145. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  146. package/server/src/Models/Contact.php +122 -13
  147. package/server/src/Models/Driver.php +2 -0
  148. package/server/src/Models/Order.php +1 -1
  149. package/server/src/Models/OrderConfig.php +14 -11
  150. package/server/src/Models/Payload.php +8 -8
  151. package/server/src/Models/Place.php +0 -3
  152. package/server/src/Models/ServiceRate.php +1 -1
  153. package/server/src/Notifications/LateDeparture.php +48 -0
  154. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  155. package/server/src/Notifications/RouteDeviation.php +48 -0
  156. package/server/src/Observers/ContactObserver.php +8 -0
  157. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  158. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +357 -38
  159. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  160. package/server/src/Providers/FleetOpsServiceProvider.php +32 -0
  161. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  162. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  163. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  164. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  165. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  166. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  167. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  168. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  169. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  170. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  171. package/server/src/Support/Analytics/RevenueTrend.php +79 -0
  172. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  173. package/server/src/Support/LiveOrderQuery.php +75 -0
  174. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  175. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  176. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  177. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  178. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  179. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  180. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  181. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  182. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  183. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  184. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  185. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  186. package/server/src/Support/Metrics/Registry.php +46 -0
  187. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  188. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  189. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  190. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  191. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  192. package/server/src/Support/Metrics.php +46 -234
  193. package/server/src/Support/OSRM.php +11 -6
  194. package/server/src/Support/OrderTracker.php +7 -582
  195. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  196. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  197. package/server/src/Support/Utils.php +1 -1
  198. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  199. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  200. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  201. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  202. package/server/src/Tracking/README.md +25 -0
  203. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  204. package/server/src/Tracking/TrackingContext.php +63 -0
  205. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  206. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  207. package/server/src/Tracking/TrackingOptions.php +49 -0
  208. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  209. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  210. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  211. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  212. package/server/src/Tracking/TrackingStop.php +59 -0
  213. package/server/src/routes.php +27 -0
  214. package/server/tests/AnalyticsRoutesTest.php +41 -0
  215. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  216. package/server/tests/LiveOrderQueryTest.php +34 -0
  217. package/server/tests/MetricsRegistryTest.php +88 -0
  218. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  219. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  220. package/server/tests/PingDriverEndpointTest.php +19 -0
  221. package/server/tests/TrackingIntelligenceTest.php +176 -0
  222. package/server/tests/VroomOrchestrationEngineTest.php +250 -0
  223. package/translations/en-us.yaml +41 -0
@@ -141,85 +141,243 @@ class OrchestrationPayloadBuilder
141
141
  }
142
142
 
143
143
  /**
144
- * Build the normalized job list from a collection of Orders.
144
+ * Build normalized route tasks from Fleetbase orders.
145
145
  *
146
- * Each job contains:
147
- * - id: order public_id
148
- * - location: [longitude, latitude] of the delivery destination
149
- * - pickup: [longitude, latitude] of the pickup (for PDPTW shipments)
150
- * - service: service time in seconds (waypoint.service_time → order meta → default 300)
151
- * - time_windows: [[earliest_unix, latest_unix]] from order.time_window_start/end or scheduled_at
152
- * - skills: integer skill codes from order.required_skills or custom fields
153
- * - amount: multi-dimensional capacity demand [weight_kg, volume_litres, pallets, parcels]
154
- * - priority: orchestrator_priority (0–100, higher = more important)
155
- * - description: human-readable label for debugging
146
+ * Fleetbase orders must remain atomic during allocation. A task contains
147
+ * the full ordered stop list for the order, but engine adapters decide how
148
+ * much of that route shape their solver can safely model.
156
149
  */
157
- public static function buildJobs(Collection $orders): array
150
+ public static function buildRouteTasks(Collection $orders): array
158
151
  {
159
152
  return $orders->map(function (Order $order) {
160
- $payload = $order->payload;
161
- $destination = $payload?->dropoff ?? $payload?->waypoints?->last();
153
+ $candidates = static::buildRouteStopCandidates($order);
154
+ $invalidStop = collect($candidates)->first(fn (array $stop) => !static::coordinatesFromPlace($stop['place'] ?? null));
155
+ $stops = static::normalizeRouteStops($candidates);
156
+
157
+ if ($invalidStop) {
158
+ return [
159
+ 'id' => $order->public_id,
160
+ 'description' => $order->public_id,
161
+ 'invalid' => true,
162
+ 'reason' => sprintf('Order %s stop is missing valid coordinates.', $invalidStop['role'] ?? 'route'),
163
+ 'stops' => [],
164
+ ];
165
+ }
162
166
 
163
- if (!$destination || !$destination->location) {
164
- return null;
167
+ if (empty($stops)) {
168
+ return [
169
+ 'id' => $order->public_id,
170
+ 'description' => $order->public_id,
171
+ 'invalid' => true,
172
+ 'reason' => 'Order has no routable pickup, dropoff, or waypoint coordinates.',
173
+ 'stops' => [],
174
+ ];
165
175
  }
166
176
 
167
- // --- Location ---
168
- $job = [
177
+ $task = [
169
178
  'id' => $order->public_id,
170
- 'location' => [$destination->location->getLng(), $destination->location->getLat()],
171
179
  'description' => $order->public_id,
180
+ 'stops' => $stops,
181
+ 'amount' => static::computePayloadDemand($order),
182
+ 'service' => static::resolveServiceTime($order),
172
183
  ];
173
184
 
174
- // Pickup location (for pickup-and-delivery problems)
175
- $pickup = $payload?->pickup;
176
- if ($pickup && $pickup->location) {
177
- $job['pickup'] = [$pickup->location->getLng(), $pickup->location->getLat()];
178
- }
179
-
180
- // --- Service time ---
181
- // Prefer waypoint-level service_time, then order meta, then default 300s
182
- $waypointMarker = $payload?->waypointMarkers?->last();
183
- $job['service'] = (int) (
184
- $waypointMarker?->service_time
185
- ?? $order->getMeta('service_time_seconds')
186
- ?? 300
187
- );
188
-
189
- // --- Capacity demand ---
190
- // Aggregated dynamically from payload entities; falls back to order meta.
191
- $job['amount'] = static::computePayloadDemand($order);
192
-
193
- // --- Time windows ---
194
- // Prefer explicit orchestrator time_window columns, fall back to scheduled_at
195
185
  if ($order->time_window_start && $order->time_window_end) {
196
- $job['time_windows'] = [[
186
+ $task['time_windows'] = [[
197
187
  $order->time_window_start->timestamp,
198
188
  $order->time_window_end->timestamp,
199
189
  ]];
200
190
  } elseif ($order->scheduled_at) {
201
- $start = $order->scheduled_at->timestamp;
202
- $end = $order->scheduled_at->copy()->addHours(4)->timestamp;
203
- $job['time_windows'] = [[$start, $end]];
191
+ $start = $order->scheduled_at->timestamp;
192
+ $end = $order->scheduled_at->copy()->addHours(4)->timestamp;
193
+ $task['time_windows'] = [[$start, $end]];
204
194
  }
205
195
 
206
- // --- Skills ---
207
- // Prefer first-class required_skills JSON column, fall back to custom fields
208
196
  $skills = static::resolveSkills(
209
197
  $order->required_skills ?? [],
210
198
  $order->custom_fields ?? []
211
199
  );
212
200
  if (!empty($skills)) {
213
- $job['skills'] = $skills;
201
+ $task['skills'] = $skills;
214
202
  }
215
203
 
216
- // --- Priority ---
217
204
  if ($order->orchestrator_priority !== null && $order->orchestrator_priority > 0) {
218
- $job['priority'] = (int) $order->orchestrator_priority;
205
+ $task['priority'] = (int) $order->orchestrator_priority;
219
206
  }
220
207
 
221
- return $job;
222
- })->filter()->values()->toArray();
208
+ return $task;
209
+ })->values()->toArray();
210
+ }
211
+
212
+ /**
213
+ * @deprecated Use buildRouteTasks(). Kept for older engine adapters.
214
+ */
215
+ public static function buildJobs(Collection $orders): array
216
+ {
217
+ return array_values(array_filter(
218
+ static::buildRouteTasks($orders),
219
+ fn (array $task) => empty($task['invalid'])
220
+ ));
221
+ }
222
+
223
+ /**
224
+ * Build normalized capacity-only tasks from Fleetbase orders.
225
+ *
226
+ * Capacity allocation does not require route stops. It only needs an order
227
+ * demand vector plus optional skills, priority, and task limits.
228
+ */
229
+ public static function buildCapacityTasks(Collection $orders): array
230
+ {
231
+ return $orders->map(function (Order $order) {
232
+ if (!$order->payload) {
233
+ return [
234
+ 'id' => $order->public_id,
235
+ 'description' => $order->public_id,
236
+ 'invalid' => true,
237
+ 'reason' => 'Order has no payload to compute capacity demand.',
238
+ 'amount' => [0, 0, 0, 0],
239
+ ];
240
+ }
241
+
242
+ $task = [
243
+ 'id' => $order->public_id,
244
+ 'description' => $order->public_id,
245
+ 'amount' => static::computePayloadDemand($order),
246
+ 'service' => static::resolveServiceTime($order),
247
+ ];
248
+
249
+ $skills = static::resolveSkills(
250
+ $order->required_skills ?? [],
251
+ $order->custom_fields ?? []
252
+ );
253
+ if (!empty($skills)) {
254
+ $task['skills'] = $skills;
255
+ }
256
+
257
+ if ($order->orchestrator_priority !== null && $order->orchestrator_priority > 0) {
258
+ $task['priority'] = (int) $order->orchestrator_priority;
259
+ }
260
+
261
+ return $task;
262
+ })->values()->toArray();
263
+ }
264
+
265
+ /**
266
+ * Return Fleetbase's canonical ordered stops for an order:
267
+ * pickup, then waypoints by marker order, then dropoff.
268
+ */
269
+ public static function buildRouteStops(Order $order): array
270
+ {
271
+ return static::normalizeRouteStops(static::buildRouteStopCandidates($order));
272
+ }
273
+
274
+ protected static function buildRouteStopCandidates(Order $order): array
275
+ {
276
+ $payload = $order->payload;
277
+ if (!$payload) {
278
+ return [];
279
+ }
280
+
281
+ $stops = [];
282
+
283
+ if ($payload->pickup) {
284
+ $stops[] = ['role' => 'pickup', 'place' => $payload->pickup];
285
+ }
286
+
287
+ $waypointMarkers = method_exists($payload, 'relationLoaded') && $payload->relationLoaded('waypointMarkers')
288
+ ? $payload->waypointMarkers
289
+ : collect();
290
+ if ($waypointMarkers->isNotEmpty()) {
291
+ foreach ($waypointMarkers->sortBy('order')->values() as $index => $waypoint) {
292
+ $stops[] = [
293
+ 'role' => 'waypoint',
294
+ 'place' => $waypoint->place,
295
+ 'waypoint_id' => $waypoint->public_id,
296
+ 'waypoint_uuid' => $waypoint->uuid,
297
+ 'order' => $waypoint->order ?? $index,
298
+ ];
299
+ }
300
+ } elseif (isset($payload->waypoints) && $payload->waypoints) {
301
+ foreach ($payload->waypoints->values() as $index => $waypoint) {
302
+ $stops[] = [
303
+ 'role' => 'waypoint',
304
+ 'place' => $waypoint,
305
+ 'order' => $index,
306
+ ];
307
+ }
308
+ }
309
+
310
+ if ($payload->dropoff) {
311
+ $stops[] = ['role' => 'dropoff', 'place' => $payload->dropoff];
312
+ }
313
+
314
+ return $stops;
315
+ }
316
+
317
+ protected static function normalizeRouteStops(array $stops): array
318
+ {
319
+ return collect($stops)
320
+ ->map(function (array $stop) {
321
+ $coordinates = static::coordinatesFromPlace($stop['place'] ?? null);
322
+ if (!$coordinates) {
323
+ return null;
324
+ }
325
+
326
+ unset($stop['place'], $stop['waypoint_uuid']);
327
+
328
+ return array_merge($stop, [
329
+ 'location' => $coordinates,
330
+ ]);
331
+ })
332
+ ->filter()
333
+ ->values()
334
+ ->toArray();
335
+ }
336
+
337
+ protected static function resolveServiceTime(Order $order): int
338
+ {
339
+ $payload = $order->payload;
340
+ $waypointMarker = $payload?->waypointMarkers?->last();
341
+
342
+ return (int) (
343
+ $waypointMarker?->service_time
344
+ ?? $order->getMeta('service_time_seconds')
345
+ ?? 300
346
+ );
347
+ }
348
+
349
+ protected static function coordinatesFromPlace($place): ?array
350
+ {
351
+ $location = $place?->location;
352
+ if (!$location) {
353
+ return null;
354
+ }
355
+
356
+ $lng = $location->getLng();
357
+ $lat = $location->getLat();
358
+
359
+ if (!static::isValidCoordinate($lng, $lat)) {
360
+ return null;
361
+ }
362
+
363
+ return [(float) $lng, (float) $lat];
364
+ }
365
+
366
+ protected static function isValidCoordinate($lng, $lat): bool
367
+ {
368
+ if (!is_numeric($lng) || !is_numeric($lat)) {
369
+ return false;
370
+ }
371
+
372
+ $lng = (float) $lng;
373
+ $lat = (float) $lat;
374
+
375
+ return is_finite($lng)
376
+ && is_finite($lat)
377
+ && $lng >= -180
378
+ && $lng <= 180
379
+ && $lat >= -90
380
+ && $lat <= 90;
223
381
  }
224
382
 
225
383
  /**
@@ -337,6 +495,56 @@ class OrchestrationPayloadBuilder
337
495
  })->filter()->values()->toArray();
338
496
  }
339
497
 
498
+ /**
499
+ * Build vehicles for non-geospatial capacity allocation.
500
+ *
501
+ * Unlike buildVehicles(), this intentionally does not require a vehicle or
502
+ * driver location because no routing engine will be asked to calculate
503
+ * travel distance or duration.
504
+ */
505
+ public static function buildCapacityVehicles(Collection $vehicles): array
506
+ {
507
+ return $vehicles->map(function (Vehicle $vehicle) {
508
+ $driver = $vehicle->driver;
509
+ $entry = [
510
+ 'id' => $vehicle->public_id,
511
+ 'driver_id' => $driver?->public_id,
512
+ 'capacity' => static::buildVehicleCapacity($vehicle),
513
+ ];
514
+
515
+ if ($vehicle->max_tasks !== null && $vehicle->max_tasks > 0) {
516
+ $entry['max_tasks'] = (int) $vehicle->max_tasks;
517
+ }
518
+
519
+ $maxTravel = $driver?->max_travel_time ?? static::safeMeta($vehicle, 'max_travel_time_seconds');
520
+ if ($maxTravel) {
521
+ $entry['max_travel_time'] = (int) $maxTravel;
522
+ }
523
+
524
+ if ($driver?->time_window_start && $driver?->time_window_end) {
525
+ $entry['time_window'] = [
526
+ $driver->time_window_start->timestamp,
527
+ $driver->time_window_end->timestamp,
528
+ ];
529
+ } elseif ($vehicle->time_window_start && $vehicle->time_window_end) {
530
+ $entry['time_window'] = [
531
+ $vehicle->time_window_start->timestamp,
532
+ $vehicle->time_window_end->timestamp,
533
+ ];
534
+ }
535
+
536
+ $skills = array_values(array_unique(array_merge(
537
+ static::resolveSkills($vehicle->skills ?? [], $vehicle->custom_fields ?? []),
538
+ static::resolveSkills($driver?->skills ?? [], $driver?->custom_fields ?? [])
539
+ )));
540
+ if (!empty($skills)) {
541
+ $entry['skills'] = $skills;
542
+ }
543
+
544
+ return $entry;
545
+ })->values()->toArray();
546
+ }
547
+
340
548
  /**
341
549
  * Resolve integer skill codes from a first-class skills array and/or custom fields.
342
550
  *
@@ -65,6 +65,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
65
65
  \Fleetbase\FleetOps\Console\Commands\TestEmail::class,
66
66
  \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class,
67
67
  \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class,
68
+ \Fleetbase\FleetOps\Console\Commands\ProcessOperationalAlerts::class,
68
69
  ];
69
70
 
70
71
  /**
@@ -99,6 +100,13 @@ class FleetOpsServiceProvider extends CoreServiceProvider
99
100
  \Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry::class,
100
101
  fn () => new \Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry()
101
102
  );
103
+
104
+ // Register the TrackingProviderRegistry as a singleton so FleetOps core
105
+ // and third-party extensions can share tracking intelligence providers.
106
+ $this->app->singleton(
107
+ \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class,
108
+ fn () => new \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry()
109
+ );
102
110
  }
103
111
 
104
112
  /**
@@ -119,6 +127,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
119
127
  $schedule->command('fleetops:purge-service-quotes')->daily()->withoutOverlapping();
120
128
  $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
121
129
  $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
130
+ $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
122
131
  });
123
132
  $this->registerNotifications();
124
133
  $this->registerExpansionsFrom(__DIR__ . '/../Expansions');
@@ -135,6 +144,26 @@ class FleetOpsServiceProvider extends CoreServiceProvider
135
144
  if (!$registry->has('greedy')) {
136
145
  $registry->register(new \Fleetbase\FleetOps\Orchestration\Engines\GreedyOrchestrationEngine());
137
146
  }
147
+ if (!$registry->has('capacity')) {
148
+ $registry->register(new \Fleetbase\FleetOps\Orchestration\Engines\CapacityAllocationEngine());
149
+ }
150
+ }
151
+ );
152
+
153
+ // Register built-in tracking providers. Third-party extensions can
154
+ // register additional providers from their own service providers.
155
+ $this->app->resolving(
156
+ \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class,
157
+ function (\Fleetbase\FleetOps\Tracking\TrackingProviderRegistry $registry) {
158
+ if (!$registry->has('google_routes')) {
159
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\GoogleRoutesTrackingProvider());
160
+ }
161
+ if (!$registry->has('osrm')) {
162
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider());
163
+ }
164
+ if (!$registry->has('calculated')) {
165
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider());
166
+ }
138
167
  }
139
168
  );
140
169
  $this->loadRoutesFrom(__DIR__ . '/../routes.php');
@@ -165,6 +194,9 @@ class FleetOpsServiceProvider extends CoreServiceProvider
165
194
  \Fleetbase\FleetOps\Notifications\OrderFailed::class,
166
195
  \Fleetbase\FleetOps\Notifications\OrderCompleted::class,
167
196
  \Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence::class,
197
+ \Fleetbase\FleetOps\Notifications\LateDeparture::class,
198
+ \Fleetbase\FleetOps\Notifications\RouteDeviation::class,
199
+ \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class,
168
200
  ]);
169
201
 
170
202
  // 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
+ }