From b4b5763245f6d1618a561b3e2f23a5eb4c20e3c9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 10:13:12 +0800 Subject: [PATCH 01/19] Implement order tracking intelligence providers --- addon/components/customer/orders.js | 2 +- .../map/order-list-overlay/order.hbs | 6 +- addon/components/order-list-overlay/order.hbs | 6 +- addon/components/order-progress-card.hbs | 12 +- addon/components/order-tracking-lookup.hbs | 14 +- addon/components/order-tracking-lookup.js | 2 +- addon/components/order/details/tracking.hbs | 12 +- addon/components/order/kanban-card.hbs | 6 +- server/config/fleetops.php | 42 +- .../Controllers/Api/v1/OrderController.php | 52 +- .../Internal/v1/OrderController.php | 22 +- .../Internal/v1/SettingController.php | 74 ++- .../src/Providers/FleetOpsServiceProvider.php | 24 + server/src/Support/OSRM.php | 17 +- server/src/Support/OrderTracker.php | 589 +----------------- .../Contracts/TrackingProviderInterface.php | 19 + .../Providers/CalculatedTrackingProvider.php | 65 ++ .../GoogleRoutesTrackingProvider.php | 118 ++++ .../Providers/OsrmTrackingProvider.php | 71 +++ server/src/Tracking/README.md | 25 + .../Tracking/Support/FakeTrackingProvider.php | 42 ++ server/src/Tracking/TrackingContext.php | 49 ++ .../src/Tracking/TrackingContextBuilder.php | 177 ++++++ .../Tracking/TrackingIntelligenceService.php | 148 +++++ server/src/Tracking/TrackingOptions.php | 46 ++ .../Tracking/TrackingProviderCapabilities.php | 30 + .../src/Tracking/TrackingProviderManager.php | 69 ++ .../src/Tracking/TrackingProviderRegistry.php | 35 ++ .../src/Tracking/TrackingProviderResult.php | 20 + server/src/Tracking/TrackingStop.php | 59 ++ server/src/routes.php | 2 + server/tests/TrackingIntelligenceTest.php | 110 ++++ 32 files changed, 1282 insertions(+), 683 deletions(-) create mode 100644 server/src/Tracking/Contracts/TrackingProviderInterface.php create mode 100644 server/src/Tracking/Providers/CalculatedTrackingProvider.php create mode 100644 server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php create mode 100644 server/src/Tracking/Providers/OsrmTrackingProvider.php create mode 100644 server/src/Tracking/README.md create mode 100644 server/src/Tracking/Support/FakeTrackingProvider.php create mode 100644 server/src/Tracking/TrackingContext.php create mode 100644 server/src/Tracking/TrackingContextBuilder.php create mode 100644 server/src/Tracking/TrackingIntelligenceService.php create mode 100644 server/src/Tracking/TrackingOptions.php create mode 100644 server/src/Tracking/TrackingProviderCapabilities.php create mode 100644 server/src/Tracking/TrackingProviderManager.php create mode 100644 server/src/Tracking/TrackingProviderRegistry.php create mode 100644 server/src/Tracking/TrackingProviderResult.php create mode 100644 server/src/Tracking/TrackingStop.php create mode 100644 server/tests/TrackingIntelligenceTest.php diff --git a/addon/components/customer/orders.js b/addon/components/customer/orders.js index 8289fc339..1095742f3 100644 --- a/addon/components/customer/orders.js +++ b/addon/components/customer/orders.js @@ -138,7 +138,7 @@ export default class CustomerOrdersComponent extends Component { // start loading order tracking activity order.loadTrackingActivity(); this.urlSearchParams.addParamToCurrentUrl('order', order.public_id); - const driverCurrentLocation = order.get('tracker_data.driver_current_location'); + const driverCurrentLocation = order.get('tracker_data.driver.location'); if (driverCurrentLocation) { this.latitude = driverCurrentLocation.coordinates[1]; this.longitude = driverCurrentLocation.coordinates[0]; diff --git a/addon/components/map/order-list-overlay/order.hbs b/addon/components/map/order-list-overlay/order.hbs index 73262e4d2..a63db9351 100644 --- a/addon/components/map/order-list-overlay/order.hbs +++ b/addon/components/map/order-list-overlay/order.hbs @@ -27,9 +27,9 @@
diff --git a/addon/components/order-list-overlay/order.hbs b/addon/components/order-list-overlay/order.hbs index d7e486e3d..92fe825ce 100644 --- a/addon/components/order-list-overlay/order.hbs +++ b/addon/components/order-list-overlay/order.hbs @@ -26,9 +26,9 @@
diff --git a/addon/components/order-progress-card.hbs b/addon/components/order-progress-card.hbs index 4db072c6a..922e655a7 100644 --- a/addon/components/order-progress-card.hbs +++ b/addon/components/order-progress-card.hbs @@ -16,9 +16,9 @@
@@ -41,11 +41,11 @@
{{t "order.fields.current-eta"}}:
-
{{format-duration this.order.tracker_data.current_destination_eta}}
+
{{format-duration this.order.tracker_data.eta.active_stop_seconds}}
{{t "order.fields.ect"}}:
-
{{n-a this.order.tracker_data.estimated_completion_time_formatted}}
+
{{n-a this.order.tracker_data.eta.completion_at}}
{{t "order.fields.driver"}}:
@@ -57,7 +57,7 @@
{{t "order.fields.current-destination"}}:
-
{{n-a this.order.tracker_data.current_destination.address}}
+
{{n-a this.order.tracker_data.active_stop.address}}
diff --git a/addon/components/order-tracking-lookup.hbs b/addon/components/order-tracking-lookup.hbs index 7f5773c49..1509db38a 100644 --- a/addon/components/order-tracking-lookup.hbs +++ b/addon/components/order-tracking-lookup.hbs @@ -90,27 +90,27 @@
Current ETA:
-
{{format-duration this.order.tracker_data.current_destination_eta}}
+
{{format-duration this.order.tracker_data.eta.active_stop_seconds}}
ECT:
-
{{n-a this.order.tracker_data.estimated_completion_time_formatted}}
+
{{n-a this.order.tracker_data.eta.completion_at}}
Current Destination:
-
{{n-a this.order.tracker_data.current_destination.address}}
+
{{n-a this.order.tracker_data.active_stop.address}}
Next Destination:
-
{{n-a this.order.tracker_data.next_destination.address}}
+
{{n-a this.order.tracker_data.next_stop.address}}
diff --git a/addon/components/order-tracking-lookup.js b/addon/components/order-tracking-lookup.js index 3a27a41df..f341833e1 100644 --- a/addon/components/order-tracking-lookup.js +++ b/addon/components/order-tracking-lookup.js @@ -50,7 +50,7 @@ export default class OrderTrackingLookupComponent extends Component { try { this.order = yield this.fetch.get('fleet-ops/lookup', { tracking: this.trackingNumber }, { normalizeToEmberData: true, normalizeModelType: 'order' }); this.urlSearchParams.addParamToCurrentUrl('order', this.order.tracking); - const driverCurrentLocation = this.order.get('tracker_data.driver_current_location'); + const driverCurrentLocation = this.order.get('tracker_data.driver.location'); if (driverCurrentLocation) { this.latitude = driverCurrentLocation.coordinates[1]; this.longitude = driverCurrentLocation.coordinates[0]; diff --git a/addon/components/order/details/tracking.hbs b/addon/components/order/details/tracking.hbs index 1a663e7c9..a9564a43c 100644 --- a/addon/components/order/details/tracking.hbs +++ b/addon/components/order/details/tracking.hbs @@ -4,23 +4,23 @@
Current ETA
-
{{n-a (format-duration @resource.tracker_data.current_destination_eta)}}
+
{{n-a (format-duration @resource.tracker_data.eta.active_stop_seconds)}}
Current ECT
-
{{n-a @resource.tracker_data.estimated_completion_time_formatted}}
+
{{n-a @resource.tracker_data.eta.completion_at}}
Current Destination
-
{{n-a @resource.tracker_data.current_destination.address}}
+
{{n-a @resource.tracker_data.active_stop.address}}
diff --git a/addon/components/order/kanban-card.hbs b/addon/components/order/kanban-card.hbs index 2788dce8c..74d4f3230 100644 --- a/addon/components/order/kanban-card.hbs +++ b/addon/components/order/kanban-card.hbs @@ -15,9 +15,9 @@
diff --git a/server/config/fleetops.php b/server/config/fleetops.php index ce87d7398..316be6fcd 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -3,7 +3,7 @@ /** * ------------------------------------------- * FleetOps API Configuration - * ------------------------------------------- + * -------------------------------------------. */ return [ /* @@ -14,17 +14,17 @@ 'api' => [ 'version' => '0.0.1', 'routing' => [ - 'prefix' => null, - 'internal_prefix' => 'int' - ] + 'prefix' => null, + 'internal_prefix' => 'int', + ], ], 'connection' => [ - 'db' => env('DB_CONNECTION', 'mysql') + 'db' => env('DB_CONNECTION', 'mysql'), ], /* |-------------------------------------------------------------------------- - | Facilitator Fee - This is a percentage fee the system admin takes when + | Facilitator Fee - This is a percentage fee the system admin takes when | facilitating any payments in the system. | Example: if `10` then 10% fee will be taken on all payments. |-------------------------------------------------------------------------- @@ -37,7 +37,7 @@ |-------------------------------------------------------------------------- */ 'osrm' => [ - 'host' => env('OSRM_HOST', 'https://router.project-osrm.org') + 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'), ], /* @@ -61,12 +61,32 @@ /* |-------------------------------------------------------------------------- - | Distance Matrix Calculator + | Distance Matrix Calculator | Options: "calculate", "google", "osrm" |-------------------------------------------------------------------------- */ 'distance_matrix' => [ - 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate') + 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate'), + ], + + /* + |-------------------------------------------------------------------------- + | Order Tracking Intelligence + |-------------------------------------------------------------------------- + | + | Provider-neutral tracking data for active orders. The provider registry + | can be extended by third-party packages without editing FleetOps core. + | + | Built-in providers: "google_routes", "osrm", "calculated" + |-------------------------------------------------------------------------- + */ + 'tracking' => [ + 'provider' => env('TRACKING_PROVIDER', 'google_routes'), + 'fallbacks' => array_filter(explode(',', env('TRACKING_PROVIDER_FALLBACKS', 'osrm,calculated'))), + 'traffic_enabled' => env('TRACKING_TRAFFIC_ENABLED', true), + 'cache_ttl_seconds' => env('TRACKING_CACHE_TTL_SECONDS', 60), + 'stale_location_threshold_seconds' => env('TRACKING_STALE_LOCATION_THRESHOLD_SECONDS', 300), + 'default_vehicle_speed_kph' => env('TRACKING_DEFAULT_VEHICLE_SPEED_KPH', 35), ], /* @@ -76,7 +96,7 @@ */ 'navigator' => [ 'bypass_verification_code' => env('SMS_AUTH_BYPASS_CODE', env('NAVIGATOR_BYPASS_VERIFICATION_CODE')), - 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator') + 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'), ], /* @@ -189,5 +209,5 @@ |-------------------------------------------------------------------------- */ 'versions' => ['2020-09-30', '2024-03-12'], - 'version' => '2024-03-12', + 'version' => '2024-03-12', ]; diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index bb23364b1..c60a7ecad 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -93,17 +93,17 @@ public function create(CreateOrderRequest $request) // create payload if ($request->has('payload') && $request->isArray('payload')) { - $payload = new Payload(); - $payloadInput = $request->input('payload'); - $entities = data_get($payloadInput, 'entities', []); - $waypoints = data_get($payloadInput, 'waypoints', []); - $pickup = data_get($payloadInput, 'pickup'); - $dropoff = data_get($payloadInput, 'dropoff'); - $return = data_get($payloadInput, 'return'); - $hasPickupField = array_key_exists('pickup', $payloadInput); - $hasDropoffField = array_key_exists('dropoff', $payloadInput); - $hasReturnField = array_key_exists('return', $payloadInput); - $hasWaypointsField = array_key_exists('waypoints', $payloadInput); + $payload = new Payload(); + $payloadInput = $request->input('payload'); + $entities = data_get($payloadInput, 'entities', []); + $waypoints = data_get($payloadInput, 'waypoints', []); + $pickup = data_get($payloadInput, 'pickup'); + $dropoff = data_get($payloadInput, 'dropoff'); + $return = data_get($payloadInput, 'return'); + $hasPickupField = array_key_exists('pickup', $payloadInput); + $hasDropoffField = array_key_exists('dropoff', $payloadInput); + $hasReturnField = array_key_exists('return', $payloadInput); + $hasWaypointsField = array_key_exists('waypoints', $payloadInput); $hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField; if ($pickup) { @@ -445,17 +445,17 @@ public function update($id, UpdateOrderRequest $request) // create a payload if missing payload[] but has pickup/dropoff/etc if ($request->missing('payload')) { - $payload = data_get($order, 'payload', new Payload()); - $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']); - $entities = data_get($payloadInput, 'entities', []); - $waypoints = data_get($payloadInput, 'waypoints', []); - $pickup = data_get($payloadInput, 'pickup'); - $dropoff = data_get($payloadInput, 'dropoff'); - $return = data_get($payloadInput, 'return'); - $hasPickupField = $request->exists('pickup'); - $hasDropoffField = $request->exists('dropoff'); - $hasReturnField = $request->exists('return'); - $hasWaypointsField = $request->exists('waypoints'); + $payload = data_get($order, 'payload', new Payload()); + $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']); + $entities = data_get($payloadInput, 'entities', []); + $waypoints = data_get($payloadInput, 'waypoints', []); + $pickup = data_get($payloadInput, 'pickup'); + $dropoff = data_get($payloadInput, 'dropoff'); + $return = data_get($payloadInput, 'return'); + $hasPickupField = $request->exists('pickup'); + $hasDropoffField = $request->exists('dropoff'); + $hasReturnField = $request->exists('return'); + $hasWaypointsField = $request->exists('waypoints'); $hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField; // if no pickup and dropoff extract from waypoints @@ -1328,13 +1328,13 @@ public function optimize(string $id) * * @return \Illuminate\Http\Response */ - public function trackerData(string $id) + public function trackerData(Request $request, string $id) { set_time_limit(280); try { $order = Order::findRecordOrFail($id); - $data = $order->tracker()->toArray(); + $data = $order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled'])); return response()->json($data); } catch (ModelNotFoundException $e) { @@ -1351,11 +1351,11 @@ public function trackerData(string $id) * * @return \Illuminate\Http\Response */ - public function etaData(string $id) + public function etaData(Request $request, string $id) { try { $order = Order::findRecordOrFail($id); - $data = $order->tracker()->eta(); + $data = $order->tracker()->eta($request->only(['provider', 'fallbacks', 'traffic_enabled'])); return response()->json($data); } catch (ModelNotFoundException $e) { diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 71582b9ab..97eb8e3e8 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -35,7 +35,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -121,7 +120,7 @@ function ($request, &$input) { if ($resolvedOrderConfig) { $input['order_config_uuid'] = $resolvedOrderConfig->uuid; - $input['type'] = $resolvedOrderConfig->key; + $input['type'] = $resolvedOrderConfig->key; } elseif (!isset($input['type'])) { $input['type'] = 'transport'; } @@ -762,7 +761,7 @@ public function nextActivity(string $id, Request $request) return response()->error('No order found.'); } - $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null; + $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null; $orderConfig = $order->ensureOrderConfig(); if (!$orderConfig) { return response()->error('No order config found for order.'); @@ -792,33 +791,24 @@ public function nextActivity(string $id, Request $request) * * @return \Illuminate\Http\Response */ - public function trackerInfo(string $id) + public function trackerInfo(Request $request, string $id) { $order = Order::findById($id); if (!$order) { return response()->error('No order found.'); } - // Cache tracker data for 30 seconds with order-specific key - $cacheKey = "order:{$order->uuid}:tracker"; - $trackerInfo = Cache::remember($cacheKey, 30, function () use ($order) { - return $order->tracker()->toArray(); - }); - - return response()->json($trackerInfo); + return response()->json($order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled']))); } - public function waypointEtas(string $id) + public function waypointEtas(Request $request, string $id) { $order = Order::findById($id); if (!$order) { return response()->error('No order found.'); } - // Get order tracker - $eta = $order->tracker()->eta(); - - return response()->json($eta); + return response()->json($order->tracker()->eta($request->only(['provider', 'fallbacks', 'traffic_enabled']))); } /** diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index d1e10b2ce..09bd29324 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -211,12 +211,12 @@ public function getRoutingSettings() { $routingSettings = Setting::lookupCompany('routing', ['router' => 'osrm', 'unit' => 'km']); - $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm'))); - $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine)); - $routingSettings['router'] = $displayEngine; - $routingSettings['display_engine'] = $displayEngine; - $routingSettings['optimization_engine'] = $optimizationEngine; - $routingSettings['routing_display_engine'] = $displayEngine; + $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm'))); + $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine)); + $routingSettings['router'] = $displayEngine; + $routingSettings['display_engine'] = $displayEngine; + $routingSettings['optimization_engine'] = $optimizationEngine; + $routingSettings['routing_display_engine'] = $displayEngine; $routingSettings['routing_optimization_engine'] = $optimizationEngine; // always default to km if no unit is set @@ -227,6 +227,56 @@ public function getRoutingSettings() return response()->json($routingSettings); } + /** + * Save order tracking intelligence settings. + * + * @param Request $request the HTTP request object containing tracking settings + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveTrackingSettings(Request $request) + { + $config = config('fleetops.tracking', []); + $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated'])); + if (is_string($fallbacks)) { + $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); + } + + Setting::configureCompany('tracking', [ + 'provider' => $request->input('provider', data_get($config, 'provider', 'google_routes')), + 'fallbacks' => $fallbacks, + 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)), + 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)), + 'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)), + 'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)), + ]); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Tracking settings succesfully saved.', + ]); + } + + /** + * Retrieve order tracking intelligence settings. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getTrackingSettings() + { + $config = config('fleetops.tracking', []); + $trackingSettings = Setting::lookupCompany('tracking', [ + 'provider' => data_get($config, 'provider', 'google_routes'), + 'fallbacks' => data_get($config, 'fallbacks', ['osrm', 'calculated']), + 'traffic_enabled' => data_get($config, 'traffic_enabled', true), + 'cache_ttl_seconds' => data_get($config, 'cache_ttl_seconds', 60), + 'stale_location_threshold_seconds' => data_get($config, 'stale_location_threshold_seconds', 300), + 'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35), + ]); + + return response()->json($trackingSettings); + } + /** * Retrieve and return the map provider settings for the current company. * @@ -244,15 +294,15 @@ public function getMapSettings() 'mapProvider' => 'leaflet', ]; - $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []); - $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults); + $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []); + $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults); $mapSettings['mapProvider'] = data_get($mapSettings, 'mapProvider') ?: data_get($systemMapSettings, 'mapProvider', 'leaflet'); // Source the Google Maps API key from the system-level services config // that is managed by the core-api admin settings panel. This ensures a // single source of truth and avoids duplicating key management. $mapSettings['googleMapsApiKey'] = config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY', '')); - $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', ''); + $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', ''); return response()->json($mapSettings); } @@ -289,7 +339,7 @@ public function saveMapSettings(Request $request) public function getAdminMapSettings() { $defaults = [ - 'mapProvider' => 'leaflet', + 'mapProvider' => 'leaflet', 'googleMapsMapId' => '', ]; @@ -299,13 +349,13 @@ public function getAdminMapSettings() public function saveAdminMapSettings(Request $request) { $allowedProviders = ['leaflet', 'google']; - $mapProvider = $request->input('mapProvider', 'leaflet'); + $mapProvider = $request->input('mapProvider', 'leaflet'); if (!in_array($mapProvider, $allowedProviders)) { $mapProvider = 'leaflet'; } $settings = [ - 'mapProvider' => $mapProvider, + 'mapProvider' => $mapProvider, 'googleMapsMapId' => (string) $request->input('googleMapsMapId', ''), ]; diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 267e80ef9..715182eb5 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -99,6 +99,13 @@ public function register() \Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry::class, fn () => new \Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry() ); + + // Register the TrackingProviderRegistry as a singleton so FleetOps core + // and third-party extensions can share tracking intelligence providers. + $this->app->singleton( + \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class, + fn () => new \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry() + ); } /** @@ -137,6 +144,23 @@ function (\Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry $registr } } ); + + // Register built-in tracking providers. Third-party extensions can + // register additional providers from their own service providers. + $this->app->resolving( + \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class, + function (\Fleetbase\FleetOps\Tracking\TrackingProviderRegistry $registry) { + if (!$registry->has('google_routes')) { + $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\GoogleRoutesTrackingProvider()); + } + if (!$registry->has('osrm')) { + $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider()); + } + if (!$registry->has('calculated')) { + $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider()); + } + } + ); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'fleetops'); diff --git a/server/src/Support/OSRM.php b/server/src/Support/OSRM.php index 34077424c..4ab8cb2fe 100644 --- a/server/src/Support/OSRM.php +++ b/server/src/Support/OSRM.php @@ -88,7 +88,7 @@ public static function getRouteFromCoordinatesString(string $coordinates, array $cacheKey = 'getRouteFromCoordinatesString:' . md5($coordinates . serialize($queryParameters)); try { - $url = self::$baseUrl . "/route/v1/driving/{$coordinates}"; + $url = static::baseUrl() . "/route/v1/driving/{$coordinates}"; $response = Http::timeout(1)->get($url, $queryParameters); $data = $response->json(); @@ -130,7 +130,7 @@ public static function getNearest(Point $location, array $queryParameters = []) } $coordinates = "{$location->getLng()},{$location->getLat()}"; - $url = self::$baseUrl . "/nearest/v1/driving/{$coordinates}"; + $url = static::baseUrl() . "/nearest/v1/driving/{$coordinates}"; $response = Http::timeout(1)->get($url, $queryParameters); $result = $response->json(); @@ -163,7 +163,7 @@ public static function getTable(array $points, array $queryParameters = []) return "{$point->getLng()},{$point->getLat()}"; }, $points)); - $url = self::$baseUrl . "/table/v1/driving/{$coordinates}"; + $url = static::baseUrl() . "/table/v1/driving/{$coordinates}"; $response = Http::timeout(1)->get($url, $queryParameters); $result = $response->json(); @@ -196,7 +196,7 @@ public static function getTrip(array $points, array $queryParameters = []) return "{$point->getLng()},{$point->getLat()}"; }, $points)); - $url = self::$baseUrl . "/trip/v1/driving/{$coordinates}"; + $url = static::baseUrl() . "/trip/v1/driving/{$coordinates}"; $response = Http::timeout(1)->get($url, $queryParameters); $data = $response->json(); @@ -222,7 +222,7 @@ public static function getMatch(array $points, array $queryParameters = []) return "{$point->getLng()},{$point->getLat()}"; }, $points)); - $url = self::$baseUrl . "/match/v1/driving/{$coordinates}"; + $url = static::baseUrl() . "/match/v1/driving/{$coordinates}"; $response = Http::timeout(1)->get($url, $queryParameters); @@ -241,7 +241,7 @@ public static function getMatch(array $points, array $queryParameters = []) */ public static function getTile(int $z, int $x, int $y, array $queryParameters = []) { - $url = self::$baseUrl . "/tile/v1/car/{$z}/{$x}/{$y}.mvt"; + $url = static::baseUrl() . "/tile/v1/car/{$z}/{$x}/{$y}.mvt"; $response = Http::timeout(1)->get($url, $queryParameters); @@ -259,4 +259,9 @@ public static function decodePolyline($polyline) { return Polyline::decode($polyline); } + + protected static function baseUrl(): string + { + return rtrim(config('fleetops.osrm.host', static::$baseUrl), '/'); + } } diff --git a/server/src/Support/OrderTracker.php b/server/src/Support/OrderTracker.php index 45d1efc6a..2aa5f4743 100644 --- a/server/src/Support/OrderTracker.php +++ b/server/src/Support/OrderTracker.php @@ -2,598 +2,23 @@ namespace Fleetbase\FleetOps\Support; -use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; -use Fleetbase\FleetOps\Models\Payload; -use Fleetbase\FleetOps\Models\Place; -use Fleetbase\FleetOps\Models\Waypoint; -use Fleetbase\LaravelMysqlSpatial\Types\Point; -use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; +use Fleetbase\FleetOps\Tracking\TrackingIntelligenceService; +use Fleetbase\FleetOps\Tracking\TrackingOptions; -/** - * Class OrderTracker. - * - * Provides functionality to track an order's progress, calculate ETA, and retrieve various order-related data. - */ class OrderTracker { - /** @var Order The order being tracked */ - protected Order $order; - - /** @var Payload The payload associated with the order */ - protected Payload $payload; - - /** @var Driver The driver assigned to the order */ - protected ?Driver $driver; - - /** @var bool Flag to indicate if the order has multiple dropoff points */ - protected bool $isMultipleDropOrder = false; - - /** - * Constructor for OrderTracker. - * - * @param Order $order the order to track - */ - public function __construct(Order $order) - { - $order->loadAssignedDriver(); - - $this->order = $order; - $this->payload = $order->getPayload(function ($payload) { - $payload->loadMissing(['pickup', 'dropoff', 'waypoints', 'waypointMarkers']); - }); - $this->isMultipleDropOrder = $this->payload->isMultipleDropOrder; - $this->driver = $order->driverAssigned; - } - - /** - * Get the current location of the driver or starting point. - * - * @return Point|null the current location of the driver, or null if unavailable - */ - public function getDriverCurrentLocation(): ?Point - { - if ($this->driver) { - return $this->driver->location; - } - - $startingPoint = $this->payload->getPickupOrCurrentWaypoint(); - if ($startingPoint) { - return $startingPoint->location; - } - - return null; - } - - /** - * Get the percentage progress of the order. - * - * @return int|float the percentage of the order progress - */ - public function getOrderProgressPercentage(): int|float - { - $totalDistance = $this->getTotalDistance(); - $completedDistance = $this->getCompletedDistance(); - $cannotUseDistance = $totalDistance == -1 || $completedDistance == -1 || $completedDistance === 0; - - // Get order percentage by activity if distance-based progress is not available - if ($cannotUseDistance) { - /** @var Collection $activities */ - $activities = $this->order->orderConfig ? $this->order->orderConfig->activities() : collect(); - $totalActivity = $activities->count(); - if ($totalActivity === 0) { - return 100; // No activities, so treat it as 100% complete - } - - // Calculate completed activities - $completedActivity = $activities->filter(function ($activity) { - return $activity->isCompleted($this->order); - })->count(); - - // Return progress percentage based on completed activities - return round(($completedActivity / $totalActivity) * 100, 2); - } - - if ($totalDistance === 0) { - return 100; - } - - return round(($completedDistance / $totalDistance) * 100, 2); - } - - /** - * Get the total distance for the order. - * - * @return int|float the total distance of the order in meters - */ - public function getTotalDistance(): int|float - { - $points = $this->getAllDestinationPoints()->toArray(); - if (count($points) < 2) { - return -1; - } - - try { - $response = OSRM::getRouteFromPoints($points); - if (isset($response['code']) && $response['code'] === 'Ok') { - $route = Arr::first($response['routes']); - if ($route) { - return data_get($route, 'distance', 0); - } - } - } catch (\Exception $e) { - Log::warning('Error loading OSRM routes from order points.', [$e]); - - return -1; - } - - return -1; - } - - /** - * Get the completed distance by the driver. - * - * @return float the completed distance in meters - */ - public function getCompletedDistance(): float - { - $points = $this->getCompletedDestinationPoints()->toArray(); - if (count($points) < 2) { - return -1; - } - - try { - $response = OSRM::getRouteFromPoints($points); - if (isset($response['code']) && $response['code'] === 'Ok') { - $route = Arr::first($response['routes']); - if ($route) { - return data_get($route, 'distance', 0); - } - } - } catch (\Exception $e) { - Log::warning('Error loading OSRM routes from order points.', [$e]); - - return -1; - } - - return -1; - } - - /** - * Get the estimated time of arrival (ETA) for the current destination. - * - * @return float the ETA in seconds or -1 if unable to calculate - */ - public function getCurrentDestinationETA(): float - { - $start = $this->getDriverCurrentLocation(); - $currentDestination = $this->getCurrentDestination(); - $end = $currentDestination ? $currentDestination->location : null; - if ($start == $end) { - $nextDestination = $this->getNextDestination(); - if ($nextDestination) { - $end = $nextDestination->location; - } - } - - if (!$start || !$end) { - return -1; - } - - // Convert SpatialExpression to Point objects - $start = Utils::getPointFromMixed($start); - $end = Utils::getPointFromMixed($end); - - if (!$start || !$end) { - return -1; - } - - try { - $response = OSRM::getRoute($start, $end); - if (isset($response['code']) && $response['code'] === 'Ok') { - $route = Arr::first($response['routes']); - if ($route) { - return data_get($route, 'duration', -1); - } - } - } catch (\Exception $e) { - Log::warning('Error loading OSRM route from start and end points.', [$e]); - - return -1; - } - - return -1; - } - - public function getWaypointETA(Waypoint|Place $waypoint): float - { - if ($waypoint instanceof Waypoint) { - $waypoint->loadMissing('place'); - $waypoint = $waypoint->place; - } - - $start = $this->getDriverCurrentLocation(); - $end = $waypoint->location; - - // Convert SpatialExpression to Point objects - $start = Utils::getPointFromMixed($start); - $end = Utils::getPointFromMixed($end); - - if (!$start || !$end) { - return -1; - } - - try { - $response = OSRM::getRoute($start, $end); - if (isset($response['code']) && $response['code'] === 'Ok') { - $route = Arr::first($response['routes']); - if ($route) { - return data_get($route, 'duration', -1); - } - } - } catch (\Exception $e) { - Log::warning('Error loading OSRM routes from start and end points.', [$e]); - - return -1; - } - - return -1; - } - - /** - * Get the estimated time of arrival (ETA) for the completion of the order. - * - * @return float the ETA in seconds or -1 if unable to calculate - */ - public function getCompletionETA(): float - { - $start = $this->getDriverCurrentLocation(); - $end = $this->payload->getDropoffOrLastWaypoint()->location; - - // Convert SpatialExpression to Point objects - $start = Utils::getPointFromMixed($start); - $end = Utils::getPointFromMixed($end); - - if (!$start || !$end) { - return -1; - } - - try { - $response = OSRM::getRoute($start, $end); - if (isset($response['code']) && $response['code'] === 'Ok') { - $route = Arr::first($response['routes']); - if ($route) { - return data_get($route, 'duration', -1); - } - } - } catch (\Exception $e) { - Log::warning('Error loading OSRM routes from start and end points.', [$e]); - - return -1; - } - - return -1; - } - - /** - * Get the estimated completion time of the order. - * - * @return Carbon the estimated completion time - */ - public function getEstimatedCompletionTime(): ?Carbon - { - $currentEtaCompletion = $this->getCompletionETA(); - - return now()->addSeconds($currentEtaCompletion); - } - - /** - * Get the start time of the order. - * - * @return Carbon the time the order started - */ - public function getOrderStartTime(): ?Carbon + public function __construct(protected Order $order) { - return $this->order->started_at; } - /** - * Get the completion time of the order, if completed. - * - * @return Carbon|null the time the order was completed, or null if not completed - */ - public function getOrderCompletionTime(): ?Carbon + public function eta(array $options = []): array { - return $this->order->completed_at; + return app(TrackingIntelligenceService::class)->eta($this->order, TrackingOptions::fromArray($options)); } - /** - * Get all destinations (pickup, waypoints, dropoff) for the order. - * - * @return Collection a collection of all destination places - */ - public function getAllDestinations(): Collection + public function toArray(array $options = []): array { - return collect([$this->payload->pickup, ...$this->payload->waypoints, $this->payload->dropoff])->filter(); - } - - /** - * Get all destination points (pickup, waypoints, dropoff) as spatial points. - * - * @return Collection a collection of Point objects for each destination - */ - public function getAllDestinationPoints(): Collection - { - return $this->getAllDestinations()->map(function (Place $place) { - return $place->location; - })->filter()->values(); - } - - /** - * Get the current destination the driver is heading to. - * - * @return Place|null the current destination place object, or null if not available - */ - public function getCurrentDestination(): ?Place - { - if ($this->isMultipleDropOrder) { - if ($this->payload->waypoints) { - $destination = null; - - if (Str::isUuid($this->payload->current_waypoint_uuid)) { - $destination = $this->payload->waypoints->firstWhere('uuid', $this->payload->current_waypoint_uuid); - } - - if (!$destination) { - $destination = $this->payload->waypoints->first(); - } - - return $destination; - } - - return null; - } - - if ($this->order->status === 'created' || $this->order->status === 'dispatched' || $this->order->status === 'pending') { - return $this->payload->pickup; - } - - return $this->payload->dropoff; - } - - /** - * Get the completed destinations of the order. - * - * @return Collection a collection of completed destination places - */ - public function getCompletedDestinations(): Collection - { - if ($this->isMultipleDropOrder) { - return $this->payload->waypointMarkers->filter( - function (Waypoint $waypoint) { - return $this->isWaypointCompleted($waypoint); - } - )->map( - function (Waypoint $waypoint) { - $waypoint->loadMissing('place'); - - return $waypoint->place; - } - ); - } - - if ($this->order->status === 'created' || $this->order->status === 'dispatched' || $this->order->status === 'pending') { - return collect(); - } - - if ($this->order->status === 'completed') { - return collect([$this->getPickup(), $this->getDropoff()]); - } - - return collect([$this->getPickup()]); - } - - /** - * Get the completed destination points as spatial points. - * - * @return Collection a collection of Point objects representing the completed destination points - */ - public function getCompletedDestinationPoints(): Collection - { - return $this->getCompletedDestinations()->map(function (Place $place) { - return $place->location; - })->filter()->values(); - } - - /** - * Get the next destination that the driver should go to. - * - * @return Place|null the next destination place, or null if not available - */ - public function getNextDestination(): ?Place - { - if ($this->isMultipleDropOrder) { - /** @var Waypoint $nextWaypoint */ - $nextWaypoint = $this->payload->waypointMarkers->filter(function (Waypoint $waypoint) { - return $this->isWaypointNotCompleted($waypoint) && $this->isWaypointNotCurrentDestination($waypoint); - })->first(); - - if ($nextWaypoint) { - $nextWaypoint->loadMissing('place'); - - return $nextWaypoint->place; - } - } - - return $this->payload->dropoff; - } - - /** - * Check if a waypoint has been completed. - * - * @param Waypoint $waypoint the waypoint to check - * - * @return bool true if the waypoint is completed or canceled, otherwise false - */ - public function isWaypointCompleted(Waypoint $waypoint): bool - { - $waypoint->loadMissing(['trackingNumber', 'trackingNumber.status']); - $status = strtolower($waypoint->status_code); - - return $status === 'completed' || $status === 'canceled'; - } - - /** - * Check if a waypoint is not completed. - * - * @param Waypoint $waypoint the waypoint to check - * - * @return bool true if the waypoint is not completed, otherwise false - */ - public function isWaypointNotCompleted(Waypoint $waypoint): bool - { - return !$this->isWaypointCompleted($waypoint); - } - - /** - * Check if a waypoint is the current destination. - * - * @param Waypoint $waypoint the waypoint to check - * - * @return bool true if the waypoint is the current destination, otherwise false - */ - public function isWaypointCurrentDestination(Waypoint $waypoint): bool - { - if (!$this->payload->current_waypoint_uuid) { - $currentDestination = $this->getCurrentDestination(); - - return $currentDestination->uuid === $waypoint->place_uuid; - } - - return $waypoint->place_uuid === $this->payload->current_waypoint_uuid; - } - - /** - * Check if a waypoint is not the current destination. - * - * @param Waypoint $waypoint the waypoint to check - * - * @return bool true if the waypoint is not the current destination, otherwise false - */ - public function isWaypointNotCurrentDestination(Waypoint $waypoint): bool - { - return !$this->isWaypointCurrentDestination($waypoint); - } - - /** - * Get the pickup location of the order. - * - * @return Place the pickup place of the order - */ - public function getPickup(): ?Place - { - return $this->payload->pickup; - } - - /** - * Get the dropoff location of the order. - * - * @return Place the dropoff place of the order - */ - public function getDropoff(): ?Place - { - return $this->payload->dropoff; - } - - public function eta(): array - { - // Generate cache key based on order UUID and updated_at timestamp - $cacheKey = 'order_eta:' . $this->order->uuid . ':' . optional($this->order->updated_at)->timestamp; - - // Return cached data if available - return Cache::remember($cacheKey, 60, function () { - // Load missing waypoints and places - $waypoints = $this->payload->getAllStops(); - - // ETA's - $eta = []; - foreach ($waypoints as $waypoint) { - $eta[$waypoint->uuid] = $this->getWaypointETA($waypoint); - } - - return $eta; - }); - } - - /** - * Get all key tracker information as an array. - * Cached for 60 seconds to avoid repeated OSRM calls. - */ - public function toArray(): array - { - // Generate cache key based on order UUID and updated_at timestamp - $cacheKey = 'order_tracker:' . $this->order->uuid . ':' . optional($this->order->updated_at)->timestamp; - - // Return cached data if available - return Cache::remember($cacheKey, 60, function () { - // Early return for completed orders - skip expensive OSRM calls - if (in_array($this->order->status, ['completed', 'canceled'])) { - return [ - 'driver_current_location' => null, - 'progress_percentage' => 100, - 'total_distance' => 0, - 'completed_distance' => 0, - 'current_destination_eta' => 0, - 'completion_eta' => 0, - 'estimated_completion_time' => null, - 'estimated_completion_time_formatted' => null, - 'start_time' => $this->getOrderStartTime(), - 'completion_time' => $this->getOrderCompletionTime(), - 'current_destination' => null, - 'next_destination' => null, - 'first_waypoint_completed' => true, - 'last_waypoint_completed' => true, - ]; - } - - // Wrap OSRM-dependent calculations in try-catch for graceful degradation - try { - $totalDistance = $this->getTotalDistance(); - $completedDistance = $this->getCompletedDistance(); - $currentDestinationEta = $this->getCurrentDestinationETA(); - $completionEta = $this->getCompletionETA(); - } catch (\Exception $e) { - Log::warning('OrderTracker: Failed to calculate distances/ETAs', ['error' => $e->getMessage()]); - $totalDistance = 0; - $completedDistance = 0; - $currentDestinationEta = 0; - $completionEta = 0; - } - - $estimatedCompletionTime = $this->getEstimatedCompletionTime(); - $orderProgressPercentage = $this->getOrderProgressPercentage(); - - return [ - 'driver_current_location' => $this->getDriverCurrentLocation(), - 'progress_percentage' => $orderProgressPercentage, - 'total_distance' => $totalDistance, - 'completed_distance' => $completedDistance, - 'current_destination_eta' => $currentDestinationEta, - 'completion_eta' => $completionEta, - 'estimated_completion_time' => $estimatedCompletionTime, - 'estimated_completion_time_formatted' => $estimatedCompletionTime instanceof Carbon ? $estimatedCompletionTime->format('M jS, Y H:i') : null, - 'start_time' => $this->getOrderStartTime(), - 'completion_time' => $this->getOrderCompletionTime(), - 'current_destination' => $this->getCurrentDestination(), - 'next_destination' => $this->getNextDestination(), - 'first_waypoint_completed' => $orderProgressPercentage > 10, - 'last_waypoint_completed' => $orderProgressPercentage === 100 || $this->order->status === 'completed', - ]; - }); + return app(TrackingIntelligenceService::class)->track($this->order, TrackingOptions::fromArray($options)); } } diff --git a/server/src/Tracking/Contracts/TrackingProviderInterface.php b/server/src/Tracking/Contracts/TrackingProviderInterface.php new file mode 100644 index 000000000..6d74f1ed3 --- /dev/null +++ b/server/src/Tracking/Contracts/TrackingProviderInterface.php @@ -0,0 +1,19 @@ +canRoute(); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $points = $context->routePoints(); + $distance = 0; + $legs = []; + + for ($i = 0; $i < count($points) - 1; $i++) { + $legDistance = Utils::vincentyGreatCircleDistance($points[$i], $points[$i + 1]); + $legDuration = $this->durationFromDistance($legDistance, $options); + $distance += $legDistance; + $legs[] = [ + 'index' => $i, + 'distance_m' => $legDistance, + 'duration_s' => $legDuration, + 'duration_in_traffic_s' => null, + 'provider' => $this->key(), + ]; + } + + return new TrackingProviderResult( + provider: $this->key(), + distanceMeters: $distance, + durationSeconds: $this->durationFromDistance($distance, $options), + durationInTrafficSeconds: null, + legs: $legs, + warnings: ['calculated_route_used'], + confidence: 'low' + ); + } + + protected function durationFromDistance(float $distanceMeters, TrackingOptions $options): float + { + $metersPerSecond = max($options->defaultVehicleSpeedKph, 1) * 1000 / 3600; + + return round($distanceMeters / $metersPerSecond); + } +} diff --git a/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php new file mode 100644 index 000000000..5f157d3eb --- /dev/null +++ b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php @@ -0,0 +1,118 @@ +canRoute() && filled($this->apiKey()); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $points = $context->routePoints(); + $origin = array_shift($points); + $destination = array_pop($points); + $body = [ + 'origin' => ['location' => ['latLng' => $this->latLng($origin)]], + 'destination' => ['location' => ['latLng' => $this->latLng($destination)]], + 'travelMode' => 'DRIVE', + 'routingPreference' => $options->trafficEnabled ? 'TRAFFIC_AWARE_OPTIMAL' : 'TRAFFIC_UNAWARE', + 'computeAlternativeRoutes' => false, + 'languageCode' => 'en-US', + 'units' => 'METRIC', + ]; + + if (!empty($points)) { + $body['intermediates'] = array_map(fn ($point) => ['location' => ['latLng' => $this->latLng($point)]], $points); + } + + $response = Http::timeout(5) + ->withHeaders([ + 'X-Goog-Api-Key' => $this->apiKey(), + 'X-Goog-FieldMask' => 'routes.distanceMeters,routes.duration,routes.staticDuration,routes.polyline.encodedPolyline,routes.legs.distanceMeters,routes.legs.duration,routes.legs.staticDuration', + ]) + ->post('https://routes.googleapis.com/directions/v2:computeRoutes', $body); + + if (!$response->successful()) { + throw new \RuntimeException('Google Routes request failed with status ' . $response->status()); + } + + $route = data_get($response->json(), 'routes.0'); + if (!$route) { + throw new \RuntimeException('Google Routes returned no route.'); + } + + $duration = $this->durationToSeconds(data_get($route, 'staticDuration')) ?? $this->durationToSeconds(data_get($route, 'duration')); + $trafficDuration = $this->durationToSeconds(data_get($route, 'duration')); + + return new TrackingProviderResult( + provider: $this->key(), + distanceMeters: data_get($route, 'distanceMeters'), + durationSeconds: $duration, + durationInTrafficSeconds: $options->trafficEnabled ? $trafficDuration : null, + polyline: data_get($route, 'polyline.encodedPolyline'), + legs: $this->legs(data_get($route, 'legs', []), $options), + warnings: [], + confidence: $options->trafficEnabled ? 'high' : 'medium', + raw: $route + ); + } + + protected function legs(array $legs, TrackingOptions $options): array + { + return collect($legs)->map(function ($leg, $index) use ($options) { + $duration = $this->durationToSeconds(data_get($leg, 'staticDuration')) ?? $this->durationToSeconds(data_get($leg, 'duration')); + $trafficDuration = $this->durationToSeconds(data_get($leg, 'duration')); + + return [ + 'index' => $index, + 'distance_m' => data_get($leg, 'distanceMeters'), + 'duration_s' => $duration, + 'duration_in_traffic_s' => $options->trafficEnabled ? $trafficDuration : null, + 'provider' => $this->key(), + ]; + })->all(); + } + + protected function latLng($point): array + { + return [ + 'latitude' => $point->getLat(), + 'longitude' => $point->getLng(), + ]; + } + + protected function durationToSeconds(?string $duration): ?float + { + if (!$duration) { + return null; + } + + return (float) Str::replaceLast('s', '', $duration); + } + + protected function apiKey(): ?string + { + return config('services.google_maps.api_key') ?: env('GOOGLE_MAPS_API_KEY'); + } +} diff --git a/server/src/Tracking/Providers/OsrmTrackingProvider.php b/server/src/Tracking/Providers/OsrmTrackingProvider.php new file mode 100644 index 000000000..16ee41686 --- /dev/null +++ b/server/src/Tracking/Providers/OsrmTrackingProvider.php @@ -0,0 +1,71 @@ +canRoute(); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $response = OSRM::getRouteFromPoints($context->routePoints(), [ + 'overview' => 'full', + 'geometries' => 'polyline', + 'steps' => 'false', + 'annotations' => 'false', + ]); + + if (data_get($response, 'code') !== 'Ok') { + throw new \RuntimeException('OSRM did not return a routable response.'); + } + + $route = Arr::first(data_get($response, 'routes', [])); + if (!$route) { + throw new \RuntimeException('OSRM returned no route.'); + } + + $legs = collect(data_get($route, 'legs', []))->map(function ($leg, $index) { + return [ + 'index' => $index, + 'distance_m' => data_get($leg, 'distance'), + 'duration_s' => data_get($leg, 'duration'), + 'duration_in_traffic_s' => null, + 'provider' => $this->key(), + ]; + })->all(); + + return new TrackingProviderResult( + provider: $this->key(), + distanceMeters: data_get($route, 'distance'), + durationSeconds: data_get($route, 'duration'), + durationInTrafficSeconds: null, + polyline: data_get($route, 'geometry'), + coordinates: data_get($route, 'waypoints', []), + legs: $legs, + warnings: ['no_live_traffic'], + confidence: 'medium', + raw: $route + ); + } +} diff --git a/server/src/Tracking/README.md b/server/src/Tracking/README.md new file mode 100644 index 000000000..74fd24690 --- /dev/null +++ b/server/src/Tracking/README.md @@ -0,0 +1,25 @@ +# Order Tracking Providers + +FleetOps tracking providers adapt third-party routing or tracking systems into the canonical tracking intelligence response. + +Providers must implement `Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface`: + +```php +public function key(): string; +public function capabilities(): TrackingProviderCapabilities; +public function canTrack(TrackingContext $context): bool; +public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult; +``` + +Register providers from a service provider: + +```php +use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; + +public function boot(): void +{ + app(TrackingProviderRegistry::class)->register(new TomTomTrackingProvider()); +} +``` + +Provider adapters should never expose vendor response shapes directly. Map provider responses into `TrackingProviderResult` and declare capabilities such as traffic-aware ETA, per-leg ETA, route geometry, or map matching. diff --git a/server/src/Tracking/Support/FakeTrackingProvider.php b/server/src/Tracking/Support/FakeTrackingProvider.php new file mode 100644 index 000000000..a47a39abd --- /dev/null +++ b/server/src/Tracking/Support/FakeTrackingProvider.php @@ -0,0 +1,42 @@ +providerKey; + } + + public function capabilities(): TrackingProviderCapabilities + { + return new TrackingProviderCapabilities(traffic: true, perLegEta: true); + } + + public function canTrack(TrackingContext $context): bool + { + return $context->canRoute(); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $result = (new CalculatedTrackingProvider())->track($context, $options); + $result->provider = $this->providerKey; + $result->confidence = 'high'; + $result->warnings = []; + + return $result; + } +} diff --git a/server/src/Tracking/TrackingContext.php b/server/src/Tracking/TrackingContext.php new file mode 100644 index 000000000..e53588b95 --- /dev/null +++ b/server/src/Tracking/TrackingContext.php @@ -0,0 +1,49 @@ +origin) { + $points[] = $this->origin; + } + + foreach ($this->remainingStops as $stop) { + $point = $stop instanceof TrackingStop ? $stop->point() : null; + if ($point) { + $points[] = $point; + } + } + + return $points; + } + + public function canRoute(): bool + { + return count($this->routePoints()) >= 2; + } +} diff --git a/server/src/Tracking/TrackingContextBuilder.php b/server/src/Tracking/TrackingContextBuilder.php new file mode 100644 index 000000000..c6c91ee85 --- /dev/null +++ b/server/src/Tracking/TrackingContextBuilder.php @@ -0,0 +1,177 @@ +loadAssignedDriver(); + $order->loadMissing([ + 'payload', + 'payload.pickup', + 'payload.dropoff', + 'payload.waypoints', + 'payload.waypointMarkers.place', + ]); + + $payload = $order->payload; + $driver = $order->driverAssigned; + $warnings = []; + $driverLocationAge = $driver?->updated_at ? now()->diffInSeconds($driver->updated_at) : null; + + $origin = null; + if ($driver && $driver->location) { + try { + $origin = Utils::getPointFromMixed($driver); + } catch (\Throwable) { + $warnings[] = 'missing_driver_location'; + } + } + + if (!$origin) { + $warnings[] = 'missing_driver_location'; + $origin = $this->fallbackOrigin($payload); + } + + if ($driverLocationAge !== null && $driverLocationAge > $options->staleLocationThresholdSeconds) { + $warnings[] = 'stale_driver_location'; + } + + $stops = $this->stops($payload, $order); + $completedStops = $stops->filter(fn (TrackingStop $stop) => $stop->completed)->values(); + $remainingStops = $stops->reject(fn (TrackingStop $stop) => $stop->completed)->values(); + $activeStop = $this->activeStop($payload, $remainingStops); + $nextStop = $remainingStops->first(fn (TrackingStop $stop) => !$activeStop || $stop->uuid !== $activeStop->uuid); + + if (!$activeStop && $remainingStops->isNotEmpty()) { + $activeStop = $remainingStops->first(); + } + + $missingStopLocations = $remainingStops->filter(fn (TrackingStop $stop) => !$stop->point())->count(); + if ($missingStopLocations > 0) { + $warnings[] = 'missing_stop_location'; + } + + return new TrackingContext( + order: $order, + payload: $payload, + driver: $driver, + origin: $origin, + stops: $stops, + completedStops: $completedStops, + remainingStops: $remainingStops, + activeStop: $activeStop, + nextStop: $nextStop, + driverLocationAgeSeconds: $driverLocationAge, + warnings: array_values(array_unique($warnings)) + ); + } + + protected function stops($payload, Order $order): Collection + { + if (!$payload) { + return collect(); + } + + $stops = collect(); + $sequence = 0; + + if ($payload->pickup instanceof Place) { + $stops->push(new TrackingStop( + uuid: $payload->pickup->uuid, + publicId: $payload->pickup->public_id, + type: 'pickup', + status: null, + place: $payload->pickup, + completed: !in_array($order->status, ['created', 'dispatched', 'pending']), + sequence: ++$sequence + )); + } + + $markers = $payload->waypointMarkers instanceof Collection ? $payload->waypointMarkers : collect(); + if ($markers->isNotEmpty()) { + foreach ($markers->sortBy('order')->values() as $waypoint) { + $stops->push($this->stopFromWaypoint($waypoint, ++$sequence)); + } + } elseif ($payload->waypoints instanceof Collection) { + foreach ($payload->waypoints as $place) { + if ($place instanceof Place) { + $stops->push(new TrackingStop( + uuid: $place->uuid, + publicId: $place->public_id, + type: 'waypoint', + status: null, + place: $place, + completed: false, + sequence: ++$sequence + )); + } + } + } + + if ($payload->dropoff instanceof Place) { + $stops->push(new TrackingStop( + uuid: $payload->dropoff->uuid, + publicId: $payload->dropoff->public_id, + type: 'dropoff', + status: null, + place: $payload->dropoff, + completed: in_array($order->status, ['completed', 'canceled']), + sequence: ++$sequence + )); + } + + return $stops->values(); + } + + protected function stopFromWaypoint(Waypoint $waypoint, int $sequence): TrackingStop + { + $waypoint->loadMissing('place'); + $status = strtolower((string) $waypoint->status_code); + + return new TrackingStop( + uuid: $waypoint->place_uuid, + publicId: data_get($waypoint, 'place.public_id'), + type: 'waypoint', + status: $status ?: null, + place: $waypoint->place, + waypoint: $waypoint, + completed: in_array($status, ['completed', 'canceled']), + sequence: $sequence + ); + } + + protected function activeStop($payload, Collection $remainingStops): ?TrackingStop + { + if (!$payload || !$payload->current_waypoint_uuid || !Str::isUuid($payload->current_waypoint_uuid)) { + return $remainingStops->first(); + } + + return $remainingStops->first(function (TrackingStop $stop) use ($payload) { + return $stop->uuid === $payload->current_waypoint_uuid + || data_get($stop->waypoint, 'uuid') === $payload->current_waypoint_uuid + || data_get($stop->waypoint, 'place_uuid') === $payload->current_waypoint_uuid; + }); + } + + protected function fallbackOrigin($payload) + { + if (!$payload) { + return null; + } + + try { + return Utils::getPointFromMixed($payload->getPickupOrCurrentWaypoint()); + } catch (\Throwable) { + return null; + } + } +} diff --git a/server/src/Tracking/TrackingIntelligenceService.php b/server/src/Tracking/TrackingIntelligenceService.php new file mode 100644 index 000000000..4ba62395b --- /dev/null +++ b/server/src/Tracking/TrackingIntelligenceService.php @@ -0,0 +1,148 @@ +contextBuilder->build($order, $options); + $cacheKey = $this->cacheKey($context, $options); + + return Cache::remember($cacheKey, $options->cacheTtlSeconds, function () use ($context, $options) { + return $this->buildResult($context, $this->providerManager->track($context, $options), $options); + }); + } + + public function eta(Order $order, array|TrackingOptions $options = []): array + { + $tracker = $this->track($order, $options); + + return [ + 'active_stop_seconds' => data_get($tracker, 'eta.active_stop_seconds'), + 'completion_seconds' => data_get($tracker, 'eta.completion_seconds'), + 'active_stop_at' => data_get($tracker, 'eta.active_stop_at'), + 'completion_at' => data_get($tracker, 'eta.completion_at'), + 'provider' => data_get($tracker, 'provider'), + 'confidence' => data_get($tracker, 'confidence'), + 'warnings' => data_get($tracker, 'warnings', []), + ]; + } + + protected function buildResult(TrackingContext $context, TrackingProviderResult $providerResult, TrackingOptions $options): array + { + $now = now(); + $completionSeconds = $providerResult->durationInTrafficSeconds ?? $providerResult->durationSeconds; + $activeStopSeconds = data_get($providerResult->legs, '0.duration_in_traffic_s', data_get($providerResult->legs, '0.duration_s', $completionSeconds)); + $progress = $this->progress($context, $providerResult); + $warnings = array_values(array_unique([...$context->warnings, ...$providerResult->warnings])); + + if ($providerResult->confidence === 'low') { + $warnings[] = 'low_confidence_eta'; + } + + return [ + 'provider' => $providerResult->provider, + 'fallback_provider' => in_array('fallback_used', $warnings) ? $providerResult->provider : null, + 'generated_at' => $now->toISOString(), + 'confidence' => $providerResult->confidence, + 'warnings' => array_values(array_unique($warnings)), + 'driver' => [ + 'location' => $this->pointToGeoJson($context->origin), + 'location_age_seconds' => $context->driverLocationAgeSeconds, + 'online' => (bool) data_get($context->driver, 'online', false), + ], + 'progress' => $progress, + 'active_stop' => $context->activeStop?->toArray(), + 'next_stop' => $context->nextStop?->toArray(), + 'route' => [ + 'distance_m' => $providerResult->distanceMeters, + 'duration_s' => $providerResult->durationSeconds, + 'duration_in_traffic_s' => $providerResult->durationInTrafficSeconds, + 'polyline' => $providerResult->polyline, + 'coordinates' => $providerResult->coordinates, + 'legs' => $providerResult->legs, + ], + 'eta' => [ + 'active_stop_seconds' => $activeStopSeconds, + 'completion_seconds' => $completionSeconds, + 'active_stop_at' => $this->addSeconds($now, $activeStopSeconds), + 'completion_at' => $this->addSeconds($now, $completionSeconds), + ], + 'insights' => [ + 'is_delayed' => false, + 'delay_seconds' => 0, + 'is_location_stale' => in_array('stale_driver_location', $warnings), + 'is_off_route' => in_array('off_route', $warnings), + ], + 'capabilities' => $this->capabilitiesFor($providerResult->provider), + ]; + } + + protected function progress(TrackingContext $context, TrackingProviderResult $providerResult): array + { + $totalStops = max($context->stops->count(), 1); + $completedStops = $context->completedStops->count(); + $percentage = $context->order->status === 'completed' ? 100 : round(($completedStops / $totalStops) * 100, 2); + + return [ + 'percentage' => $percentage, + 'completed_stops' => $completedStops, + 'remaining_stops' => $context->remainingStops->count(), + 'total_stops' => $context->stops->count(), + 'completed_distance_m' => null, + 'remaining_distance_m' => $providerResult->distanceMeters, + ]; + } + + protected function capabilitiesFor(string $provider): array + { + $registry = app(TrackingProviderRegistry::class); + $registered = $registry->get($provider); + + return $registered ? $registered->capabilities()->toArray() : (new TrackingProviderCapabilities())->toArray(); + } + + protected function cacheKey(TrackingContext $context, TrackingOptions $options): string + { + return 'order_tracking_intelligence:' . md5(json_encode([ + 'order' => $context->order->uuid, + 'order_ts' => optional($context->order->updated_at)->timestamp, + 'payload' => optional($context->payload)->uuid, + 'payload_ts' => optional($context->payload?->updated_at)->timestamp, + 'driver' => optional($context->driver)->uuid, + 'driver_ts' => optional($context->driver?->updated_at)->timestamp, + 'provider' => $options->provider, + 'fallbacks' => $options->fallbacks, + 'traffic' => $options->trafficEnabled, + ])); + } + + protected function pointToGeoJson($point): ?array + { + if (!$point) { + return null; + } + + return [ + 'type' => 'Point', + 'coordinates' => [$point->getLng(), $point->getLat()], + ]; + } + + protected function addSeconds(Carbon $now, ?float $seconds): ?string + { + return $seconds === null ? null : $now->copy()->addSeconds((int) round($seconds))->toISOString(); + } +} diff --git a/server/src/Tracking/TrackingOptions.php b/server/src/Tracking/TrackingOptions.php new file mode 100644 index 000000000..e79b154da --- /dev/null +++ b/server/src/Tracking/TrackingOptions.php @@ -0,0 +1,46 @@ + $this->traffic, + 'per_leg_eta' => $this->perLegEta, + 'map_matching' => $this->mapMatching, + 'route_geometry' => $this->routeGeometry, + ], $this->extras); + } + + public function jsonSerialize(): mixed + { + return $this->toArray(); + } +} diff --git a/server/src/Tracking/TrackingProviderManager.php b/server/src/Tracking/TrackingProviderManager.php new file mode 100644 index 000000000..d7ac68382 --- /dev/null +++ b/server/src/Tracking/TrackingProviderManager.php @@ -0,0 +1,69 @@ +providerOrder($options) as $providerKey) { + $provider = $this->registry->get($providerKey); + if (!$provider instanceof TrackingProviderInterface) { + $warnings[] = 'provider_not_registered:' . $providerKey; + continue; + } + + if (!$provider->canTrack($context)) { + $warnings[] = 'provider_unavailable:' . $providerKey; + continue; + } + + try { + $result = $provider->track($context, $options); + $result->warnings = array_values(array_unique([...$warnings, ...$result->warnings])); + + if (Str::snake($result->provider) !== Str::snake((string) $options->provider)) { + $result->warnings[] = 'fallback_used'; + } + + return $result; + } catch (\Throwable $e) { + $warnings[] = 'provider_failed:' . $providerKey; + Log::warning('Tracking provider failed.', [ + 'provider' => $providerKey, + 'order' => $context->order->public_id ?? $context->order->uuid, + 'error' => $e->getMessage(), + ]); + } + } + + return new TrackingProviderResult( + provider: 'none', + warnings: array_values(array_unique([...$warnings, 'no_tracking_provider_available'])), + confidence: 'none' + ); + } + + protected function providerOrder(TrackingOptions $options): array + { + $provider = $options->provider ?: Arr::get(config('fleetops.tracking', []), 'provider', 'google_routes'); + $fallbacks = $options->fallbacks ?: Arr::get(config('fleetops.tracking', []), 'fallbacks', ['osrm', 'calculated']); + + return collect([$provider, ...$fallbacks]) + ->filter() + ->map(fn ($key) => Str::snake($key)) + ->unique() + ->values() + ->all(); + } +} diff --git a/server/src/Tracking/TrackingProviderRegistry.php b/server/src/Tracking/TrackingProviderRegistry.php new file mode 100644 index 000000000..dfe76846f --- /dev/null +++ b/server/src/Tracking/TrackingProviderRegistry.php @@ -0,0 +1,35 @@ +key()); + $this->providers[$providerKey] = $instance; + + return $this; + } + + public function has(string $key): bool + { + return isset($this->providers[Str::snake($key)]); + } + + public function get(string $key): ?TrackingProviderInterface + { + return $this->providers[Str::snake($key)] ?? null; + } + + public function all(): array + { + return $this->providers; + } +} diff --git a/server/src/Tracking/TrackingProviderResult.php b/server/src/Tracking/TrackingProviderResult.php new file mode 100644 index 000000000..09736f965 --- /dev/null +++ b/server/src/Tracking/TrackingProviderResult.php @@ -0,0 +1,20 @@ +place); + } catch (\Throwable) { + return null; + } + } + + public function toArray(): array + { + $point = $this->point(); + + return [ + 'uuid' => $this->uuid, + 'public_id' => $this->publicId, + 'type' => $this->type, + 'status' => $this->status, + 'completed' => $this->completed, + 'sequence' => $this->sequence, + 'address' => data_get($this->place, 'address'), + 'name' => data_get($this->place, 'name'), + 'location' => $point ? [ + 'type' => 'Point', + 'coordinates' => [$point->getLng(), $point->getLat()], + ] : null, + 'latitude' => $point ? $point->getLat() : null, + 'longitude' => $point ? $point->getLng() : null, + ]; + } + + public function jsonSerialize(): mixed + { + return $this->toArray(); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index d62ce4213..d541d1c1b 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -546,6 +546,8 @@ function ($router) { $router->post('notification-settings', 'SettingController@saveNotificationSettings'); $router->get('routing-settings', 'SettingController@getRoutingSettings'); $router->post('routing-settings', 'SettingController@saveRoutingSettings'); + $router->get('tracking-settings', 'SettingController@getTrackingSettings'); + $router->post('tracking-settings', 'SettingController@saveTrackingSettings'); $router->get('map', 'SettingController@getMapSettings'); $router->post('map', 'SettingController@saveMapSettings'); $router->get('admin-map', 'SettingController@getAdminMapSettings'); diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php new file mode 100644 index 000000000..80fa7dc3b --- /dev/null +++ b/server/tests/TrackingIntelligenceTest.php @@ -0,0 +1,110 @@ +uuid = $uuid; + $place->public_id = 'place_' . substr($uuid, 0, 8); + $place->address = 'Test address ' . $uuid; + $place->location = new Point($lat, $lng); + + return $place; +} + +function trackingOrderWithStops(): Order +{ + $pickup = trackingPlace('11111111-1111-1111-1111-111111111111', 1.30, 103.80); + $dropoff = trackingPlace('22222222-2222-2222-2222-222222222222', 1.35, 103.85); + $payload = new Payload(); + $payload->uuid = '33333333-3333-3333-3333-333333333333'; + $payload->current_waypoint_uuid = $pickup->uuid; + $payload->setRelation('pickup', $pickup); + $payload->setRelation('dropoff', $dropoff); + $payload->setRelation('waypoints', collect()); + $payload->setRelation('waypointMarkers', collect()); + + $driver = new Driver(); + $driver->uuid = '44444444-4444-4444-4444-444444444444'; + $driver->location = new Point(1.29, 103.79); + $driver->online = true; + $driver->updated_at = Carbon::now(); + + $order = new Order(); + $order->uuid = '55555555-5555-5555-5555-555555555555'; + $order->public_id = 'order_test'; + $order->status = 'started'; + $order->updated_at = Carbon::now(); + $order->setRelation('payload', $payload); + $order->setRelation('driverAssigned', $driver); + + return $order; +} + +test('tracking context builder normalizes order stops and driver telemetry', function () { + $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([ + 'provider' => 'calculated', + ])); + + expect($context->stops)->toHaveCount(2) + ->and($context->activeStop?->type)->toBe('dropoff') + ->and($context->nextStop)->toBeNull() + ->and($context->driverLocationAgeSeconds)->toBeInt() + ->and($context->warnings)->toBe([]); +}); + +test('calculated provider returns normalized low confidence route data', function () { + $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([ + 'provider' => 'calculated', + ])); + + $result = (new CalculatedTrackingProvider())->track($context, TrackingOptions::fromArray([ + 'provider' => 'calculated', + 'default_vehicle_speed_kph' => 36, + ])); + + expect($result->provider)->toBe('calculated') + ->and($result->distanceMeters)->toBeGreaterThan(0) + ->and($result->durationSeconds)->toBeGreaterThan(0) + ->and($result->confidence)->toBe('low') + ->and($result->warnings)->toContain('calculated_route_used'); +}); + +test('provider manager falls back to registered provider and records fallback warning', function () { + $registry = new TrackingProviderRegistry(); + $registry->register(new FakeTrackingProvider('fake')); + $manager = new TrackingProviderManager($registry); + $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([ + 'provider' => 'missing', + 'fallbacks' => ['fake'], + ])); + + $result = $manager->track($context, TrackingOptions::fromArray([ + 'provider' => 'missing', + 'fallbacks' => ['fake'], + ])); + + expect($result->provider)->toBe('fake') + ->and($result->warnings)->toContain('provider_not_registered:missing') + ->and($result->warnings)->toContain('fallback_used'); +}); + +test('third party providers can be registered through the tracking provider contract', function () { + $registry = new TrackingProviderRegistry(); + $registry->register(new FakeTrackingProvider('tomtom')); + + expect($registry->has('tomtom'))->toBeTrue() + ->and($registry->get('tomtom')?->capabilities()->traffic)->toBeTrue(); +}); From a1d468027ab04abf8f9333c30b72e4e83e8dcaa9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 11:51:17 +0800 Subject: [PATCH 02/19] Add tracking intelligence settings and caching --- addon/components/admin/routing-settings.hbs | 46 +++++++++++ addon/components/admin/routing-settings.js | 74 +++++++++++++++++ .../map/order-list-overlay/order.hbs | 9 ++ addon/components/order-list-overlay/order.hbs | 9 ++ addon/components/order-progress-card.hbs | 9 ++ addon/components/order/details/tracking.hbs | 82 ++++++++++++++++++- addon/components/order/kanban-card.hbs | 9 ++ addon/controllers/settings/routing.js | 49 ++++++++++- addon/routes/application.js | 6 ++ addon/templates/settings/routing.hbs | 37 ++++++++- server/config/fleetops.php | 1 + .../Internal/v1/SettingController.php | 57 ++++++++++++- server/src/Tracking/TrackingContext.php | 13 +++ .../Tracking/TrackingIntelligenceService.php | 21 ++++- server/src/Tracking/TrackingOptions.php | 5 +- .../src/Tracking/TrackingProviderManager.php | 22 ++++- server/src/routes.php | 2 + server/tests/TrackingIntelligenceTest.php | 34 ++++++++ 18 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 addon/components/admin/routing-settings.js diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index d5934467b..e609b95f3 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -3,9 +3,55 @@ Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings. + + + + + + + +
+ + + + + + + + + + + + +
+
+
+ + + {{/if}} {{/if}} - -
@@ -72,20 +67,14 @@
Reported ETA
-
- {{#if this.hasReportedEta}} - {{format-duration this.reportedEtaSeconds}} +
+ {{#if this.hasDisplayedReportedEta}} + {{format-duration this.displayedReportedEtaSeconds}} {{else}} Pending GPS fix {{/if}}
-
- Completion - {{#if this.hasCompletionEta}} - · - {{format-date-fns @resource.tracker_data.eta.completion_at "d MMM HH:mm"}} - {{/if}} -
+
{{this.reportedEtaWarning}}
@@ -96,7 +85,7 @@ {{/each}}
-
{{this.confidenceLabel}}
+
{{this.confidencePercent}}%
@@ -119,7 +108,7 @@
- Adjusted total + Reported total {{#if this.hasRemainingDistance}} {{format-meters @resource.tracker_data.route.distance_m}} @@ -127,10 +116,22 @@ - {{/if}} +
+ +
- Remaining stops - {{n-a @resource.tracker_data.progress.remaining_stops}} + Between stops + + {{#if this.hasCurrentLegDistance}} + {{format-meters this.currentLeg.distance_m}} + {{else}} + - + {{/if}} + +
+ +
Provider context: {{this.providerLabel}} @@ -140,7 +141,10 @@
Diagnostics - {{if this.hasWarnings "Warnings active" "No blocking warnings"}} + + {{this.diagnosticsSummaryLabel}} + +
{{#each this.diagnostics as |item|}} @@ -158,11 +162,19 @@ {{/if}}
+ +
{{/if}}
Labels
-
+
{{@resource.public_id}} diff --git a/addon/components/order/details/tracking.js b/addon/components/order/details/tracking.js index 85e68907f..daa8e07b2 100644 --- a/addon/components/order/details/tracking.js +++ b/addon/components/order/details/tracking.js @@ -121,6 +121,25 @@ export default class OrderDetailsTrackingComponent extends Component { return this.humanize(this.trackerData?.confidence || 'unknown'); } + get confidencePercent() { + const score = this.trackerData?.confidence_score ?? this.trackerData?.confidence_percent ?? this.trackerData?.confidence_percentage; + + if (score !== null && score !== undefined && !Number.isNaN(Number(score))) { + return Math.max(0, Math.min(100, Math.round(Number(score)))); + } + + switch (this.trackerData?.confidence) { + case 'high': + return 92; + case 'medium': + return 68; + case 'low': + return 34; + default: + return 0; + } + } + get providerLabel() { return this.humanize(this.trackerData?.provider); } @@ -192,8 +211,78 @@ export default class OrderDetailsTrackingComponent extends Component { return activeStop?.eta_seconds ?? eta?.[activeStop?.id] ?? eta?.[activeStop?.uuid] ?? eta?.[activeStop?.public_id] ?? null; } - get hasReportedEta() { - return this.reportedEtaSeconds !== null && this.reportedEtaSeconds !== undefined; + get displayedReportedEtaSeconds() { + return this.reportedEtaSeconds ?? this.activeEtaSeconds ?? this.trackerData?.route?.duration_in_traffic_s ?? this.trackerData?.route?.duration_s ?? null; + } + + get hasDisplayedReportedEta() { + return this.displayedReportedEtaSeconds !== null && this.displayedReportedEtaSeconds !== undefined; + } + + get isReportedEtaUntrusted() { + const trackerData = this.trackerData; + + if (!trackerData) { + return false; + } + + return !trackerData?.driver?.location || trackerData?.insights?.is_location_stale || trackerData.fallback_provider || (trackerData.confidence && trackerData.confidence !== 'high'); + } + + get reportedEtaWarning() { + if (!this.isReportedEtaUntrusted) { + return 'Reported from existing route data'; + } + + return this.operatorWarning ?? 'Reported ETA may not reflect the latest tracking signal.'; + } + + get warningsCount() { + return (this.trackerData?.warnings ?? []).length; + } + + get diagnosticsSummaryLabel() { + return this.warningsCount === 1 ? '1 warning' : `${this.warningsCount} warnings`; + } + + get totalProgressPercentage() { + const percentage = Number(this.trackerData?.progress?.percentage ?? 0); + + return Math.max(0, Math.min(100, Number.isFinite(percentage) ? percentage : 0)); + } + + get totalProgressStyle() { + return `width: ${this.totalProgressPercentage}%;`; + } + + get currentLeg() { + return this.trackerData?.route?.legs?.[0] ?? null; + } + + get hasCurrentLegDistance() { + return this.currentLeg?.distance_m !== null && this.currentLeg?.distance_m !== undefined; + } + + get currentLegProgressPercentage() { + const explicit = Number(this.currentLeg?.progress_percentage ?? this.trackerData?.progress?.active_leg_percentage); + + if (Number.isFinite(explicit)) { + return Math.max(0, Math.min(100, explicit)); + } + + if (this.driverSignal === 'Missing') { + return 0; + } + + if (this.driverSignal === 'Stale') { + return 18; + } + + return Math.max(8, Math.min(92, this.totalProgressPercentage)); + } + + get currentLegProgressStyle() { + return `width: ${this.currentLegProgressPercentage}%;`; } get hasWarnings() { diff --git a/addon/components/route-list.hbs b/addon/components/route-list.hbs index c08ca9c74..d6676b2a1 100644 --- a/addon/components/route-list.hbs +++ b/addon/components/route-list.hbs @@ -15,10 +15,13 @@ {{#if this.firstStop.completed}} Completed {{else if this.firstStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.firstStop.etaSeconds}} - ETA: {{format-duration this.firstStop.etaSeconds}} + ETA: {{format-duration this.firstStop.etaSeconds}} + {{/if}} + {{#if this.firstStop.routeLeg.distance_m}} + {{format-meters this.firstStop.routeLeg.distance_m}} {{/if}} {{#if this.firstStop.etaAt}} {{format-date-fns this.firstStop.etaAt "d MMM HH:mm"}} @@ -53,10 +56,13 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} - ETA: {{format-duration stop.etaSeconds}} + ETA: {{format-duration stop.etaSeconds}} + {{/if}} + {{#if stop.routeLeg.distance_m}} + {{format-meters stop.routeLeg.distance_m}} {{/if}} {{#if stop.etaAt}} {{format-date-fns stop.etaAt "d MMM HH:mm"}} @@ -81,10 +87,13 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} - ETA: {{format-duration stop.etaSeconds}} + ETA: {{format-duration stop.etaSeconds}} + {{/if}} + {{#if stop.routeLeg.distance_m}} + {{format-meters stop.routeLeg.distance_m}} {{/if}} {{#if stop.etaAt}} {{format-date-fns stop.etaAt "d MMM HH:mm"}} @@ -112,10 +121,13 @@ {{#if this.lastStop.completed}} Completed {{else if this.lastStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.lastStop.etaSeconds}} - ETA: {{format-duration this.lastStop.etaSeconds}} + ETA: {{format-duration this.lastStop.etaSeconds}} + {{/if}} + {{#if this.lastStop.routeLeg.distance_m}} + {{format-meters this.lastStop.routeLeg.distance_m}} {{/if}} {{#if this.lastStop.etaAt}} {{format-date-fns this.lastStop.etaAt "d MMM HH:mm"}} diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index b39219e5b..85de59292 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -4217,6 +4217,18 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions color: #b6bfca; } +.tracking-intelligence-alert--critical .tracking-intelligence-alert__cta { + border-color: rgba(239, 68, 68, 30%); + background: rgba(239, 68, 68, 8%); + color: #b91c1c; +} + +[data-theme='dark'] .tracking-intelligence-alert--critical .tracking-intelligence-alert__cta { + border-color: rgba(226, 107, 107, 32%); + background: rgba(226, 107, 107, 10%); + color: #e26b6b; +} + .tracking-intelligence__eta-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -4227,6 +4239,7 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions .tracking-intelligence-destination, .tracking-intelligence-distance, .tracking-intelligence-diagnostics, +.tracking-intelligence-labels, .tracking-stop-progress { border: 1px solid #e5e7eb; border-radius: 0.5rem; @@ -4238,6 +4251,7 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions [data-theme='dark'] .tracking-intelligence-destination, [data-theme='dark'] .tracking-intelligence-distance, [data-theme='dark'] .tracking-intelligence-diagnostics, +[data-theme='dark'] .tracking-intelligence-labels, [data-theme='dark'] .tracking-stop-progress { border-color: #374151; background: #1f2937; @@ -4286,6 +4300,13 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions color: #d97706; } +.tracking-intelligence-cell__value--muted { + color: #6b7280; + text-decoration: line-through; + text-decoration-color: #ef4444; + text-decoration-thickness: 1px; +} + .tracking-intelligence-cell__sub { margin-top: 0.2rem; overflow: hidden; @@ -4296,6 +4317,11 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions white-space: nowrap; } +.tracking-intelligence-cell__sub--warn { + color: #b45309; + white-space: normal; +} + [data-theme='dark'] .tracking-intelligence-cell__label, [data-theme='dark'] .tracking-intelligence-cell__sub { color: #7c8896; @@ -4309,6 +4335,14 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions color: #e5a14b; } +[data-theme='dark'] .tracking-intelligence-cell__value--muted { + color: #7c8896; +} + +[data-theme='dark'] .tracking-intelligence-cell__sub--warn { + color: #e5a14b; +} + .tracking-intelligence-tag { padding: 0.0625rem 0.3rem; border-radius: 0.2rem; @@ -4427,10 +4461,10 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions } .tracking-intelligence-distance__row { - display: flex; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 0.75rem; + align-items: center; color: #6b7280; font-size: 0.72rem; } @@ -4443,6 +4477,25 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions color: #111827; } +.tracking-intelligence-distance__bar { + grid-column: 1 / -1; + height: 0.32rem; + overflow: hidden; + border-radius: 9999px; + background: #e5e7eb; +} + +.tracking-intelligence-distance__bar span { + display: block; + height: 100%; + border-radius: inherit; + background: #f59e0b; +} + +.tracking-intelligence-distance__bar--secondary span { + background: #38bdf8; +} + .tracking-intelligence-distance__foot { margin-top: 0.5rem; padding-top: 0.45rem; @@ -4459,6 +4512,18 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions color: #eceff4; } +[data-theme='dark'] .tracking-intelligence-distance__bar { + background: #20293a; +} + +[data-theme='dark'] .tracking-intelligence-distance__bar span { + background: #e5a14b; +} + +[data-theme='dark'] .tracking-intelligence-distance__bar--secondary span { + background: #5baec7; +} + [data-theme='dark'] .tracking-intelligence-distance__foot { border-color: #374151; color: #54616f; @@ -4483,6 +4548,21 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions text-transform: uppercase; } +.tracking-intelligence-diagnostics__summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.tracking-intelligence-diagnostics__summary svg { + transition: transform 160ms ease; +} + +.tracking-intelligence-diagnostics[open] .tracking-intelligence-diagnostics__summary svg { + transform: rotate(90deg); +} + .tracking-intelligence-diagnostics summary::-webkit-details-marker { display: none; } @@ -4563,7 +4643,8 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions } .tracking-stop-progress__scroller { - overflow-x: auto; + overflow: auto hidden; + min-height: 2.7rem; padding: 0.2rem 0 0.05rem; } @@ -4672,3 +4753,67 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions background: #e5a14b; color: #1a1206; } + +.tracking-intelligence-alert__cta, +.tracking-intelligence-footer__button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid rgba(245, 158, 11, 32%); + border-radius: 0.375rem; + background: rgba(245, 158, 11, 8%); + color: #b45309; + font-size: 0.7rem; + font-weight: 700; + line-height: 1.2; + padding: 0.35rem 0.55rem; + white-space: nowrap; +} + +.tracking-intelligence-alert__cta:disabled, +.tracking-intelligence-footer__button:disabled { + cursor: progress; + opacity: 0.7; +} + +[data-theme='dark'] .tracking-intelligence-alert__cta, +[data-theme='dark'] .tracking-intelligence-footer__button { + border-color: rgba(229, 161, 75, 32%); + background: rgba(229, 161, 75, 10%); + color: #e5a14b; +} + +.tracking-intelligence-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + color: #9ca3af; + font-size: 0.68rem; +} + +.tracking-intelligence-labels { + display: flex; + flex-direction: column; + padding: 0.75rem; +} + +.route-list-current-stop-badge { + display: inline-flex; + align-items: center; + border: 1px solid rgba(99, 102, 241, 30%); + border-radius: 9999px; + background: rgba(99, 102, 241, 12%); + color: #4f46e5; + font-size: 0.7rem; + font-weight: 700; + line-height: 1.2; + padding: 0.16rem 0.45rem; + white-space: nowrap; +} + +[data-theme='dark'] .route-list-current-stop-badge { + border-color: rgba(129, 140, 248, 36%); + background: rgba(129, 140, 248, 16%); + color: #a5b4fc; +} From 9356fb679cfad1b03c51269f909f046761ed76ce Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 18:20:14 +0800 Subject: [PATCH 09/19] Tighten tracking route UI refinements --- .../order/details/purchase-rate.hbs | 41 +++++++------- addon/components/order/details/tracking.hbs | 8 +-- addon/components/order/details/tracking.js | 55 +++++++++++++++++-- addon/components/route-list.hbs | 20 ++----- addon/helpers/format-duration.js | 38 +++++++++++++ addon/styles/fleetops-engine.css | 22 +------- app/helpers/format-duration.js | 1 + 7 files changed, 115 insertions(+), 70 deletions(-) create mode 100644 addon/helpers/format-duration.js create mode 100644 app/helpers/format-duration.js diff --git a/addon/components/order/details/purchase-rate.hbs b/addon/components/order/details/purchase-rate.hbs index dd41edff9..d0dfa02c2 100644 --- a/addon/components/order/details/purchase-rate.hbs +++ b/addon/components/order/details/purchase-rate.hbs @@ -1,38 +1,35 @@ {{#if @resource.purchase_rate}} - -
- + +
+
- - - + + + {{#each @resource.purchase_rate.service_quote.items as |item|}} - - + - {{/each}} - - + + + - - +
{{t "order.fields.breakdown"}} -
{{@resource.purchase_rate.service_quote.currency}}
-
{{t "order.fields.breakdown"}}{{@resource.purchase_rate.service_quote.currency}}
- {{smart-humanize item.details}} +
+ {{smart-humanize item.details}} -
{{format-currency item.amount @resource.purchase_rate.service_quote.currency}}
+
+ {{format-currency item.amount @resource.purchase_rate.service_quote.currency}}
- {{t "common.total"}} +
+ {{t "common.total"}} -
{{format-currency - @resource.purchase_rate.service_quote.amount - @resource.purchase_rate.service_quote.currency - }}
+
+ {{format-currency @resource.purchase_rate.service_quote.amount @resource.purchase_rate.service_quote.currency}}
diff --git a/addon/components/order/details/tracking.hbs b/addon/components/order/details/tracking.hbs index 4ba1157c4..922e1e6de 100644 --- a/addon/components/order/details/tracking.hbs +++ b/addon/components/order/details/tracking.hbs @@ -55,12 +55,10 @@ Live
- {{#if this.isDueNow}} - Due now - {{else if this.hasActiveEta}} - {{format-duration this.activeEtaSeconds}} + {{#if this.hasSmartAdjustedEta}} + {{format-duration this.smartAdjustedEtaSeconds}} {{else}} - - + {{this.smartAdjustedEtaUnavailableLabel}} {{/if}}
Based on provider route and driver signal
diff --git a/addon/components/order/details/tracking.js b/addon/components/order/details/tracking.js index daa8e07b2..0579da43c 100644 --- a/addon/components/order/details/tracking.js +++ b/addon/components/order/details/tracking.js @@ -41,8 +41,26 @@ export default class OrderDetailsTrackingComponent extends Component { return this.activeEtaSeconds !== null && this.activeEtaSeconds !== undefined; } - get isDueNow() { - return this.hasActiveEta && Number(this.activeEtaSeconds) <= 0; + get smartAdjustedEtaSeconds() { + return ( + this.firstPositiveNumber(this.activeEtaSeconds) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_s) ?? + this.firstPositiveNumber(this.reportedEtaSeconds) ?? + null + ); + } + + get hasSmartAdjustedEta() { + return this.smartAdjustedEtaSeconds !== null && this.smartAdjustedEtaSeconds !== undefined; + } + + get smartAdjustedEtaUnavailableLabel() { + if (this.driverSignal === 'Missing' || this.driverSignal === 'Stale') { + return 'Pending GPS fix'; + } + + return 'Pending start'; } get hasCompletionEta() { @@ -212,7 +230,13 @@ export default class OrderDetailsTrackingComponent extends Component { } get displayedReportedEtaSeconds() { - return this.reportedEtaSeconds ?? this.activeEtaSeconds ?? this.trackerData?.route?.duration_in_traffic_s ?? this.trackerData?.route?.duration_s ?? null; + return ( + this.firstPositiveNumber(this.reportedEtaSeconds) ?? + this.firstPositiveNumber(this.activeEtaSeconds) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_s) ?? + null + ); } get hasDisplayedReportedEta() { @@ -246,13 +270,26 @@ export default class OrderDetailsTrackingComponent extends Component { } get totalProgressPercentage() { - const percentage = Number(this.trackerData?.progress?.percentage ?? 0); + const percentage = Number(this.trackerData?.progress?.percentage); + + if (Number.isFinite(percentage)) { + return Math.max(0, Math.min(100, percentage)); + } - return Math.max(0, Math.min(100, Number.isFinite(percentage) ? percentage : 0)); + const stops = this.trackerData?.stops ?? []; + const completedStops = stops.filter((stop) => stop.completed).length; + + if (stops.length) { + return Math.max(0, Math.min(100, Math.round((completedStops / stops.length) * 100))); + } + + return 0; } get totalProgressStyle() { - return `width: ${this.totalProgressPercentage}%;`; + const percentage = this.hasRemainingDistance && this.totalProgressPercentage === 0 ? 2 : this.totalProgressPercentage; + + return `width: ${percentage}%;`; } get currentLeg() { @@ -344,6 +381,12 @@ export default class OrderDetailsTrackingComponent extends Component { .replace(/\b\w/g, (character) => character.toUpperCase()); } + firstPositiveNumber(value) { + const number = Number(value); + + return Number.isFinite(number) && number > 0 ? number : null; + } + matchesStop(stop, activeStop) { if (!stop || !activeStop) { return false; diff --git a/addon/components/route-list.hbs b/addon/components/route-list.hbs index d6676b2a1..71d5e18d8 100644 --- a/addon/components/route-list.hbs +++ b/addon/components/route-list.hbs @@ -15,14 +15,11 @@ {{#if this.firstStop.completed}} Completed {{else if this.firstStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.firstStop.etaSeconds}} ETA: {{format-duration this.firstStop.etaSeconds}} {{/if}} - {{#if this.firstStop.routeLeg.distance_m}} - {{format-meters this.firstStop.routeLeg.distance_m}} - {{/if}} {{#if this.firstStop.etaAt}} {{format-date-fns this.firstStop.etaAt "d MMM HH:mm"}} {{/if}} @@ -56,14 +53,11 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} ETA: {{format-duration stop.etaSeconds}} {{/if}} - {{#if stop.routeLeg.distance_m}} - {{format-meters stop.routeLeg.distance_m}} - {{/if}} {{#if stop.etaAt}} {{format-date-fns stop.etaAt "d MMM HH:mm"}} {{/if}} @@ -87,14 +81,11 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} ETA: {{format-duration stop.etaSeconds}} {{/if}} - {{#if stop.routeLeg.distance_m}} - {{format-meters stop.routeLeg.distance_m}} - {{/if}} {{#if stop.etaAt}} {{format-date-fns stop.etaAt "d MMM HH:mm"}} {{/if}} @@ -121,14 +112,11 @@ {{#if this.lastStop.completed}} Completed {{else if this.lastStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.lastStop.etaSeconds}} ETA: {{format-duration this.lastStop.etaSeconds}} {{/if}} - {{#if this.lastStop.routeLeg.distance_m}} - {{format-meters this.lastStop.routeLeg.distance_m}} - {{/if}} {{#if this.lastStop.etaAt}} {{format-date-fns this.lastStop.etaAt "d MMM HH:mm"}} {{/if}} diff --git a/addon/helpers/format-duration.js b/addon/helpers/format-duration.js new file mode 100644 index 000000000..6bf47bc02 --- /dev/null +++ b/addon/helpers/format-duration.js @@ -0,0 +1,38 @@ +import { helper } from '@ember/component/helper'; + +export function formatDurationValue(seconds) { + const value = Number(seconds); + + if (!Number.isFinite(value)) { + return '0s'; + } + + const totalSeconds = Math.max(0, Math.ceil(value)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + const parts = []; + + if (days) { + parts.push(`${days}d`); + } + + if (hours) { + parts.push(`${hours}h`); + } + + if (!days && minutes) { + parts.push(`${minutes}m`); + } + + if (!days && !hours && parts.length < 2 && remainingSeconds) { + parts.push(`${remainingSeconds}s`); + } + + return parts.length ? parts.join(' ') : '0s'; +} + +export default helper(function formatDuration([seconds]) { + return formatDurationValue(seconds); +}); diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 85de59292..aa113c004 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -4644,7 +4644,7 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions .tracking-stop-progress__scroller { overflow: auto hidden; - min-height: 2.7rem; + min-height: 2rem; padding: 0.2rem 0 0.05rem; } @@ -4797,23 +4797,3 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions flex-direction: column; padding: 0.75rem; } - -.route-list-current-stop-badge { - display: inline-flex; - align-items: center; - border: 1px solid rgba(99, 102, 241, 30%); - border-radius: 9999px; - background: rgba(99, 102, 241, 12%); - color: #4f46e5; - font-size: 0.7rem; - font-weight: 700; - line-height: 1.2; - padding: 0.16rem 0.45rem; - white-space: nowrap; -} - -[data-theme='dark'] .route-list-current-stop-badge { - border-color: rgba(129, 140, 248, 36%); - background: rgba(129, 140, 248, 16%); - color: #a5b4fc; -} diff --git a/app/helpers/format-duration.js b/app/helpers/format-duration.js new file mode 100644 index 000000000..6b498e34d --- /dev/null +++ b/app/helpers/format-duration.js @@ -0,0 +1 @@ +export { default, formatDurationValue } from '@fleetbase/fleetops-engine/helpers/format-duration'; From c4c6234b497bf230d0a5914700fc822e9e914fa8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 18:27:37 +0800 Subject: [PATCH 10/19] Fix route summary stop count --- addon/components/order/details/route.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/addon/components/order/details/route.js b/addon/components/order/details/route.js index 93e8164e1..fc5f86369 100644 --- a/addon/components/order/details/route.js +++ b/addon/components/order/details/route.js @@ -4,6 +4,7 @@ import { action } from '@ember/object'; import { debug } from '@ember/debug'; import { task } from 'ember-concurrency'; import { applyOptimizedIntermediateWaypoints, buildRouteOptimizationInput, canOptimizeIntermediateWaypoints } from '../../../utils/order-route-editing'; +import { buildRoutePointsFromPayload } from '../../../utils/route-visualization'; export default class OrderDetailsRouteComponent extends Component { @service orderActions; @@ -78,10 +79,7 @@ export default class OrderDetailsRouteComponent extends Component { } get routeStopsCount() { - const payload = this.args.resource?.payload; - const waypointCount = payload?.waypoints?.length ?? payload?.waypoints?.toArray?.().length ?? 0; - - return [payload?.pickup, ...Array.from({ length: waypointCount }), payload?.dropoff].filter(Boolean).length; + return buildRoutePointsFromPayload(this.args.resource?.payload).length; } get hasTrackingDuration() { From 3022969689f4a56c71d4a3338585ee6efc6ea359 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 18:30:55 +0800 Subject: [PATCH 11/19] Fix tracking progress rail width --- addon/components/tracking-stop-progress.hbs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/addon/components/tracking-stop-progress.hbs b/addon/components/tracking-stop-progress.hbs index 4a2ad8be5..5a3707add 100644 --- a/addon/components/tracking-stop-progress.hbs +++ b/addon/components/tracking-stop-progress.hbs @@ -14,11 +14,9 @@ {{#each this.stops as |stop|}}
- {{#if stop.isFirst}} -
- {{else}} + {{#unless stop.isFirst}}
- {{/if}} + {{/unless}}
- {{else}} + {{#unless stop.isLast}}
- {{/if}} + {{/unless}}
{{/each}} From 1e8695998ee09557120ca3b5b3832aaaea5a638c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 12 May 2026 20:16:19 +0800 Subject: [PATCH 12/19] Few UI tweaks --- addon/components/route-list.hbs | 24 ++++++++++----------- addon/components/tracking-stop-progress.hbs | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/addon/components/route-list.hbs b/addon/components/route-list.hbs index 71d5e18d8..2b596ecbe 100644 --- a/addon/components/route-list.hbs +++ b/addon/components/route-list.hbs @@ -15,13 +15,13 @@ {{#if this.firstStop.completed}} Completed {{else if this.firstStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.firstStop.etaSeconds}} - ETA: {{format-duration this.firstStop.etaSeconds}} + ETA: {{format-duration this.firstStop.etaSeconds}} {{/if}} {{#if this.firstStop.etaAt}} - {{format-date-fns this.firstStop.etaAt "d MMM HH:mm"}} + {{format-date-fns this.firstStop.etaAt "d MMM HH:mm"}} {{/if}}
@@ -53,13 +53,13 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} - ETA: {{format-duration stop.etaSeconds}} + ETA: {{format-duration stop.etaSeconds}} {{/if}} {{#if stop.etaAt}} - {{format-date-fns stop.etaAt "d MMM HH:mm"}} + {{format-date-fns stop.etaAt "d MMM HH:mm"}} {{/if}}
@@ -81,13 +81,13 @@ {{#if stop.completed}} Completed {{else if stop.active}} - Current Stop + Current Stop {{/if}} {{#if stop.etaSeconds}} - ETA: {{format-duration stop.etaSeconds}} + ETA: {{format-duration stop.etaSeconds}} {{/if}} {{#if stop.etaAt}} - {{format-date-fns stop.etaAt "d MMM HH:mm"}} + {{format-date-fns stop.etaAt "d MMM HH:mm"}} {{/if}}
@@ -112,13 +112,13 @@ {{#if this.lastStop.completed}} Completed {{else if this.lastStop.active}} - Current Stop + Current Stop {{/if}} {{#if this.lastStop.etaSeconds}} - ETA: {{format-duration this.lastStop.etaSeconds}} + ETA: {{format-duration this.lastStop.etaSeconds}} {{/if}} {{#if this.lastStop.etaAt}} - {{format-date-fns this.lastStop.etaAt "d MMM HH:mm"}} + {{format-date-fns this.lastStop.etaAt "d MMM HH:mm"}} {{/if}}
diff --git a/addon/components/tracking-stop-progress.hbs b/addon/components/tracking-stop-progress.hbs index 5a3707add..ae1606d0a 100644 --- a/addon/components/tracking-stop-progress.hbs +++ b/addon/components/tracking-stop-progress.hbs @@ -28,10 +28,10 @@ {{stop.label}}
- +
{{stop.title}}
-
+
{{#unless stop.isLast}} From 2aa82eb04c169a790fb5aa3b1983118c4c97dccb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 08:26:34 +0800 Subject: [PATCH 13/19] Refine tracking provider settings controls --- addon/components/admin/routing-settings.hbs | 20 +++++-- addon/components/admin/routing-settings.js | 59 ++++++++++++++++--- addon/controllers/settings/routing.js | 59 ++++++++++++++++--- addon/templates/settings/routing.hbs | 20 +++++-- .../Internal/v1/SettingController.php | 6 +- 5 files changed, 141 insertions(+), 23 deletions(-) diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index e609b95f3..d1ae62c2b 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -9,14 +9,26 @@ class="w-full" @value={{this.trackingProvider}} @options={{this.trackingProviderOptions}} - @optionLabel="name" - @optionValue="key" + @optionLabel="label" + @optionValue="value" @placeholder="Select default tracking provider" @onSelect={{fn (mut this.trackingProvider)}} /> - - + +
+ + {{provider.label}} + +
diff --git a/addon/components/admin/routing-settings.js b/addon/components/admin/routing-settings.js index 3a7785f92..e87b10bc4 100644 --- a/addon/components/admin/routing-settings.js +++ b/addon/components/admin/routing-settings.js @@ -1,22 +1,23 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class AdminRoutingSettingsComponent extends Component { @service fetch; @service notifications; @tracked trackingProvider = 'google_routes'; - @tracked trackingFallbacks = 'osrm,calculated'; + @tracked trackingFallbacks = ['osrm', 'calculated']; @tracked trackingTrafficEnabled = true; @tracked trackingCacheTtlSeconds = 60; @tracked trackingRouteCacheTtlSeconds = 600; @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; @tracked trackingProviderOptions = [ - { key: 'google_routes', name: 'Google Routes' }, - { key: 'osrm', name: 'Osrm' }, - { key: 'calculated', name: 'Calculated' }, + { value: 'google_routes', label: 'Google Routes' }, + { value: 'osrm', label: 'OSRM' }, + { value: 'calculated', label: 'Calculated' }, ]; constructor() { @@ -28,13 +29,13 @@ export default class AdminRoutingSettingsComponent extends Component { try { const settings = yield this.fetch.get('fleet-ops/settings/admin-tracking-settings'); this.trackingProvider = settings.provider ?? 'google_routes'; - this.trackingFallbacks = this.normalizeFallbacks(settings.fallbacks).join(','); + this.trackingFallbacks = this.normalizeFallbacks(settings.fallbacks); this.trackingTrafficEnabled = settings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = settings.cache_ttl_seconds ?? 60; this.trackingRouteCacheTtlSeconds = settings.route_cache_ttl_seconds ?? 600; this.trackingStaleLocationThresholdSeconds = settings.stale_location_threshold_seconds ?? 300; this.trackingDefaultVehicleSpeedKph = settings.default_vehicle_speed_kph ?? 35; - this.trackingProviderOptions = settings.providers ?? this.trackingProviderOptions; + this.trackingProviderOptions = this.normalizeProviderOptions(settings.providers ?? this.trackingProviderOptions); } catch (error) { this.notifications.serverError(error); } @@ -63,7 +64,10 @@ export default class AdminRoutingSettingsComponent extends Component { normalizeFallbacks(fallbacks) { if (Array.isArray(fallbacks)) { - return fallbacks.map((fallback) => String(fallback).trim()).filter(Boolean); + return fallbacks + .map((fallback) => this.optionValue(fallback)) + .map((fallback) => String(fallback).trim()) + .filter(Boolean); } return String(fallbacks ?? '') @@ -71,4 +75,45 @@ export default class AdminRoutingSettingsComponent extends Component { .map((fallback) => fallback.trim()) .filter(Boolean); } + + get selectedTrackingFallbackOptions() { + const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks)); + + return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option))); + } + + normalizeProviderOptions(options = []) { + return options.map((option) => { + const value = this.optionValue(option); + const label = option?.label ?? option?.name ?? this.providerLabel(value); + + return { + ...option, + key: option?.key ?? value, + name: option?.name ?? label, + value, + label, + }; + }); + } + + providerLabel(value) { + if (value === 'osrm') { + return 'OSRM'; + } + + return String(value ?? '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (character) => character.toUpperCase()); + } + + optionValue(option) { + return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option; + } + + @action setTrackingFallbacks(options) { + this.trackingFallbacks = this.normalizeFallbacks(options); + } } diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js index 1823fce59..5e1f127af 100644 --- a/addon/controllers/settings/routing.js +++ b/addon/controllers/settings/routing.js @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class SettingsRoutingController extends Controller { @@ -13,16 +14,16 @@ export default class SettingsRoutingController extends Controller { @tracked optimizationEngine = 'osrm'; @tracked routingUnit = 'km'; @tracked trackingProvider = 'google_routes'; - @tracked trackingFallbacks = 'osrm,calculated'; + @tracked trackingFallbacks = ['osrm', 'calculated']; @tracked trackingTrafficEnabled = true; @tracked trackingCacheTtlSeconds = 60; @tracked trackingRouteCacheTtlSeconds = 600; @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; @tracked trackingProviderOptions = [ - { key: 'google_routes', name: 'Google Routes' }, - { key: 'osrm', name: 'Osrm' }, - { key: 'calculated', name: 'Calculated' }, + { value: 'google_routes', label: 'Google Routes' }, + { value: 'osrm', label: 'OSRM' }, + { value: 'calculated', label: 'Calculated' }, ]; @tracked routingUnitOptions = [ { label: 'Kilometers', value: 'km' }, @@ -81,18 +82,24 @@ export default class SettingsRoutingController extends Controller { this.routingUnit = unit; const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings'); this.trackingProvider = trackingSettings.provider ?? 'google_routes'; - this.trackingFallbacks = this.normalizeFallbacks(trackingSettings.fallbacks).join(','); + this.trackingFallbacks = this.normalizeFallbacks(trackingSettings.fallbacks); this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = trackingSettings.cache_ttl_seconds ?? 60; this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600; this.trackingStaleLocationThresholdSeconds = trackingSettings.stale_location_threshold_seconds ?? 300; this.trackingDefaultVehicleSpeedKph = trackingSettings.default_vehicle_speed_kph ?? 35; - this.trackingProviderOptions = trackingSettings.providers ?? this.trackingProviderOptions; + this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions); } catch (error) { this.notifications.serverError(error); } } + get selectedTrackingFallbackOptions() { + const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks)); + + return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option))); + } + get trackingSettingsPayload() { return { provider: this.trackingProvider, @@ -107,7 +114,10 @@ export default class SettingsRoutingController extends Controller { normalizeFallbacks(fallbacks) { if (Array.isArray(fallbacks)) { - return fallbacks.map((fallback) => String(fallback).trim()).filter(Boolean); + return fallbacks + .map((fallback) => this.optionValue(fallback)) + .map((fallback) => String(fallback).trim()) + .filter(Boolean); } return String(fallbacks ?? '') @@ -116,6 +126,41 @@ export default class SettingsRoutingController extends Controller { .filter(Boolean); } + normalizeProviderOptions(options = []) { + return options.map((option) => { + const value = this.optionValue(option); + const label = option?.label ?? option?.name ?? this.providerLabel(value); + + return { + ...option, + key: option?.key ?? value, + name: option?.name ?? label, + value, + label, + }; + }); + } + + providerLabel(value) { + if (value === 'osrm') { + return 'OSRM'; + } + + return String(value ?? '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (character) => character.toUpperCase()); + } + + optionValue(option) { + return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option; + } + + @action setTrackingFallbacks(options) { + this.trackingFallbacks = this.normalizeFallbacks(options); + } + registerSaveTask(key, task) { if (!key || typeof task?.perform !== 'function') { return; diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index c58bfb3d9..9225af95c 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -53,15 +53,27 @@ + +
+ + {{provider.label}} + +
diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index e808b378c..380ae58dd 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -527,9 +527,13 @@ protected function trackingProviderOptions(): array $registry = app(TrackingProviderRegistry::class); return collect($registry->all())->map(function ($provider, $key) { + $label = $key === 'osrm' ? 'OSRM' : str($key)->replace('_', ' ')->title()->toString(); + return [ 'key' => $key, - 'name' => str($key)->replace('_', ' ')->title()->toString(), + 'name' => $label, + 'value' => $key, + 'label' => $label, 'capabilities' => $provider->capabilities()->toArray(), ]; })->values()->all(); From 4cd16fe4db8b6a1542b4de846364cbcdbd4a8628 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 08:32:59 +0800 Subject: [PATCH 14/19] Hide tracking technical settings behind advanced toggle --- addon/components/admin/routing-settings.hbs | 45 +++++++++++++-------- addon/components/admin/routing-settings.js | 5 +++ addon/controllers/settings/routing.js | 5 +++ addon/templates/settings/routing.hbs | 45 +++++++++++++-------- 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index d1ae62c2b..23781d4cf 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -30,23 +30,36 @@ - - - -
- - - - - - - - - - - - +
+
+ {{#if this.showTrackingAdvancedSettings}} +
+ + + +
+ + + + + + + + + + + + +
+
+ {{/if}} diff --git a/addon/components/admin/routing-settings.js b/addon/components/admin/routing-settings.js index e87b10bc4..106ba740e 100644 --- a/addon/components/admin/routing-settings.js +++ b/addon/components/admin/routing-settings.js @@ -14,6 +14,7 @@ export default class AdminRoutingSettingsComponent extends Component { @tracked trackingRouteCacheTtlSeconds = 600; @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; + @tracked showTrackingAdvancedSettings = false; @tracked trackingProviderOptions = [ { value: 'google_routes', label: 'Google Routes' }, { value: 'osrm', label: 'OSRM' }, @@ -116,4 +117,8 @@ export default class AdminRoutingSettingsComponent extends Component { @action setTrackingFallbacks(options) { this.trackingFallbacks = this.normalizeFallbacks(options); } + + @action toggleTrackingAdvancedSettings() { + this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings; + } } diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js index 5e1f127af..2af4b7d89 100644 --- a/addon/controllers/settings/routing.js +++ b/addon/controllers/settings/routing.js @@ -20,6 +20,7 @@ export default class SettingsRoutingController extends Controller { @tracked trackingRouteCacheTtlSeconds = 600; @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; + @tracked showTrackingAdvancedSettings = false; @tracked trackingProviderOptions = [ { value: 'google_routes', label: 'Google Routes' }, { value: 'osrm', label: 'OSRM' }, @@ -161,6 +162,10 @@ export default class SettingsRoutingController extends Controller { this.trackingFallbacks = this.normalizeFallbacks(options); } + @action toggleTrackingAdvancedSettings() { + this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings; + } + registerSaveTask(key, task) { if (!key || typeof task?.perform !== 'function') { return; diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index 9225af95c..5368faeca 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -75,23 +75,36 @@
- - - -
- - - - - - - - - - - - +
+
+ {{#if this.showTrackingAdvancedSettings}} +
+ + + +
+ + + + + + + + + + + + +
+
+ {{/if}} From b3ac3ce27bf1fb168570b03f419acadd15678941 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 08:36:06 +0800 Subject: [PATCH 15/19] Polish tracking advanced settings toggle --- addon/components/admin/routing-settings.hbs | 6 +++--- addon/templates/settings/routing.hbs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index 23781d4cf..a3a210033 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -23,7 +23,7 @@ @selected={{this.selectedTrackingFallbackOptions}} @onChange={{this.setTrackingFallbacks}} @placeholder="Select default fallback providers" - @triggerClass="form-select form-input flex-1" + @triggerClass="form-select form-input form-input-sm flex-1" as |provider| > {{provider.label}} @@ -35,12 +35,12 @@ @type="default" @size="xs" @icon={{if this.showTrackingAdvancedSettings "chevron-up" "chevron-down"}} - @text={{if this.showTrackingAdvancedSettings "Hide Advanced Settings" "Advanced Settings"}} + @text="Advanced Settings" @onClick={{this.toggleTrackingAdvancedSettings}} />
{{#if this.showTrackingAdvancedSettings}} -
+
diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index 5368faeca..f181bd04a 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -68,7 +68,7 @@ @selected={{this.selectedTrackingFallbackOptions}} @onChange={{this.setTrackingFallbacks}} @placeholder="Select fallback providers" - @triggerClass="form-select form-input flex-1" + @triggerClass="form-select form-input form-input-sm flex-1" as |provider| > {{provider.label}} @@ -80,12 +80,12 @@ @type="default" @size="xs" @icon={{if this.showTrackingAdvancedSettings "chevron-up" "chevron-down"}} - @text={{if this.showTrackingAdvancedSettings "Hide Advanced Settings" "Advanced Settings"}} + @text="Advanced Settings" @onClick={{this.toggleTrackingAdvancedSettings}} />
{{#if this.showTrackingAdvancedSettings}} -
+
From f2adb0ff432e230692a82ac0467ea188c8c11e45 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 09:00:53 +0800 Subject: [PATCH 16/19] Fix active live order count metric --- addon/components/map/toolbar.hbs | 4 +- addon/components/map/toolbar.js | 12 ++- addon/services/order-list-overlay.js | 4 +- .../Internal/v1/LiveController.php | 48 ++---------- server/src/Support/LiveOrderQuery.php | 75 +++++++++++++++++++ server/src/Support/Metrics.php | 15 ++++ server/tests/LiveOrderQueryTest.php | 34 +++++++++ .../components/map/toolbar-test.js | 60 ++++++++++++--- .../unit/services/order-list-overlay-test.js | 8 ++ 9 files changed, 201 insertions(+), 59 deletions(-) create mode 100644 server/src/Support/LiveOrderQuery.php create mode 100644 server/tests/LiveOrderQueryTest.php diff --git a/addon/components/map/toolbar.hbs b/addon/components/map/toolbar.hbs index 527a6bb69..510e1652e 100644 --- a/addon/components/map/toolbar.hbs +++ b/addon/components/map/toolbar.hbs @@ -42,8 +42,8 @@
{{#if this.showTrackingAdvancedSettings}} -
+
diff --git a/server/src/Http/Controllers/Api/v1/PayloadController.php b/server/src/Http/Controllers/Api/v1/PayloadController.php index 6d6315488..d9732156b 100644 --- a/server/src/Http/Controllers/Api/v1/PayloadController.php +++ b/server/src/Http/Controllers/Api/v1/PayloadController.php @@ -25,16 +25,16 @@ class PayloadController extends Controller */ public function create(CreatePayloadRequest $request) { - $input = $request->all(); - $entities = data_get($input, 'entities', []); - $waypoints = data_get($input, 'waypoints', []); - $pickup = data_get($input, 'pickup'); - $dropoff = data_get($input, 'dropoff'); - $return = data_get($input, 'return'); - $hasPickupField = array_key_exists('pickup', $input); - $hasDropoffField = array_key_exists('dropoff', $input); - $hasReturnField = array_key_exists('return', $input); - $hasWaypointsField = array_key_exists('waypoints', $input); + $input = $request->all(); + $entities = data_get($input, 'entities', []); + $waypoints = data_get($input, 'waypoints', []); + $pickup = data_get($input, 'pickup'); + $dropoff = data_get($input, 'dropoff'); + $return = data_get($input, 'return'); + $hasPickupField = array_key_exists('pickup', $input); + $hasDropoffField = array_key_exists('dropoff', $input); + $hasReturnField = array_key_exists('return', $input); + $hasWaypointsField = array_key_exists('waypoints', $input); $hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField; // make sure company is set diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php index 9a673b909..cdb43efac 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php @@ -231,7 +231,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request) $entities = collect($entities)->mapInto(Entity::class); // should all be Place like - $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter(); + $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter(); $endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place); // if facilitator is an integrated partner resolve service quotes from bridge diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php index 4220829ff..34278e310 100644 --- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php @@ -573,7 +573,7 @@ public function importOrders(Request $request): JsonResponse $isMulti = $orderType === 'multi_waypoint'; // ── Resolve OrderConfig ─────────────────────────────────────── - $orderConfigUuid = null; + $orderConfigUuid = null; $resolvedOrderConfig = null; if (!empty($firstRow['type'])) { $resolvedOrderConfig = OrderConfig::resolveFromIdentifier($firstRow['type']); diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 6290d902a..8ea82659a 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -825,7 +825,7 @@ public function pingDriver(string $id) } try { - $order = Order::findRecordOrFail($id, ['driverAssigned']); + $order = Order::findByIdOrFail($id, ['driverAssigned']); } catch (ModelNotFoundException $e) { return response()->error('Order resource not found.', 404); } diff --git a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php index c3c8b8436..3e7b31d39 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php @@ -226,7 +226,7 @@ public function preliminaryQuery(Request $request) $entities = collect($entities)->mapInto(Entity::class); // should all be Place like - $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter(); + $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter(); $endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place); // if facilitator is an integrated partner resolve service quotes from bridge diff --git a/server/src/Http/Resources/v1/Index/Payload.php b/server/src/Http/Resources/v1/Index/Payload.php index 8509a088e..cf821fe92 100644 --- a/server/src/Http/Resources/v1/Index/Payload.php +++ b/server/src/Http/Resources/v1/Index/Payload.php @@ -19,8 +19,8 @@ class Payload extends FleetbaseResource public function toArray($request): array { $isInternal = Http::isInternalRequest(); - $pickup = $this->index_pickup_place; - $dropoff = $this->index_dropoff_place; + $pickup = $this->index_pickup_place; + $dropoff = $this->index_dropoff_place; return [ 'id' => $this->when($isInternal, $this->id, $this->public_id), diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 3a3c97c37..dc021ed53 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1739,7 +1739,7 @@ public function config(): ?OrderConfig } } - $company = $this->relationLoaded('company') ? $this->company : $this->company()->first(); + $company = $this->relationLoaded('company') ? $this->company : $this->company()->first(); $orderConfig = OrderConfig::defaultOrCreate($company); if ($orderConfig instanceof OrderConfig) { $orderConfig->setOrderContext($this); diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php index 14b6bace0..67787ee94 100644 --- a/server/src/Models/Payload.php +++ b/server/src/Models/Payload.php @@ -555,15 +555,15 @@ public function updateWaypoints($waypoints = []) } $this->loadMissing('waypointMarkers'); - $keptWaypointIds = []; + $keptWaypointIds = []; $availableWaypointMarkers = $this->waypointMarkers()->get(); foreach ($waypoints as $index => $attributes) { - $raw = $attributes; - $type = data_get($raw, 'type', 'dropoff'); - $customerUuidIn = data_get($raw, 'customer_uuid'); + $raw = $attributes; + $type = data_get($raw, 'type', 'dropoff'); + $customerUuidIn = data_get($raw, 'customer_uuid'); $customerPubIdIn = data_get($raw, 'customer_id'); - $customerType = data_get($raw, 'customer_type', 'fleetops:contact'); + $customerType = data_get($raw, 'customer_type', 'fleetops:contact'); if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) { $attributes = Utils::get($attributes, 'place'); @@ -599,12 +599,12 @@ public function updateWaypoints($waypoints = []) $placeUuid = $place->uuid; } - $customerUuid = null; + $customerUuid = null; $customerTypeNamespace = null; if ($customerType) { $customerTypeNamespace = Utils::getMutationType($customerType); - $customerModel = app($customerTypeNamespace); + $customerModel = app($customerTypeNamespace); if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) { $customerUuid = $customerUuidIn; @@ -642,7 +642,7 @@ public function updateWaypoints($waypoints = []) continue; } - $waypointRecord = Waypoint::create($values); + $waypointRecord = Waypoint::create($values); $keptWaypointIds[] = $waypointRecord->uuid; } diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php index d51836994..cf919215d 100644 --- a/server/src/Models/Place.php +++ b/server/src/Models/Place.php @@ -21,7 +21,6 @@ use Fleetbase\Traits\SendsWebhooks; use Fleetbase\Traits\TracksApiCredential; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class Place extends Model @@ -383,8 +382,6 @@ public static function createFromGeocodingLookup(string $address, $saveInstance /** * Create a new Place instance from a geocoding lookup. - * - * @return array */ public static function getValuesFromGeocodingLookup(string $address): array { diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index e47b1fe9f..7576a699e 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -740,7 +740,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->isAlgorithm()) { $resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints); - $rateFee = $this->normalizeCalculatedMoney(Algo::exec( + $rateFee = $this->normalizeCalculatedMoney(Algo::exec( $this->algorithm, $this->buildAlgorithmVariables( $entities, diff --git a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php index cbb943f13..b5830871e 100644 --- a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +++ b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php @@ -104,9 +104,9 @@ public function allocate(Collection $orders, Collection $vehicles, array $option unset($job); // ── Resolve connection config ───────────────────────────────────────── - $baseUri = $this->resolveVroomBaseUri(); + $baseUri = $this->resolveVroomBaseUri(); $endpointMode = $this->resolveVroomEndpointMode(); - $timeout = (int) env('VROOM_TIMEOUT', 30); + $timeout = (int) env('VROOM_TIMEOUT', 30); $apiKey = $this->resolveVroomApiKey(); diff --git a/server/src/Support/LiveOrderQuery.php b/server/src/Support/LiveOrderQuery.php index 2899642b2..ecda46c78 100644 --- a/server/src/Support/LiveOrderQuery.php +++ b/server/src/Support/LiveOrderQuery.php @@ -7,7 +7,7 @@ class LiveOrderQuery { - public static array $baseExcludedStatuses = ['canceled', 'completed', 'expired']; + public static array $baseExcludedStatuses = ['canceled', 'completed', 'expired']; public static array $activeExcludedStatuses = ['created', 'completed', 'expired', 'order_canceled', 'canceled', 'pending']; public static function make(?string $companyUuid = null, array $options = []): Builder diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 8a5160d11..8c67ca43e 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -392,7 +392,7 @@ protected static function extractPointWktFromQueryExpression(\Illuminate\Databas return null; } - if (preg_match("/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i", $expressionValue, $matches)) { + if (preg_match('/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i', $expressionValue, $matches)) { return sprintf('POINT(%s %s)', $matches[1], $matches[2]); } diff --git a/server/tests/PingDriverEndpointTest.php b/server/tests/PingDriverEndpointTest.php index b92a21481..e41d2b35b 100644 --- a/server/tests/PingDriverEndpointTest.php +++ b/server/tests/PingDriverEndpointTest.php @@ -15,5 +15,5 @@ ->and($apiController)->not->toContain('Notifications\OrderPing') ->and($internalController)->toContain('function pingDriver') ->and($internalController)->toContain('Notifications\OrderPing') - ->and($internalController)->toContain("fleet-ops update order"); + ->and($internalController)->toContain('fleet-ops update order'); }); From f3da6f591286199962fac815bebdafd9cc9a7fd9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 11:18:28 +0800 Subject: [PATCH 19/19] hotfix: fix lint:js error on `order/details/purchase-rate` component --- addon/components/order/details/purchase-rate.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/components/order/details/purchase-rate.hbs b/addon/components/order/details/purchase-rate.hbs index ab2ea8034..0a3dc025c 100644 --- a/addon/components/order/details/purchase-rate.hbs +++ b/addon/components/order/details/purchase-rate.hbs @@ -9,7 +9,7 @@ - {{#each @resource.purchase_rate.service_quote.items as |item index|}} + {{#each @resource.purchase_rate.service_quote.items as |item|}} {{smart-humanize item.details}}