@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
@@ -0,0 +1,220 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Orchestration\Engines;
4
+
5
+ use Fleetbase\FleetOps\Orchestration\Contracts\OrchestrationEngineInterface;
6
+ use Fleetbase\FleetOps\Orchestration\Support\OrchestrationPayloadBuilder;
7
+ use Illuminate\Support\Collection;
8
+
9
+ /**
10
+ * CapacityAllocationEngine.
11
+ *
12
+ * Deterministic built-in engine for clients who need feasibility allocation
13
+ * by capacity, skills, and task limits without route geometry or vehicle GPS.
14
+ */
15
+ class CapacityAllocationEngine implements OrchestrationEngineInterface
16
+ {
17
+ public function getName(): string
18
+ {
19
+ return 'Capacity (built-in)';
20
+ }
21
+
22
+ public function getIdentifier(): string
23
+ {
24
+ return 'capacity';
25
+ }
26
+
27
+ public function allocate(Collection $orders, Collection $vehicles, array $options = []): array
28
+ {
29
+ $respectCapacity = (bool) ($options['respect_capacity'] ?? true);
30
+ $respectSkills = (bool) ($options['respect_skills'] ?? true);
31
+ $balanceWorkload = (bool) ($options['balance_workload'] ?? false);
32
+
33
+ $tasks = OrchestrationPayloadBuilder::buildCapacityTasks($orders);
34
+ $vehiclePool = $this->buildVehiclePool(OrchestrationPayloadBuilder::buildCapacityVehicles($vehicles));
35
+
36
+ $assignments = [];
37
+ $unassigned = [];
38
+ $reasons = [];
39
+
40
+ foreach ($this->sortTasks($tasks) as $task) {
41
+ if (!empty($task['invalid'])) {
42
+ $this->markUnassigned($task['id'], $task['reason'] ?? 'invalid_task', $unassigned, $reasons);
43
+ continue;
44
+ }
45
+
46
+ $bestIndex = $this->findVehicleIndex($vehiclePool, $task, $respectCapacity, $respectSkills, $balanceWorkload);
47
+ if ($bestIndex === null) {
48
+ $this->markUnassigned($task['id'], $this->resolveFailureReason($vehiclePool, $task, $respectCapacity, $respectSkills), $unassigned, $reasons);
49
+ continue;
50
+ }
51
+
52
+ $sequence = ++$vehiclePool[$bestIndex]['assigned'];
53
+ $this->consumeCapacity($vehiclePool[$bestIndex]['remaining'], $task['amount'] ?? [0, 0, 0, 0]);
54
+
55
+ $assignments[] = [
56
+ 'order_id' => $task['id'],
57
+ 'vehicle_id' => $vehiclePool[$bestIndex]['id'],
58
+ 'driver_id' => $vehiclePool[$bestIndex]['driver_id'],
59
+ 'sequence' => $sequence,
60
+ 'arrival' => null,
61
+ 'duration' => null,
62
+ 'distance' => null,
63
+ ];
64
+ }
65
+
66
+ return [
67
+ 'assignments' => $assignments,
68
+ 'unassigned' => array_values(array_unique($unassigned)),
69
+ 'summary' => [
70
+ 'engine' => 'capacity',
71
+ 'allocation_strategy' => 'capacity_only',
72
+ 'assigned' => count($assignments),
73
+ 'unassigned' => count(array_unique($unassigned)),
74
+ 'unassigned_reasons' => $reasons,
75
+ 'respect_capacity' => $respectCapacity,
76
+ 'respect_skills' => $respectSkills,
77
+ 'balance_workload' => $balanceWorkload,
78
+ ],
79
+ ];
80
+ }
81
+
82
+ protected function buildVehiclePool(array $vehicles): array
83
+ {
84
+ return array_map(fn (array $vehicle) => [
85
+ 'id' => $vehicle['id'],
86
+ 'driver_id' => $vehicle['driver_id'] ?? null,
87
+ 'capacity' => $this->normalizeVector($vehicle['capacity'] ?? [0, 0, 0, 0]),
88
+ 'remaining' => $this->normalizeVector($vehicle['capacity'] ?? [0, 0, 0, 0]),
89
+ 'skills' => $vehicle['skills'] ?? [],
90
+ 'max_tasks' => $vehicle['max_tasks'] ?? null,
91
+ 'assigned' => 0,
92
+ ], $vehicles);
93
+ }
94
+
95
+ protected function sortTasks(array $tasks): array
96
+ {
97
+ usort($tasks, fn (array $a, array $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
98
+
99
+ return $tasks;
100
+ }
101
+
102
+ protected function findVehicleIndex(array $vehiclePool, array $task, bool $respectCapacity, bool $respectSkills, bool $balanceWorkload): ?int
103
+ {
104
+ $eligible = [];
105
+
106
+ foreach ($vehiclePool as $index => $vehicle) {
107
+ if (!$this->isVehicleFeasible($vehicle, $task, $respectCapacity, $respectSkills)) {
108
+ continue;
109
+ }
110
+
111
+ $eligible[] = $index;
112
+ }
113
+
114
+ if (empty($eligible)) {
115
+ return null;
116
+ }
117
+
118
+ if (!$balanceWorkload) {
119
+ return $eligible[0];
120
+ }
121
+
122
+ usort($eligible, function (int $a, int $b) use ($vehiclePool) {
123
+ $assignedCompare = $vehiclePool[$a]['assigned'] <=> $vehiclePool[$b]['assigned'];
124
+ if ($assignedCompare !== 0) {
125
+ return $assignedCompare;
126
+ }
127
+
128
+ return array_sum($vehiclePool[$b]['remaining']) <=> array_sum($vehiclePool[$a]['remaining']);
129
+ });
130
+
131
+ return $eligible[0];
132
+ }
133
+
134
+ protected function isVehicleFeasible(array $vehicle, array $task, bool $respectCapacity, bool $respectSkills): bool
135
+ {
136
+ if ($vehicle['max_tasks'] !== null && $vehicle['assigned'] >= $vehicle['max_tasks']) {
137
+ return false;
138
+ }
139
+
140
+ if ($respectSkills && !$this->hasRequiredSkills($vehicle['skills'], $task['skills'] ?? [])) {
141
+ return false;
142
+ }
143
+
144
+ if ($respectCapacity && !$this->hasCapacity($vehicle['remaining'], $task['amount'] ?? [0, 0, 0, 0])) {
145
+ return false;
146
+ }
147
+
148
+ return true;
149
+ }
150
+
151
+ protected function hasRequiredSkills(array $vehicleSkills, array $requiredSkills): bool
152
+ {
153
+ if (empty($requiredSkills)) {
154
+ return true;
155
+ }
156
+
157
+ return empty(array_diff($requiredSkills, $vehicleSkills));
158
+ }
159
+
160
+ protected function hasCapacity(array $remaining, array $demand): bool
161
+ {
162
+ foreach ($this->normalizeVector($demand) as $index => $amount) {
163
+ if ($amount > ($remaining[$index] ?? 0)) {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ return true;
169
+ }
170
+
171
+ protected function consumeCapacity(array &$remaining, array $demand): void
172
+ {
173
+ foreach ($this->normalizeVector($demand) as $index => $amount) {
174
+ $remaining[$index] = max(0, ($remaining[$index] ?? 0) - $amount);
175
+ }
176
+ }
177
+
178
+ protected function resolveFailureReason(array $vehiclePool, array $task, bool $respectCapacity, bool $respectSkills): string
179
+ {
180
+ if (empty($vehiclePool)) {
181
+ return 'no_available_vehicle';
182
+ }
183
+
184
+ $capacityEligible = false;
185
+ $skillsEligible = false;
186
+ $taskEligible = false;
187
+
188
+ foreach ($vehiclePool as $vehicle) {
189
+ $capacityEligible = $capacityEligible || !$respectCapacity || $this->hasCapacity($vehicle['remaining'], $task['amount'] ?? [0, 0, 0, 0]);
190
+ $skillsEligible = $skillsEligible || !$respectSkills || $this->hasRequiredSkills($vehicle['skills'], $task['skills'] ?? []);
191
+ $taskEligible = $taskEligible || $vehicle['max_tasks'] === null || $vehicle['assigned'] < $vehicle['max_tasks'];
192
+ }
193
+
194
+ if (!$capacityEligible) {
195
+ return 'capacity_exceeded';
196
+ }
197
+ if (!$skillsEligible) {
198
+ return 'missing_required_skills';
199
+ }
200
+ if (!$taskEligible) {
201
+ return 'max_tasks_exceeded';
202
+ }
203
+
204
+ return 'no_available_vehicle';
205
+ }
206
+
207
+ protected function normalizeVector(array $vector): array
208
+ {
209
+ return array_pad(array_map(fn ($value) => max(0, (int) round((float) $value)), array_values($vector)), 4, 0);
210
+ }
211
+
212
+ protected function markUnassigned(string $orderId, string $reason, array &$unassigned, array &$reasons): void
213
+ {
214
+ $unassigned[] = $orderId;
215
+ $reasons[] = [
216
+ 'order_id' => $orderId,
217
+ 'reason' => $reason,
218
+ ];
219
+ }
220
+ }
@@ -50,7 +50,7 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
50
50
  /**
51
51
  * Run the VROOM VRP solver.
52
52
  *
53
- * Constructs a VROOM-format payload from the normalized jobs/vehicles
53
+ * Constructs a VROOM-format payload from the normalized route tasks/vehicles
54
54
  * produced by OrchestrationPayloadBuilder, calls the VROOM HTTP API, and
55
55
  * maps the response back to the standard OrchestrationEngineInterface result
56
56
  * shape.
@@ -59,31 +59,57 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
59
59
  */
60
60
  public function allocate(Collection $orders, Collection $vehicles, array $options = []): array
61
61
  {
62
- $jobs = OrchestrationPayloadBuilder::buildJobs($orders);
62
+ if (($options['allocation_strategy'] ?? null) === 'capacity_only') {
63
+ return $this->allocateCapacityOnly($orders, $vehicles, $options);
64
+ }
65
+
66
+ $tasks = OrchestrationPayloadBuilder::buildRouteTasks($orders);
63
67
  $vroomVehicles = OrchestrationPayloadBuilder::buildVehicles($vehicles);
64
68
 
65
- if (empty($jobs)) {
66
- return ['assignments' => [], 'unassigned' => [], 'summary' => []];
69
+ [$routeTasks, $invalidTasks] = $this->partitionRouteTasks($tasks);
70
+
71
+ if (empty($routeTasks)) {
72
+ return $this->mergeInvalidTasks([
73
+ 'assignments' => [],
74
+ 'unassigned' => [],
75
+ 'summary' => [],
76
+ ], $invalidTasks);
67
77
  }
68
78
 
79
+ $jobIdReverse = [];
80
+ $profile = $options['profile'] ?? env('VROOM_PROFILE', 'driving');
81
+
69
82
  // Map normalized vehicle entries to VROOM vehicle objects.
70
83
  // The 'driver_id' key is not part of the VROOM spec — we carry it in
71
84
  // a description field and use it when mapping results back.
72
85
  $vroomPayload = [
73
- 'jobs' => $jobs,
74
- 'vehicles' => array_map(function (array $v) {
86
+ 'jobs' => [],
87
+ 'shipments' => [],
88
+ 'vehicles' => array_map(function (array $v) use ($profile) {
75
89
  $vehicle = [
76
90
  'id' => crc32($v['id']), // VROOM requires integer IDs
77
91
  'description' => json_encode(['vehicle_id' => $v['id'], 'driver_id' => $v['driver_id']]),
78
92
  'start' => $v['start'],
79
93
  'capacity' => $v['capacity'],
80
94
  ];
95
+ if ($profile) {
96
+ $vehicle['profile'] = $profile;
97
+ }
98
+ if (isset($v['end'])) {
99
+ $vehicle['end'] = $v['end'];
100
+ }
81
101
  if (isset($v['time_window'])) {
82
102
  $vehicle['time_window'] = $v['time_window'];
83
103
  }
84
104
  if (isset($v['skills'])) {
85
105
  $vehicle['skills'] = $v['skills'];
86
106
  }
107
+ if (isset($v['max_tasks'])) {
108
+ $vehicle['max_tasks'] = $v['max_tasks'];
109
+ }
110
+ if (isset($v['max_travel_time'])) {
111
+ $vehicle['max_travel_time'] = $v['max_travel_time'];
112
+ }
87
113
 
88
114
  return $vehicle;
89
115
  }, $vroomVehicles),
@@ -92,48 +118,209 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
92
118
  ],
93
119
  ];
94
120
 
95
- // Map job IDs to integer IDs for VROOM, keeping a reverse lookup
96
- $jobIdMap = [];
97
- $jobIdReverse = [];
98
- foreach ($vroomPayload['jobs'] as &$job) {
99
- $intId = crc32($job['id']);
100
- $jobIdReverse[$intId] = $job['id'];
101
- $job['id'] = $intId;
102
- $jobIdMap[$job['id']] = true;
121
+ foreach ($routeTasks as $task) {
122
+ if (count($task['stops']) > 1) {
123
+ $shipment = $this->mapTaskToShipment($task, $jobIdReverse);
124
+ if ($shipment) {
125
+ $vroomPayload['shipments'][] = $shipment;
126
+ }
127
+
128
+ continue;
129
+ }
130
+
131
+ $job = $this->mapTaskToVroomJob($task, $jobIdReverse);
132
+ if ($job) {
133
+ $vroomPayload['jobs'][] = $job;
134
+ }
103
135
  }
104
- unset($job);
105
136
 
106
- // ── Resolve connection config ─────────────────────────────────────────
107
- $baseUri = $this->resolveVroomBaseUri();
108
- $endpointMode = $this->resolveVroomEndpointMode();
109
- $timeout = (int) env('VROOM_TIMEOUT', 30);
137
+ if (empty($vroomPayload['jobs'])) {
138
+ unset($vroomPayload['jobs']);
139
+ }
140
+ if (empty($vroomPayload['shipments'])) {
141
+ unset($vroomPayload['shipments']);
142
+ }
110
143
 
111
- $apiKey = $this->resolveVroomApiKey();
144
+ $result = $this->callVroom($vroomPayload);
112
145
 
113
- // Build the solve URL — append api_key as query param when present.
114
- $solveUrl = rtrim($baseUri, '/');
115
- if ($endpointMode !== 'binary') {
116
- $solveUrl .= '/solve';
146
+ return $this->mergeInvalidTasks(
147
+ $this->mapVroomResponse($result, $jobIdReverse),
148
+ $invalidTasks
149
+ );
150
+ }
151
+
152
+ protected function allocateCapacityOnly(Collection $orders, Collection $vehicles, array $options = []): array
153
+ {
154
+ $tasks = OrchestrationPayloadBuilder::buildCapacityTasks($orders);
155
+ $vroomVehicles = OrchestrationPayloadBuilder::buildCapacityVehicles($vehicles);
156
+
157
+ [$capacityTasks, $invalidTasks] = $this->partitionRouteTasks($tasks);
158
+
159
+ if (empty($capacityTasks)) {
160
+ return $this->mergeInvalidTasks([
161
+ 'assignments' => [],
162
+ 'unassigned' => [],
163
+ 'summary' => [
164
+ 'engine' => 'vroom',
165
+ 'allocation_strategy' => 'capacity_only',
166
+ ],
167
+ ], $invalidTasks);
117
168
  }
118
- if ($apiKey) {
119
- $solveUrl .= '?api_key=' . urlencode($apiKey);
169
+
170
+ $jobIdReverse = [];
171
+ $vroomPayload = $this->buildCapacityOnlyPayload($capacityTasks, $vroomVehicles, $options, $jobIdReverse);
172
+
173
+ if (empty($vroomPayload['vehicles'])) {
174
+ $vehiclePacking = $this->resolveVehiclePacking($options);
175
+ $vehicleFixedCost = $this->resolveVehicleFixedCost($options);
176
+
177
+ return $this->mergeInvalidTasks([
178
+ 'assignments' => [],
179
+ 'unassigned' => array_column($capacityTasks, 'id'),
180
+ 'summary' => [
181
+ 'engine' => 'vroom',
182
+ 'allocation_strategy' => 'capacity_only',
183
+ 'vehicle_packing' => $vehiclePacking,
184
+ 'vehicle_fixed_cost' => $vehiclePacking === 'minimize_vehicles' ? $vehicleFixedCost : null,
185
+ 'assigned' => 0,
186
+ 'unassigned' => count($capacityTasks),
187
+ 'unassigned_reasons' => array_map(fn (array $task) => [
188
+ 'order_id' => $task['id'],
189
+ 'reason' => 'no_available_vehicle',
190
+ ], $capacityTasks),
191
+ ],
192
+ ], $invalidTasks);
120
193
  }
121
194
 
122
- // ── Call VROOM ────────────────────────────────────────────────────────
123
- try {
124
- $response = Http::timeout($timeout)->post($solveUrl, $vroomPayload);
125
- } catch (\Exception $e) {
126
- Log::error('[VroomOrchestrationEngine] HTTP request failed: ' . $e->getMessage());
127
- throw new \RuntimeException('VROOM allocation engine is unavailable: ' . $e->getMessage() . ' — ensure VROOM_HOST is reachable or switch to the built-in greedy engine.', 0, $e);
195
+ $result = $this->mapVroomResponse($this->callVroom($vroomPayload), $jobIdReverse);
196
+
197
+ $result['summary'] = array_merge($result['summary'] ?? [], [
198
+ 'engine' => 'vroom',
199
+ 'allocation_strategy' => 'capacity_only',
200
+ 'vehicle_packing' => $this->resolveVehiclePacking($options),
201
+ 'vehicle_fixed_cost' => $this->resolveVehiclePacking($options) === 'minimize_vehicles' ? $this->resolveVehicleFixedCost($options) : null,
202
+ ]);
203
+
204
+ return $this->mergeInvalidTasks($result, $invalidTasks);
205
+ }
206
+
207
+ protected function buildCapacityOnlyPayload(array $capacityTasks, array $vroomVehicles, array $options, array &$jobIdReverse): array
208
+ {
209
+ $matrixSize = count($capacityTasks) + 1;
210
+ $profile = $options['profile'] ?? 'capacity_only';
211
+ $respectCapacity = (bool) ($options['respect_capacity'] ?? true);
212
+ $respectSkills = (bool) ($options['respect_skills'] ?? true);
213
+ $vehiclePacking = $this->resolveVehiclePacking($options);
214
+ $fixedCost = $this->resolveVehicleFixedCost($options);
215
+
216
+ $vroomPayload = [
217
+ 'jobs' => [],
218
+ 'vehicles' => array_map(function (array $v) use ($profile, $respectCapacity, $respectSkills, $vehiclePacking, $fixedCost) {
219
+ $vehicle = [
220
+ 'id' => crc32($v['id']),
221
+ 'description' => json_encode(['vehicle_id' => $v['id'], 'driver_id' => $v['driver_id'] ?? null]),
222
+ 'profile' => $profile,
223
+ 'start_index' => 0,
224
+ ];
225
+
226
+ if ($vehiclePacking === 'minimize_vehicles') {
227
+ $vehicle['costs'] = ['fixed' => $fixedCost];
228
+ }
229
+
230
+ if ($respectCapacity) {
231
+ $vehicle['capacity'] = $v['capacity'];
232
+ }
233
+ if ($respectSkills && isset($v['skills'])) {
234
+ $vehicle['skills'] = $v['skills'];
235
+ }
236
+
237
+ foreach (['time_window', 'max_tasks', 'max_travel_time'] as $key) {
238
+ if (isset($v[$key])) {
239
+ $vehicle[$key] = $v[$key];
240
+ }
241
+ }
242
+
243
+ return $vehicle;
244
+ }, $vroomVehicles),
245
+ 'matrices' => [
246
+ $profile => [
247
+ 'durations' => $this->buildUniformMatrix($matrixSize),
248
+ 'distances' => $this->buildUniformMatrix($matrixSize),
249
+ ],
250
+ ],
251
+ 'options' => [
252
+ 'g' => false,
253
+ ],
254
+ ];
255
+
256
+ foreach ($capacityTasks as $index => $task) {
257
+ $orderId = $task['id'];
258
+ $intId = crc32($orderId);
259
+ $jobIdReverse[$intId] = $orderId;
260
+
261
+ $job = [
262
+ 'id' => $intId,
263
+ 'location_index' => $index + 1,
264
+ 'description' => $task['description'] ?? $orderId,
265
+ ];
266
+
267
+ if ($respectCapacity) {
268
+ $job['delivery'] = $task['amount'] ?? [0, 0, 0, 0];
269
+ }
270
+ if ($respectSkills && isset($task['skills'])) {
271
+ $job['skills'] = $task['skills'];
272
+ }
273
+
274
+ $this->copyTaskFields($task, $job, ['priority']);
275
+ $vroomPayload['jobs'][] = $job;
128
276
  }
129
277
 
130
- if (!$response->successful()) {
131
- throw new \RuntimeException('VROOM returned an error: HTTP ' . $response->status() . ' — ' . $response->body());
278
+ return $vroomPayload;
279
+ }
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
+
295
+ protected function buildUniformMatrix(int $size): array
296
+ {
297
+ $matrix = [];
298
+
299
+ for ($row = 0; $row < $size; $row++) {
300
+ $matrix[$row] = [];
301
+ for ($column = 0; $column < $size; $column++) {
302
+ $matrix[$row][$column] = $row === $column ? 0 : 1;
303
+ }
132
304
  }
133
305
 
134
- $result = $response->json();
306
+ return $matrix;
307
+ }
308
+
309
+ protected function partitionRouteTasks(array $tasks): array
310
+ {
311
+ $routeTasks = [];
312
+ $invalidTasks = [];
313
+
314
+ foreach ($tasks as $task) {
315
+ if (!empty($task['invalid'])) {
316
+ $invalidTasks[] = $task;
317
+ continue;
318
+ }
319
+
320
+ $routeTasks[] = $task;
321
+ }
135
322
 
136
- return $this->mapVroomResponse($result, $jobIdReverse);
323
+ return [$routeTasks, $invalidTasks];
137
324
  }
138
325
 
139
326
  /**
@@ -152,7 +339,7 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
152
339
  $sequence = 0;
153
340
 
154
341
  foreach ($route['steps'] ?? [] as $step) {
155
- if ($step['type'] !== 'job') {
342
+ if (!in_array($step['type'], ['job', 'delivery'], true)) {
156
343
  continue;
157
344
  }
158
345
  $orderId = $jobIdReverse[$step['id']] ?? null;
@@ -178,11 +365,143 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
178
365
 
179
366
  return [
180
367
  'assignments' => $assignments,
181
- 'unassigned' => array_values($unassigned),
368
+ 'unassigned' => array_values(array_unique($unassigned)),
182
369
  'summary' => $vroomResult['summary'] ?? [],
183
370
  ];
184
371
  }
185
372
 
373
+ protected function mapTaskToVroomJob(array $task, array &$jobIdReverse): ?array
374
+ {
375
+ $stop = $task['stops'][0] ?? null;
376
+ if (!$stop || empty($stop['location'])) {
377
+ return null;
378
+ }
379
+
380
+ $orderId = $task['id'];
381
+ $intId = crc32($orderId);
382
+ $jobIdReverse[$intId] = $orderId;
383
+
384
+ $vroomJob = [
385
+ 'id' => $intId,
386
+ 'location' => $stop['location'],
387
+ 'description' => $task['description'] ?? $orderId,
388
+ ];
389
+
390
+ $this->copyTaskFields($task, $vroomJob, ['service', 'amount', 'time_windows', 'skills', 'priority']);
391
+
392
+ return $vroomJob;
393
+ }
394
+
395
+ protected function mapTaskToShipment(array $task, array &$jobIdReverse): ?array
396
+ {
397
+ $stops = $task['stops'] ?? [];
398
+ $first = $stops[0] ?? null;
399
+ $last = $stops[count($stops) - 1] ?? null;
400
+
401
+ if (!$first || !$last || empty($first['location']) || empty($last['location'])) {
402
+ return null;
403
+ }
404
+
405
+ $orderId = $task['id'];
406
+ $pickupId = crc32($orderId . ':pickup');
407
+ $deliveryId = crc32($orderId . ':delivery');
408
+
409
+ // We only create one FleetOps assignment per order, so route pickup
410
+ // steps are ignored while delivery and unassigned entries still map
411
+ // back to the order public_id.
412
+ $jobIdReverse[$pickupId] = $orderId;
413
+ $jobIdReverse[$deliveryId] = $orderId;
414
+
415
+ $shipment = [
416
+ 'pickup' => [
417
+ 'id' => $pickupId,
418
+ 'location' => $first['location'],
419
+ 'description' => ($task['description'] ?? $orderId) . ' pickup',
420
+ ],
421
+ 'delivery' => [
422
+ 'id' => $deliveryId,
423
+ 'location' => $last['location'],
424
+ 'description' => ($task['description'] ?? $orderId) . ' delivery',
425
+ ],
426
+ ];
427
+
428
+ if (array_key_exists('service', $task)) {
429
+ $shipment['delivery']['service'] = $task['service'];
430
+ }
431
+ if (array_key_exists('time_windows', $task)) {
432
+ $shipment['delivery']['time_windows'] = $task['time_windows'];
433
+ }
434
+
435
+ $this->copyTaskFields($task, $shipment, ['amount', 'skills', 'priority']);
436
+
437
+ return $shipment;
438
+ }
439
+
440
+ protected function copyTaskFields(array $task, array &$target, array $keys): void
441
+ {
442
+ foreach ($keys as $key) {
443
+ if (array_key_exists($key, $task)) {
444
+ $target[$key] = $task[$key];
445
+ }
446
+ }
447
+ }
448
+
449
+ protected function mergeInvalidTasks(array $result, array $invalidTasks): array
450
+ {
451
+ if (empty($invalidTasks)) {
452
+ return $result;
453
+ }
454
+
455
+ $invalid = array_map(fn (array $task) => [
456
+ 'order_id' => $task['id'],
457
+ 'reason' => $task['reason'] ?? 'Order has invalid route coordinates.',
458
+ ], $invalidTasks);
459
+
460
+ $result['unassigned'] = array_values(array_unique(array_merge(
461
+ $result['unassigned'] ?? [],
462
+ array_column($invalid, 'order_id')
463
+ )));
464
+
465
+ $summary = $result['summary'] ?? [];
466
+ $summary['invalid'] = array_values(array_merge($summary['invalid'] ?? [], $invalid));
467
+ $result['summary'] = $summary;
468
+
469
+ return $result;
470
+ }
471
+
472
+ protected function callVroom(array $vroomPayload): array
473
+ {
474
+ // ── Resolve connection config ─────────────────────────────────────────
475
+ $baseUri = $this->resolveVroomBaseUri();
476
+ $endpointMode = $this->resolveVroomEndpointMode();
477
+ $timeout = (int) env('VROOM_TIMEOUT', 30);
478
+
479
+ $apiKey = $this->resolveVroomApiKey();
480
+
481
+ // Build the solve URL — append api_key as query param when present.
482
+ $solveUrl = rtrim($baseUri, '/');
483
+ if ($endpointMode !== 'binary') {
484
+ $solveUrl .= '/solve';
485
+ }
486
+ if ($apiKey) {
487
+ $solveUrl .= '?api_key=' . urlencode($apiKey);
488
+ }
489
+
490
+ // ── Call VROOM ────────────────────────────────────────────────────────
491
+ try {
492
+ $response = Http::timeout($timeout)->post($solveUrl, $vroomPayload);
493
+ } catch (\Exception $e) {
494
+ Log::error('[VroomOrchestrationEngine] HTTP request failed: ' . $e->getMessage());
495
+ throw new \RuntimeException('VROOM allocation engine is unavailable: ' . $e->getMessage() . ' — ensure VROOM_HOST is reachable or switch to the built-in greedy engine.', 0, $e);
496
+ }
497
+
498
+ if (!$response->successful()) {
499
+ throw new \RuntimeException('VROOM returned an error: HTTP ' . $response->status() . ' — ' . $response->body());
500
+ }
501
+
502
+ return $response->json();
503
+ }
504
+
186
505
  protected function resolveVroomBaseUri(): string
187
506
  {
188
507
  return $this->resolveVroomSetting('api_host', config('vroom.base_uri', env('VROOM_HOST', 'https://api.verso-optim.com/vrp/v1')));