@fleetbase/fleetops-engine 0.6.46 → 0.6.48

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 (95) 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/map/order-list-overlay/order.hbs +18 -3
  6. package/addon/components/map/toolbar.hbs +2 -2
  7. package/addon/components/map/toolbar.js +10 -2
  8. package/addon/components/orchestrator/phase-builder.hbs +28 -5
  9. package/addon/components/orchestrator/phase-builder.js +21 -1
  10. package/addon/components/orchestrator/resource-panel.hbs +19 -1
  11. package/addon/components/orchestrator/resource-panel.js +4 -0
  12. package/addon/components/orchestrator-workbench.js +3 -1
  13. package/addon/components/order/details/purchase-rate.hbs +19 -22
  14. package/addon/components/order/details/route.hbs +26 -1
  15. package/addon/components/order/details/route.js +50 -0
  16. package/addon/components/order/details/tracking.hbs +171 -27
  17. package/addon/components/order/details/tracking.js +457 -0
  18. package/addon/components/order/form/details.hbs +1 -2
  19. package/addon/components/order/form/details.js +6 -0
  20. package/addon/components/order/form/route.js +9 -1
  21. package/addon/components/order/kanban-card.hbs +18 -3
  22. package/addon/components/order-list-overlay/order.hbs +18 -3
  23. package/addon/components/order-progress-card.hbs +21 -6
  24. package/addon/components/order-tracking-lookup.hbs +7 -7
  25. package/addon/components/order-tracking-lookup.js +1 -1
  26. package/addon/components/route-list.hbs +78 -8
  27. package/addon/components/route-list.js +48 -6
  28. package/addon/components/tracking-stop-progress.hbs +45 -0
  29. package/addon/components/tracking-stop-progress.js +79 -0
  30. package/addon/controllers/settings/payments/index.js +1 -1
  31. package/addon/controllers/settings/routing.js +98 -1
  32. package/addon/helpers/format-duration.js +38 -0
  33. package/addon/routes/application.js +6 -0
  34. package/addon/services/order-list-overlay.js +2 -2
  35. package/addon/styles/fleetops-engine.css +733 -0
  36. package/addon/templates/settings/routing.hbs +60 -2
  37. package/app/components/tracking-stop-progress.js +1 -0
  38. package/app/helpers/format-duration.js +1 -0
  39. package/composer.json +1 -1
  40. package/extension.json +1 -1
  41. package/package.json +3 -3
  42. package/pnpm-workspace.yaml +7 -0
  43. package/server/config/fleetops.php +32 -11
  44. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  45. package/server/seeders/VroomOrchestrationSeeder.php +402 -0
  46. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  47. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  48. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  49. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  50. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  51. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  52. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  53. package/server/src/Http/Controllers/Internal/v1/OrderController.php +69 -15
  54. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  55. package/server/src/Http/Controllers/Internal/v1/SettingController.php +119 -12
  56. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  57. package/server/src/Models/Contact.php +122 -13
  58. package/server/src/Models/Order.php +1 -1
  59. package/server/src/Models/Payload.php +8 -8
  60. package/server/src/Models/Place.php +0 -3
  61. package/server/src/Models/ServiceRate.php +1 -1
  62. package/server/src/Observers/ContactObserver.php +8 -0
  63. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  64. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +330 -38
  65. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  66. package/server/src/Providers/FleetOpsServiceProvider.php +27 -0
  67. package/server/src/Support/LiveOrderQuery.php +75 -0
  68. package/server/src/Support/Metrics.php +15 -0
  69. package/server/src/Support/OSRM.php +11 -6
  70. package/server/src/Support/OrderTracker.php +7 -582
  71. package/server/src/Support/Utils.php +1 -1
  72. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  73. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  74. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  75. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  76. package/server/src/Tracking/README.md +25 -0
  77. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  78. package/server/src/Tracking/TrackingContext.php +63 -0
  79. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  80. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  81. package/server/src/Tracking/TrackingOptions.php +49 -0
  82. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  83. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  84. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  85. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  86. package/server/src/Tracking/TrackingStop.php +59 -0
  87. package/server/src/routes.php +11 -0
  88. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  89. package/server/tests/LiveOrderQueryTest.php +34 -0
  90. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  91. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  92. package/server/tests/PingDriverEndpointTest.php +19 -0
  93. package/server/tests/TrackingIntelligenceTest.php +176 -0
  94. package/server/tests/VroomOrchestrationEngineTest.php +159 -0
  95. package/translations/en-us.yaml +4 -0
