@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
@@ -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;
@@ -97,6 +98,8 @@ class OrderController extends FleetOpsController
97
98
  function ($request, &$input) {
98
99
  $serviceQuote = ServiceQuote::resolveFromRequest($request);
99
100
 
101
+ $this->normalizeCustomerType($input);
102
+
100
103
  // if service quote is applied, resolve it
101
104
  if ($serviceQuote instanceof ServiceQuote && $serviceQuote->fromIntegratedVendor()) {
102
105
  // create order with integrated vendor, then resume fleetbase order creation
@@ -119,7 +122,7 @@ class OrderController extends FleetOpsController
119
122
 
120
123
  if ($resolvedOrderConfig) {
121
124
  $input['order_config_uuid'] = $resolvedOrderConfig->uuid;
122
- $input['type'] = $resolvedOrderConfig->key;
125
+ $input['type'] = $resolvedOrderConfig->key;
123
126
  } elseif (!isset($input['type'])) {
124
127
  $input['type'] = 'transport';
125
128
  }
@@ -218,6 +221,33 @@ class OrderController extends FleetOpsController
218
221
  }
219
222
  }
220
223
 
224
+ /**
225
+ * Resolve the concrete polymorphic customer type from the submitted UUID.
226
+ */
227
+ protected function normalizeCustomerType(array &$input): void
228
+ {
229
+ $customerUuid = data_get($input, 'customer_uuid') ?? data_get($input, 'customer.uuid');
230
+ if (!$customerUuid) {
231
+ return;
232
+ }
233
+
234
+ $customer = Utils::getUuid(
235
+ ['contacts', 'vendors'],
236
+ [
237
+ 'uuid' => $customerUuid,
238
+ 'company_uuid' => session('company'),
239
+ ],
240
+ [
241
+ 'with_table' => true,
242
+ ]
243
+ );
244
+
245
+ if (is_array($customer)) {
246
+ $input['customer_uuid'] = Utils::get($customer, 'uuid');
247
+ $input['customer_type'] = Utils::getModelClassName(Utils::get($customer, 'table'));
248
+ }
249
+ }
250
+
221
251
  /**
222
252
  * Route which enables editing of an order route.
223
253
  *
@@ -733,7 +763,7 @@ class OrderController extends FleetOpsController
733
763
  return response()->error('No order found.');
734
764
  }
735
765
 
736
- $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
766
+ $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
737
767
  $orderConfig = $order->ensureOrderConfig();
738
768
  if (!$orderConfig) {
739
769
  return response()->error('No order config found for order.');
@@ -763,33 +793,57 @@ class OrderController extends FleetOpsController
763
793
  *
764
794
  * @return \Illuminate\Http\Response
765
795
  */
766
- public function trackerInfo(string $id)
796
+ public function trackerInfo(Request $request, string $id)
767
797
  {
768
798
  $order = Order::findById($id);
769
799
  if (!$order) {
770
800
  return response()->error('No order found.');
771
801
  }
772
802
 
773
- // Cache tracker data for 30 seconds with order-specific key
774
- $cacheKey = "order:{$order->uuid}:tracker";
775
- $trackerInfo = Cache::remember($cacheKey, 30, function () use ($order) {
776
- return $order->tracker()->toArray();
777
- });
778
-
779
- return response()->json($trackerInfo);
803
+ return response()->json($order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled'])));
780
804
  }
781
805
 
782
- public function waypointEtas(string $id)
806
+ public function waypointEtas(Request $request, string $id)
783
807
  {
784
808
  $order = Order::findById($id);
785
809
  if (!$order) {
786
810
  return response()->error('No order found.');
787
811
  }
788
812
 
789
- // Get order tracker
790
- $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
+ }
791
836
 
792
- return response()->json($eta);
837
+ try {
838
+ $order->driverAssigned->notify(new OrderPing($order));
839
+
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
+ }
793
847
  }
794
848
 
795
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);