@fleetbase/fleetops-engine 0.6.47 → 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 (90) 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/kanban-card.hbs +18 -3
  19. package/addon/components/order-list-overlay/order.hbs +18 -3
  20. package/addon/components/order-progress-card.hbs +21 -6
  21. package/addon/components/order-tracking-lookup.hbs +7 -7
  22. package/addon/components/order-tracking-lookup.js +1 -1
  23. package/addon/components/route-list.hbs +78 -8
  24. package/addon/components/route-list.js +48 -6
  25. package/addon/components/tracking-stop-progress.hbs +45 -0
  26. package/addon/components/tracking-stop-progress.js +79 -0
  27. package/addon/controllers/settings/routing.js +98 -1
  28. package/addon/helpers/format-duration.js +38 -0
  29. package/addon/routes/application.js +6 -0
  30. package/addon/services/order-list-overlay.js +2 -2
  31. package/addon/styles/fleetops-engine.css +733 -0
  32. package/addon/templates/settings/routing.hbs +60 -2
  33. package/app/components/tracking-stop-progress.js +1 -0
  34. package/app/helpers/format-duration.js +1 -0
  35. package/composer.json +1 -1
  36. package/extension.json +1 -1
  37. package/package.json +1 -1
  38. package/server/config/fleetops.php +32 -11
  39. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  40. package/server/seeders/VroomOrchestrationSeeder.php +402 -0
  41. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  43. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  44. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  45. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  46. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  47. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/OrderController.php +40 -15
  49. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  50. package/server/src/Http/Controllers/Internal/v1/SettingController.php +119 -12
  51. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  52. package/server/src/Models/Contact.php +122 -13
  53. package/server/src/Models/Order.php +1 -1
  54. package/server/src/Models/Payload.php +8 -8
  55. package/server/src/Models/Place.php +0 -3
  56. package/server/src/Models/ServiceRate.php +1 -1
  57. package/server/src/Observers/ContactObserver.php +8 -0
  58. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  59. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +330 -38
  60. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  61. package/server/src/Providers/FleetOpsServiceProvider.php +27 -0
  62. package/server/src/Support/LiveOrderQuery.php +75 -0
  63. package/server/src/Support/Metrics.php +15 -0
  64. package/server/src/Support/OSRM.php +11 -6
  65. package/server/src/Support/OrderTracker.php +7 -582
  66. package/server/src/Support/Utils.php +1 -1
  67. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  68. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  69. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  70. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  71. package/server/src/Tracking/README.md +25 -0
  72. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  73. package/server/src/Tracking/TrackingContext.php +63 -0
  74. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  75. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  76. package/server/src/Tracking/TrackingOptions.php +49 -0
  77. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  78. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  79. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  80. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  81. package/server/src/Tracking/TrackingStop.php +59 -0
  82. package/server/src/routes.php +11 -0
  83. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  84. package/server/tests/LiveOrderQueryTest.php +34 -0
  85. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  86. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  87. package/server/tests/PingDriverEndpointTest.php +19 -0
  88. package/server/tests/TrackingIntelligenceTest.php +176 -0
  89. package/server/tests/VroomOrchestrationEngineTest.php +159 -0
  90. package/translations/en-us.yaml +4 -0
@@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Place;
13
13
  use Fleetbase\FleetOps\Models\Route;
14
14
  use Fleetbase\FleetOps\Models\Vehicle;
15
15
  use Fleetbase\FleetOps\Support\LiveCacheService;
16
+ use Fleetbase\FleetOps\Support\LiveOrderQuery;
16
17
  use Fleetbase\Http\Controllers\Controller;
17
18
  use Illuminate\Http\Request;
18
19
 
@@ -110,50 +111,13 @@ class LiveController extends Controller
110
111
  ];
111
112
 