@@ -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,182 @@ 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
+ return $this->mergeInvalidTasks([
175
+ 'assignments' => [],
176
+ 'unassigned' => array_column($capacityTasks, 'id'),
177
+ 'summary' => [
178
+ 'engine' => 'vroom',
179
+ 'allocation_strategy' => 'capacity_only',
180
+ 'assigned' => 0,
181
+ 'unassigned' => count($capacityTasks),
182
+ 'unassigned_reasons' => array_map(fn (array $task) => [
183
+ 'order_id' => $task['id'],
184
+ 'reason' => 'no_available_vehicle',
185
+ ], $capacityTasks),
186
+ ],
187
+ ], $invalidTasks);
120
188
  }
121
189
 
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);
190
+ $result = $this->mapVroomResponse($this->callVroom($vroomPayload), $jobIdReverse);
191
+
192
+ $result['summary'] = array_merge($result['summary'] ?? [], [
193
+ 'engine' => 'vroom',
194
+ 'allocation_strategy' => 'capacity_only',
195
+ ]);
196
+
197
+ return $this->mergeInvalidTasks($result, $invalidTasks);
198
+ }
199
+
200
+ protected function buildCapacityOnlyPayload(array $capacityTasks, array $vroomVehicles, array $options, array &$jobIdReverse): array
201
+ {
202
+ $matrixSize = count($capacityTasks) + 1;
203
+ $profile = $options['profile'] ?? 'capacity_only';
204
+ $respectCapacity = (bool) ($options['respect_capacity'] ?? true);
205
+ $respectSkills = (bool) ($options['respect_skills'] ?? true);
206
+
207
+ $vroomPayload = [
208
+ 'jobs' => [],
209
+ 'vehicles' => array_map(function (array $v) use ($profile, $respectCapacity, $respectSkills) {
210
+ $vehicle = [
211
+ 'id' => crc32($v['id']),
212
+ 'description' => json_encode(['vehicle_id' => $v['id'], 'driver_id' => $v['driver_id'] ?? null]),
213
+ 'profile' => $profile,
214
+ 'start_index' => 0,
215
+ ];
216
+
217
+ if ($respectCapacity) {
218
+ $vehicle['capacity'] = $v['capacity'];
219
+ }
220
+ if ($respectSkills && isset($v['skills'])) {
221
+ $vehicle['skills'] = $v['skills'];
222
+ }
223
+
224
+ foreach (['time_window', 'max_tasks', 'max_travel_time'] as $key) {
225
+ if (isset($v[$key])) {
226
+ $vehicle[$key] = $v[$key];
227
+ }
228
+ }
229
+
230
+ return $vehicle;
231
+ }, $vroomVehicles),
232
+ 'matrices' => [
233
+ $profile => [
234
+ 'durations' => $this->buildUniformMatrix($matrixSize),
235
+ 'distances' => $this->buildUniformMatrix($matrixSize),
236
+ ],
237
+ ],
238
+ 'options' => [
239
+ 'g' => false,
240
+ ],
241
+ ];
242
+
243
+ foreach ($capacityTasks as $index => $task) {
244
+ $orderId = $task['id'];
245
+ $intId = crc32($orderId);
246
+ $jobIdReverse[$intId] = $orderId;
247
+
248
+ $job = [
249
+ 'id' => $intId,
250
+ 'location_index' => $index + 1,
251
+ 'description' => $task['description'] ?? $orderId,
252
+ ];
253
+
254
+ if ($respectCapacity) {
255
+ $job['delivery'] = $task['amount'] ?? [0, 0, 0, 0];
256
+ }
257
+ if ($respectSkills && isset($task['skills'])) {
258
+ $job['skills'] = $task['skills'];
259
+ }
260
+
261
+ $this->copyTaskFields($task, $job, ['priority']);
262
+ $vroomPayload['jobs'][] = $job;
128
263
  }
129
264
 
130
- if (!$response->successful()) {
131
- throw new \RuntimeException('VROOM returned an error: HTTP ' . $response->status() . ' — ' . $response->body());
265
+ return $vroomPayload;
266
+ }
267
+
268
+ protected function buildUniformMatrix(int $size): array
269
+ {
270
+ $matrix = [];
271
+
272
+ for ($row = 0; $row < $size; $row++) {
273
+ $matrix[$row] = [];
274
+ for ($column = 0; $column < $size; $column++) {
275
+ $matrix[$row][$column] = $row === $column ? 0 : 1;
276
+ }
132
277
  }
133
278
 
134
- $result = $response->json();
279
+ return $matrix;
280
+ }
281
+
282
+ protected function partitionRouteTasks(array $tasks): array
283
+ {
284
+ $routeTasks = [];
285
+ $invalidTasks = [];
286
+
287
+ foreach ($tasks as $task) {
288
+ if (!empty($task['invalid'])) {
289
+ $invalidTasks[] = $task;
290
+ continue;
291
+ }
292
+
293
+ $routeTasks[] = $task;
294
+ }
135
295
 
136
- return $this->mapVroomResponse($result, $jobIdReverse);
296
+ return [$routeTasks, $invalidTasks];
137
297
  }
138
298
 
139
299
  /**
@@ -152,7 +312,7 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
152
312
  $sequence = 0;
153
313
 
154
314
  foreach ($route['steps'] ?? [] as $step) {
155
- if ($step['type'] !== 'job') {
315
+ if (!in_array($step['type'], ['job', 'delivery'], true)) {
156
316
  continue;
157
317
  }
158
318
  $orderId = $jobIdReverse[$step['id']] ?? null;
@@ -178,11 +338,143 @@ class VroomOrchestrationEngine implements OrchestrationEngineInterface
178
338
 
179
339
  return [
180
340
  'assignments' => $assignments,
181
- 'unassigned' => array_values($unassigned),
341
+ 'unassigned' => array_values(array_unique($unassigned)),
182
342
  'summary' => $vroomResult['summary'] ?? [],
183
343
  ];
184
344
  }
185
345
 
346
+ protected function mapTaskToVroomJob(array $task, array &$jobIdReverse): ?array
347
+ {
348
+ $stop = $task['stops'][0] ?? null;
349
+ if (!$stop || empty($stop['location'])) {
350
+ return null;
351
+ }
352
+
353
+ $orderId = $task['id'];
354
+ $intId = crc32($orderId);
355
+ $jobIdReverse[$intId] = $orderId;
356
+
357
+ $vroomJob = [
358
+ 'id' => $intId,
359
+ 'location' => $stop['location'],
360
+ 'description' => $task['description'] ?? $orderId,
361
+ ];
362
+
363
+ $this->copyTaskFields($task, $vroomJob, ['service', 'amount', 'time_windows', 'skills', 'priority']);
364
+
365
+ return $vroomJob;
366
+ }
367
+
368
+ protected function mapTaskToShipment(array $task, array &$jobIdReverse): ?array
369
+ {
370
+ $stops = $task['stops'] ?? [];
371
+ $first = $stops[0] ?? null;
372
+ $last = $stops[count($stops) - 1] ?? null;
373
+
374
+ if (!$first || !$last || empty($first['location']) || empty($last['location'])) {
375
+ return null;
376
+ }
377
+
378
+ $orderId = $task['id'];
379
+ $pickupId = crc32($orderId . ':pickup');
380
+ $deliveryId = crc32($orderId . ':delivery');
381
+
382
+ // We only create one FleetOps assignment per order, so route pickup
383
+ // steps are ignored while delivery and unassigned entries still map
384
+ // back to the order public_id.
385
+ $jobIdReverse[$pickupId] = $orderId;
386
+ $jobIdReverse[$deliveryId] = $orderId;
387
+
388
+ $shipment = [
389
+ 'pickup' => [
390
+ 'id' => $pickupId,
391
+ 'location' => $first['location'],
392
+ 'description' => ($task['description'] ?? $orderId) . ' pickup',
393
+ ],
394
+ 'delivery' => [
395
+ 'id' => $deliveryId,
396
+ 'location' => $last['location'],
397
+ 'description' => ($task['description'] ?? $orderId) . ' delivery',
398
+ ],
399
+ ];
400
+
401
+ if (array_key_exists('service', $task)) {
402
+ $shipment['delivery']['service'] = $task['service'];
403
+ }
404
+ if (array_key_exists('time_windows', $task)) {
405
+ $shipment['delivery']['time_windows'] = $task['time_windows'];
406
+ }
407
+
408
+ $this->copyTaskFields($task, $shipment, ['amount', 'skills', 'priority']);
409
+
410
+ return $shipment;
411
+ }
412
+
413
+ protected function copyTaskFields(array $task, array &$target, array $keys): void
414
+ {
415
+ foreach ($keys as $key) {
416
+ if (array_key_exists($key, $task)) {
417
+ $target[$key] = $task[$key];
418
+ }
419
+ }
420
+ }
421
+
422
+ protected function mergeInvalidTasks(array $result, array $invalidTasks): array
423
+ {
424
+ if (empty($invalidTasks)) {
425
+ return $result;
426
+ }
427
+
428
+ $invalid = array_map(fn (array $task) => [
429
+ 'order_id' => $task['id'],
430
+ 'reason' => $task['reason'] ?? 'Order has invalid route coordinates.',
431
+ ], $invalidTasks);
432
+
433
+ $result['unassigned'] = array_values(array_unique(array_merge(
434
+ $result['unassigned'] ?? [],
435
+ array_column($invalid, 'order_id')
436
+ )));
437
+
438
+ $summary = $result['summary'] ?? [];
439
+ $summary['invalid'] = array_values(array_merge($summary['invalid'] ?? [], $invalid));
440
+ $result['summary'] = $summary;
441
+
442
+ return $result;
443
+ }
444
+
445
+ protected function callVroom(array $vroomPayload): array
446
+ {
447
+ // ── Resolve connection config ─────────────────────────────────────────
448
+ $baseUri = $this->resolveVroomBaseUri();
449
+ $endpointMode = $this->resolveVroomEndpointMode();
450
+ $timeout = (int) env('VROOM_TIMEOUT', 30);
451
+
452
+ $apiKey = $this->resolveVroomApiKey();
453
+
454
+ // Build the solve URL — append api_key as query param when present.
455
+ $solveUrl = rtrim($baseUri, '/');
456
+ if ($endpointMode !== 'binary') {
457
+ $solveUrl .= '/solve';
458
+ }
459
+ if ($apiKey) {
460
+ $solveUrl .= '?api_key=' . urlencode($apiKey);
461
+ }
462
+
463
+ // ── Call VROOM ────────────────────────────────────────────────────────
464
+ try {
465
+ $response = Http::timeout($timeout)->post($solveUrl, $vroomPayload);
466
+ } catch (\Exception $e) {
467
+ Log::error('[VroomOrchestrationEngine] HTTP request failed: ' . $e->getMessage());
468
+ 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);
469
+ }
470
+
471
+ if (!$response->successful()) {
472
+ throw new \RuntimeException('VROOM returned an error: HTTP ' . $response->status() . ' — ' . $response->body());
473
+ }
474
+
475
+ return $response->json();
476
+ }
477
+
186
478
  protected function resolveVroomBaseUri(): string
187
479
  {
188
480
  return $this->resolveVroomSetting('api_host', config('vroom.base_uri', env('VROOM_HOST', 'https://api.verso-optim.com/vrp/v1')));