112
113
  return LiveCacheService::remember('orders', $cacheParams, function () use ($exclude, $active, $unassigned) {
113
- $query = Order::where('company_uuid', session('company'))
114
- ->whereHas('payload', function ($query) {
115
- $query->where(
116
- function ($q) {
117
- $q->whereHas('waypoints');
118
- $q->orWhereHas('pickup');
119
- $q->orWhereHas('dropoff');
120
- }
121
- );
122
- })
123
- ->whereNotIn('status', ['canceled', 'completed', 'expired'])
124
- ->whereHas('trackingNumber')
125
- ->whereHas('trackingStatuses')
126
- ->whereNotIn('public_id', $exclude)
127
- ->whereNull('deleted_at')
128
- ->applyDirectivesForPermissions('fleet-ops list order')
129
- ->with([
130
- 'payload.entities',
131
- 'payload.dropoff',
132
- 'payload.pickup',
133
- 'payload.return',
134
- 'payload.firstWaypointMarker.place',
135
- 'payload.lastWaypointMarker.place',
136
- 'trackingNumber',
137
- 'trackingStatuses',
138
- 'driverAssigned' => function ($query) {
139
- $query->without(['jobs', 'currentJob']);
140
- },
141
- 'vehicleAssigned' => function ($query) {
142
- $query->without(['fleets', 'vendor']);
143
- },
144
- 'customer',
145
- 'facilitator',
114
+ $query = LiveOrderQuery::make(session('company'), [
115
+ 'exclude' => $exclude,
116
+ 'active' => $active,
117
+ 'unassigned' => $unassigned,
118
+ 'with_relations' => true,
146
119
  ]);
147
120
 
148
- if ($active) {
149
- $query->whereHas('driverAssigned');
150
- $query->whereNotIn('status', ['created', 'completed', 'expired', 'order_canceled', 'canceled', 'pending']);
151
- }
152
-
153
- if ($unassigned) {
154
- $query->whereNull('driver_assigned_uuid');
155
- }
156
-
157
121
  $query->limit(60); // max 60 latest
158
122
  $query->latest();
159
123
 
@@ -573,7 +573,7 @@ class OrchestrationController extends Controller
573
573
  $isMulti = $orderType === 'multi_waypoint';
574
574
 
575
575
  // ── Resolve OrderConfig ───────────────────────────────────────
576
- $orderConfigUuid = null;
576
+ $orderConfigUuid = null;
577
577
  $resolvedOrderConfig = null;
578
578
  if (!empty($firstRow['type'])) {
579
579
  $resolvedOrderConfig = OrderConfig::resolveFromIdentifier($firstRow['type']);
@@ -26,16 +26,17 @@ use Fleetbase\FleetOps\Models\Proof;
26
26
  use Fleetbase\FleetOps\Models\ServiceQuote;
27
27
  use Fleetbase\FleetOps\Models\TrackingStatus;
28
28
  use Fleetbase\FleetOps\Models\Waypoint;
29
+ use Fleetbase\FleetOps\Notifications\OrderPing;
29
30
  use Fleetbase\FleetOps\Support\Utils;
30
31
  use Fleetbase\Http\Requests\ExportRequest;
31
32
  use Fleetbase\Http\Requests\Internal\BulkActionRequest;
32
33
  use Fleetbase\Models\File;
33
34
  use Fleetbase\Models\Type;
35
+ use Fleetbase\Support\Auth;
34
36
  use Fleetbase\Support\TemplateString;
35
37
  use Illuminate\Database\Eloquent\ModelNotFoundException;
36
38
  use Illuminate\Database\QueryException;
37
39
  use Illuminate\Http\Request;
38
- use Illuminate\Support\Facades\Cache;
39
40
  use Illuminate\Support\Facades\DB;
40
41
  use Illuminate\Support\Facades\Validator;
41
42
  use Illuminate\Support\Str;
@@ -121,7 +122,7 @@ class OrderController extends FleetOpsController
121
122
 
122
123
  if ($resolvedOrderConfig) {
123
124
  $input['order_config_uuid'] = $resolvedOrderConfig->uuid;
124
- $input['type'] = $resolvedOrderConfig->key;
125
+ $input['type'] = $resolvedOrderConfig->key;
125
126
  } elseif (!isset($input['type'])) {
126
127
  $input['type'] = 'transport';
127
128
  }
@@ -762,7 +763,7 @@ class OrderController extends FleetOpsController
762
763
  return response()->error('No order found.');
763
764
  }
764
765
 
765
- $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
766
+ $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
766
767
  $orderConfig = $order->ensureOrderConfig();
767
768
  if (!$orderConfig) {
768
769
  return response()->error('No order config found for order.');
@@ -792,33 +793,57 @@ class OrderController extends FleetOpsController
792
793
  *
793
794
  * @return \Illuminate\Http\Response
794
795
  */
795
- public function trackerInfo(string $id)
796
+ public function trackerInfo(Request $request, string $id)
796
797
  {
797
798
  $order = Order::findById($id);
798
799
  if (!$order) {
799
800
  return response()->error('No order found.');
800
801
  }
801
802
 
802
- // Cache tracker data for 30 seconds with order-specific key
803
- $cacheKey = "order:{$order->uuid}:tracker";
804
- $trackerInfo = Cache::remember($cacheKey, 30, function () use ($order) {
805
- return $order->tracker()->toArray();
806
- });
807
-
808
- return response()->json($trackerInfo);
803
+ return response()->json($order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled'])));
809
804
  }
810
805
 
811
- public function waypointEtas(string $id)
806
+ public function waypointEtas(Request $request, string $id)
812
807
  {
813
808
  $order = Order::findById($id);
814
809
  if (!$order) {
815
810
  return response()->error('No order found.');
816
811
  }
817
812
 
818
- // Get order tracker
819
- $eta = $order->tracker()->eta();
813
+ return response()->json($order->tracker()->eta($request->only(['provider', 'fallbacks', 'traffic_enabled'])));
814
+ }
815
+
816
+ /**
817
+ * Ping the assigned driver to refresh/order attention in the driver app.
818
+ *
819
+ * @return \Illuminate\Http\JsonResponse
820
+ */
821
+ public function pingDriver(string $id)
822
+ {
823
+ if (!Auth::can('fleet-ops update order')) {
824
+ return response()->error('Unauthorized.', 403);
825
+ }
826
+
827
+ try {
828
+ $order = Order::findByIdOrFail($id, ['driverAssigned']);
829
+ } catch (ModelNotFoundException $e) {
830
+ return response()->error('Order resource not found.', 404);
831
+ }
832
+
833
+ if (!$order->driverAssigned) {
834
+ return response()->error('Order does not have an assigned driver.', 422);
835
+ }
836
+
837
+ try {
838
+ $order->driverAssigned->notify(new OrderPing($order));
820
839
 
821
- return response()->json($eta);
840
+ return response()->json([
841
+ 'status' => 'ok',
842
+ 'message' => 'Driver app ping sent.',
843
+ ]);
844
+ } catch (\Throwable $e) {
845
+ return response()->error('Unable to ping driver app.', 500);
846
+ }
822
847
  }
823
848
 
824
849
  /**
@@ -226,7 +226,7 @@ class ServiceQuoteController extends FleetOpsController
226
226
  $entities = collect($entities)->mapInto(Entity::class);
227
227
 
228
228
  // should all be Place like
229
- $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
229
+ $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
230
230
  $endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place);
231
231
 
232
232
  // if facilitator is an integrated partner resolve service quotes from bridge
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry;
5
6
  use Fleetbase\Http\Controllers\Controller;
6
7
  use Fleetbase\Models\Setting;
7
8
  use Fleetbase\Support\Auth;
@@ -211,12 +212,12 @@ class SettingController extends Controller
211
212
  {
212
213
  $routingSettings = Setting::lookupCompany('routing', ['router' => 'osrm', 'unit' => 'km']);
213
214
 
214
- $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
215
- $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
216
- $routingSettings['router'] = $displayEngine;
217
- $routingSettings['display_engine'] = $displayEngine;
218
- $routingSettings['optimization_engine'] = $optimizationEngine;
219
- $routingSettings['routing_display_engine'] = $displayEngine;
215
+ $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
216
+ $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
217
+ $routingSettings['router'] = $displayEngine;
218
+ $routingSettings['display_engine'] = $displayEngine;
219
+ $routingSettings['optimization_engine'] = $optimizationEngine;
220
+ $routingSettings['routing_display_engine'] = $displayEngine;
220
221
  $routingSettings['routing_optimization_engine'] = $optimizationEngine;
221
222
 
222
223
  // always default to km if no unit is set
@@ -227,6 +228,87 @@ class SettingController extends Controller
227
228
  return response()->json($routingSettings);
228
229
  }
229
230
 
231
+ /**
232
+ * Save order tracking intelligence settings.
233
+ *
234
+ * @param Request $request the HTTP request object containing tracking settings
235
+ *
236
+ * @return \Illuminate\Http\JsonResponse
237
+ */
238
+ public function saveTrackingSettings(Request $request)
239
+ {
240
+ $config = $this->trackingDefaults();
241
+ $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated']));
242
+ if (is_string($fallbacks)) {
243
+ $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks))));
244
+ }
245
+
246
+ Setting::configureCompany('tracking', [
247
+ 'provider' => $request->input('provider', data_get($config, 'provider', 'google_routes')),
248
+ 'fallbacks' => $fallbacks,
249
+ 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)),
250
+ 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)),
251
+ 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
252
+ 'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
253
+ 'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
254
+ ]);
255
+
256
+ return response()->json([
257
+ 'status' => 'ok',
258
+ 'message' => 'Tracking settings succesfully saved.',
259
+ ]);
260
+ }
261
+
262
+ /**
263
+ * Retrieve order tracking intelligence settings.
264
+ *
265
+ * @return \Illuminate\Http\JsonResponse
266
+ */
267
+ public function getTrackingSettings()
268
+ {
269
+ $config = $this->trackingDefaults();
270
+ $trackingSettings = Setting::lookupCompany('tracking', [
271
+ 'provider' => data_get($config, 'provider', 'google_routes'),
272
+ 'fallbacks' => data_get($config, 'fallbacks', ['osrm', 'calculated']),
273
+ 'traffic_enabled' => data_get($config, 'traffic_enabled', true),
274
+ 'cache_ttl_seconds' => data_get($config, 'cache_ttl_seconds', 60),
275
+ 'route_cache_ttl_seconds' => data_get($config, 'route_cache_ttl_seconds', 600),
276
+ 'stale_location_threshold_seconds' => data_get($config, 'stale_location_threshold_seconds', 300),
277
+ 'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35),
278
+ ]);
279
+ $trackingSettings['providers'] = $this->trackingProviderOptions();
280
+
281
+ return response()->json($trackingSettings);
282
+ }
283
+
284
+ public function getAdminTrackingSettings()
285
+ {
286
+ return response()->json(array_merge($this->trackingDefaults(), [
287
+ 'providers' => $this->trackingProviderOptions(),
288
+ ]));
289
+ }
290
+
291
+ public function saveAdminTrackingSettings(Request $request)
292
+ {
293
+ $config = config('fleetops.tracking', []);
294
+ $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated']));
295
+ if (is_string($fallbacks)) {
296
+ $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks))));
297
+ }
298
+
299
+ Setting::configure('fleet-ops.tracking-settings', [
300
+ 'provider' => $request->input('provider', data_get($config, 'provider', 'google_routes')),
301
+ 'fallbacks' => $fallbacks,
302
+ 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)),
303
+ 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)),
304
+ 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
305
+ 'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
306
+ 'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
307
+ ]);
308
+
309
+ return response()->json($this->getAdminTrackingSettings()->getData(true));
310
+ }
311
+
230
312
  /**
231
313
  * Retrieve and return the map provider settings for the current company.
232
314
  *
@@ -244,15 +326,15 @@ class SettingController extends Controller
244
326
  'mapProvider' => 'leaflet',
245
327
  ];
246
328
 
247
- $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
248
- $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
329
+ $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
330
+ $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
249
331
  $mapSettings['mapProvider'] = data_get($mapSettings, 'mapProvider') ?: data_get($systemMapSettings, 'mapProvider', 'leaflet');
250
332
 
251
333
  // Source the Google Maps API key from the system-level services config
252
334
  // that is managed by the core-api admin settings panel. This ensures a
253
335
  // single source of truth and avoids duplicating key management.
254
336
  $mapSettings['googleMapsApiKey'] = config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY', ''));
255
- $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
337
+ $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
256
338
 
257
339
  return response()->json($mapSettings);
258
340
  }
@@ -289,7 +371,7 @@ class SettingController extends Controller
289
371
  public function getAdminMapSettings()
290
372
  {
291
373
  $defaults = [
292
- 'mapProvider' => 'leaflet',
374
+ 'mapProvider' => 'leaflet',
293
375
  'googleMapsMapId' => '',
294
376
  ];
295
377
 
@@ -299,13 +381,13 @@ class SettingController extends Controller
299
381
  public function saveAdminMapSettings(Request $request)
300
382
  {
301
383
  $allowedProviders = ['leaflet', 'google'];
302
- $mapProvider = $request->input('mapProvider', 'leaflet');
384
+ $mapProvider = $request->input('mapProvider', 'leaflet');
303
385
  if (!in_array($mapProvider, $allowedProviders)) {
304
386
  $mapProvider = 'leaflet';
305
387
  }
306
388
 
307
389
  $settings = [
308
- 'mapProvider' => $mapProvider,
390
+ 'mapProvider' => $mapProvider,
309
391
  'googleMapsMapId' => (string) $request->input('googleMapsMapId', ''),
310
392
  ];
311
393
 
@@ -431,4 +513,29 @@ class SettingController extends Controller
431
513
  'settings' => $normalized,
432
514
  ]);
433
515
  }
516
+
517
+ protected function trackingDefaults(): array
518
+ {
519
+ $config = config('fleetops.tracking', []);
520
+ $systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
521
+
522
+ return array_merge($config, is_array($systemSettings) ? $systemSettings : []);
523
+ }
524
+
525
+ protected function trackingProviderOptions(): array
526
+ {
527
+ $registry = app(TrackingProviderRegistry::class);
528
+
529
+ return collect($registry->all())->map(function ($provider, $key) {
530
+ $label = $key === 'osrm' ? 'OSRM' : str($key)->replace('_', ' ')->title()->toString();
531
+
532
+ return [
533
+ 'key' => $key,
534
+ 'name' => $label,
535
+ 'value' => $key,
536
+ 'label' => $label,
537
+ 'capabilities' => $provider->capabilities()->toArray(),
538
+ ];
539
+ })->values()->all();
540
+ }
434
541
  }
@@ -19,8 +19,8 @@ class Payload extends FleetbaseResource
19
19
  public function toArray($request): array
20
20
  {
21
21
  $isInternal = Http::isInternalRequest();
22
- $pickup = $this->index_pickup_place;
23
- $dropoff = $this->index_dropoff_place;
22
+ $pickup = $this->index_pickup_place;
23
+ $dropoff = $this->index_dropoff_place;
24
24
 
25
25
  return [
26
26
  'id' => $this->when($isInternal, $this->id, $this->public_id),
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Models;
5
5
  use Fleetbase\Casts\Json;
6
6
  use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
7
7
  use Fleetbase\FleetOps\Support\Utils;
8
+ use Fleetbase\Models\CompanyUser;
8
9
  use Fleetbase\Models\Invite;
9
10
  use Fleetbase\Models\Model;
10
11
  use Fleetbase\Models\User;
@@ -261,6 +262,14 @@ class Contact extends Model
261
262
  return $this->type === 'customer';
262
263
  }
263
264
 
265
+ /**
266
+ * Determines if this contact is a Fleet-Ops customer.
267
+ */
268
+ public function isCustomer(): bool
269
+ {
270
+ return $this->type === 'customer';
271
+ }
272
+
264
273
  /**
265
274
  * Creates a new contact from an import row.
266
275
  */
@@ -308,19 +317,33 @@ class Contact extends Model
308
317
  public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User
309
318
  {
310
319
  // Check if user already exist with email or phone number
311
- $existingUser = User::where(function ($query) use ($contact) {
312
- $query->where('company_uuid', $contact->company_uuid);
313
- $query->where('email', $contact->email);
314
- if ($contact->phone) {
315
- $query->orWhere('phone', Utils::formatPhoneNumber($contact->phone));
316
- }
317
- })->whereNull('deleted_at')->first();
320
+ $existingUser = null;
321
+ if ($contact->email || $contact->phone) {
322
+ $existingUser = User::where('company_uuid', $contact->company_uuid)
323
+ ->where(function ($query) use ($contact) {
324
+ if ($contact->email) {
325
+ $query->where('email', $contact->email);
326
+ }
327
+
328
+ if ($contact->phone) {
329
+ $method = $contact->email ? 'orWhere' : 'where';
330
+ $query->{$method}('phone', Utils::formatPhoneNumber($contact->phone));
331
+ }
332
+ })
333
+ ->whereNull('deleted_at')
334
+ ->first();
335
+ }
336
+
318
337
  if ($existingUser) {
319
338
  // Check if existing user belongs to another contact
320
339
  $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first();
321
340
  if ($existingUserContact) {
322
341
  throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser);
323
342
  } else {
343
+ if ($contact->isCustomer()) {
344
+ $contact->normalizeCustomerUser($existingUser);
345
+ }
346
+
324
347
  // Assign the user to this contact instead
325
348
  $contact->setAttribute('user_uuid', $existingUser->uuid);
326
349
  if ($update) {
@@ -351,10 +374,10 @@ class Contact extends Model
351
374
  $user->setType($contact->type);
352
375
 
353
376
  // Assing to company
354
- $user->assignCompany($contact->company, $user->type === 'customer' ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
377
+ $user->assignCompany($contact->company, $contact->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
355
378
 
356
379
  // Assign customer role
357
- if ($user->type === 'customer') {
380
+ if ($contact->isCustomer()) {
358
381
  $user->assignSingleRole('Fleet-Ops Customer');
359
382
  }
360
383
 
@@ -386,6 +409,87 @@ class Contact extends Model
386
409
  return $user;
387
410
  }
388
411
 
412
+ /**
413
+ * Normalize the linked user for a customer contact.
414
+ */
415
+ public function normalizeCustomerUser(?User $user = null, bool $quiet = false): ?User
416
+ {
417
+ if (!$this->isCustomer()) {
418
+ return $user;
419
+ }
420
+
421
+ $user ??= $this->getUser();
422
+ if (!$user) {
423
+ return null;
424
+ }
425
+
426
+ if ($user->type !== 'customer') {
427
+ if ($quiet) {
428
+ $user->forceFill(['type' => 'customer'])->saveQuietly();
429
+ } else {
430
+ $user->setType('customer');
431
+ }
432
+ }
433
+
434
+ $this->loadMissing('company');
435
+ if ($this->company) {
436
+ $companyUser = CompanyUser::firstOrCreate(
437
+ [
438
+ 'company_uuid' => $this->company->uuid,
439
+ 'user_uuid' => $user->uuid,
440
+ ],
441
+ [
442
+ 'company_uuid' => $this->company->uuid,
443
+ 'user_uuid' => $user->uuid,
444
+ 'status' => $user->status ?? 'active',
445
+ ]
446
+ );
447
+ $user->forceFill(['company_uuid' => $this->company->uuid])->saveQuietly();
448
+ $user->setRelation('companyUser', $companyUser);
449
+
450
+ if ($companyUser) {
451
+ $companyUser->assignSingleRole('Fleet-Ops Customer');
452
+ }
453
+ }
454
+
455
+ $this->setRelation('user', $user);
456
+
457
+ return $user;
458
+ }
459
+
460
+ /**
461
+ * Repair a historical customer contact and its linked user.
462
+ */
463
+ public function repairCustomerTypeInvariant(bool $quiet = false): ?User
464
+ {
465
+ if (!$this->isCustomer()) {
466
+ $this->forceFill(['type' => 'customer'])->saveQuietly();
467
+ }
468
+
469
+ $user = $this->getUser();
470
+ if (!$user && ($this->email || $this->phone)) {
471
+ if ($quiet) {
472
+ $this->loadMissing('company');
473
+ $user = User::create([
474
+ 'company_uuid' => $this->company_uuid,
475
+ 'name' => $this->name,
476
+ 'email' => $this->email,
477
+ 'phone' => $this->phone ? Utils::formatPhoneNumber($this->phone) : null,
478
+ 'username' => Str::slug($this->name . '_' . Str::random(4), '_'),
479
+ 'password' => Str::random(),
480
+ 'timezone' => $this->company->timezone ?? date_default_timezone_get(),
481
+ 'status' => 'pending',
482
+ ]);
483
+ $user->forceFill(['type' => 'customer'])->saveQuietly();
484
+ $this->forceFill(['user_uuid' => $user->uuid])->saveQuietly();
485
+ } else {
486
+ $user = $this->createUser();
487
+ }
488
+ }
489
+
490
+ return $this->normalizeCustomerUser($user, $quiet);
491
+ }
492
+
389
493
  /**
390
494
  * Assigns a user to the company and optionally sends an invitation email.
391
495
  *
@@ -406,16 +510,21 @@ class Contact extends Model
406
510
  public function assignUser(User $user, bool $sendInvite = false): self
407
511
  {
408
512
  // Load company
409
- $this->loadMissing('copmany');
513
+ $this->loadMissing('company');
514
+
515
+ if ($this->isCustomer() && $user->type !== 'customer') {
516
+ $user->setType('customer');
517
+ }
410
518
 
411
519
  // Assing to company
412
- $user->assignCompany($this->company, $this->type === 'customer' ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
520
+ $user->assignCompany($this->company, $this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
413
521
 
414
522
  // Get the company user instance
415
523
  $companyUser = $user->getCompanyUser($this->company);
416
524
 
417
- // Assign customer role
418
- $companyUser->assignSingleRole('Fleet-Ops Customer');
525
+ if ($companyUser) {
526
+ $companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
527
+ }
419
528
 
420
529
  // Set user to contact
421
530
  $this->update(['user_uuid' => $user->uuid]);
@@ -1739,7 +1739,7 @@ class Order extends Model
1739
1739
  }
1740
1740
  }
1741
1741
 
1742
- $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
1742
+ $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
1743
1743
  $orderConfig = OrderConfig::defaultOrCreate($company);
1744
1744
  if ($orderConfig instanceof OrderConfig) {
1745
1745
  $orderConfig->setOrderContext($this);
@@ -555,15 +555,15 @@ class Payload extends Model
555
555
  }
556
556
 
557
557
  $this->loadMissing('waypointMarkers');
558
- $keptWaypointIds = [];
558
+ $keptWaypointIds = [];
559
559
  $availableWaypointMarkers = $this->waypointMarkers()->get();
560
560
 
561
561
  foreach ($waypoints as $index => $attributes) {
562
- $raw = $attributes;
563
- $type = data_get($raw, 'type', 'dropoff');
564
- $customerUuidIn = data_get($raw, 'customer_uuid');
562
+ $raw = $attributes;
563
+ $type = data_get($raw, 'type', 'dropoff');
564
+ $customerUuidIn = data_get($raw, 'customer_uuid');
565
565
  $customerPubIdIn = data_get($raw, 'customer_id');
566
- $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
566
+ $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
567
567
 
568
568
  if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) {
569
569
  $attributes = Utils::get($attributes, 'place');
@@ -599,12 +599,12 @@ class Payload extends Model
599
599
  $placeUuid = $place->uuid;
600
600
  }
601
601
 
602
- $customerUuid = null;
602
+ $customerUuid = null;
603
603
  $customerTypeNamespace = null;
604
604
 
605
605
  if ($customerType) {
606
606
  $customerTypeNamespace = Utils::getMutationType($customerType);
607
- $customerModel = app($customerTypeNamespace);
607
+ $customerModel = app($customerTypeNamespace);
608
608
 
609
609
  if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) {
610
610
  $customerUuid = $customerUuidIn;
@@ -642,7 +642,7 @@ class Payload extends Model
642
642
  continue;
643
643
  }
644
644
 
645
- $waypointRecord = Waypoint::create($values);
645
+ $waypointRecord = Waypoint::create($values);
646
646
  $keptWaypointIds[] = $waypointRecord->uuid;
647
647
  }
648
648
 
@@ -21,7 +21,6 @@ use Fleetbase\Traits\Searchable;
21
21
  use Fleetbase\Traits\SendsWebhooks;
22
22
  use Fleetbase\Traits\TracksApiCredential;
23
23
  use Illuminate\Support\Carbon;
24
- use Illuminate\Support\Facades\DB;
25
24
  use Illuminate\Support\Str;
26
25
 
27
26
  class Place extends Model
@@ -383,8 +382,6 @@ class Place extends Model
383
382
 
384
383
  /**
385
384
  * Create a new Place instance from a geocoding lookup.
386
- *
387
- * @return array
388
385
  */
389
386
  public static function getValuesFromGeocodingLookup(string $address): array
390
387
  {
@@ -740,7 +740,7 @@ class ServiceRate extends Model
740
740
 
741
741
  if ($this->isAlgorithm()) {
742
742
  $resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
743
- $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
743
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
744
744
  $this->algorithm,
745
745
  $this->buildAlgorithmVariables(
746
746
  $entities,