diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index 7ee7af45f..5a1f5ba65 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -1,36 +1,117 @@
- Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings. + Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings, but only with providers that are switched on + below. - - + +

+ Choose which providers organizations may use for map routing, route optimization, order distance and tracking. A provider switched off here is never called, even if an + organization selected it; they fall back to the defaults below. Changes apply on every server within 30 seconds. +

+
+ {{#each this.providerAccessRows as |provider|}} +
+
+
{{provider.label}}
+ {{#if provider.billed}} +
Billed per request to the platform's Google Maps key.
+ {{/if}} +
+ +
+ {{/each}} +
+
+ + + + + + + - - -
- - {{provider.label}} - -
-
+

+ Live ETAs, remaining distance and route lines use the routing service selected above, the same one that measures each order's distance and time. These settings only tune + tracking behaviour. +

- + \ No newline at end of file diff --git a/addon/utils/routing-providers.js b/addon/utils/routing-providers.js new file mode 100644 index 000000000..bb9e3dede --- /dev/null +++ b/addon/utils/routing-providers.js @@ -0,0 +1,21 @@ +import { underscore } from '@ember/string'; + +/** + * Built-in routing providers governed by the admin routing provider policy + * (Admin → Fleet-Ops → Routing). Engines registered by other extensions are not + * listed here and are never filtered. + */ +export const GOVERNED_ROUTING_PROVIDERS = ['google', 'valhalla', 'osrm', 'vroom']; + +/** + * Whether the organization may use an engine, given the `allowed_providers` + * list from its routing settings. Without that list nothing is filtered. + */ +export default function isRoutingProviderAllowed(name, allowedProviders) { + const key = underscore(String(name ?? '')); + if (!Array.isArray(allowedProviders) || !GOVERNED_ROUTING_PROVIDERS.includes(key)) { + return true; + } + + return allowedProviders.includes(key); +} diff --git a/app/utils/routing-providers.js b/app/utils/routing-providers.js new file mode 100644 index 000000000..21f15cd22 --- /dev/null +++ b/app/utils/routing-providers.js @@ -0,0 +1 @@ +export { default, GOVERNED_ROUTING_PROVIDERS } from '@fleetbase/fleetops-engine/utils/routing-providers'; diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 798e4e351..36446b851 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -40,6 +40,51 @@ 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'), ], + /* + |-------------------------------------------------------------------------- + | Routing + |-------------------------------------------------------------------------- + | + | Starting values for Admin -> Fleet-Ops -> Routing. Once an administrator + | saves that page these are no longer read. + | + | The routing engine answers every route, distance and time question: map + | route lines, an order's stored distance and time, and live ETAs while it + | is in progress. Google is billed per request, so organizations cannot use + | it unless it is switched on (globally or for named organizations). + | ROUTING_SERVER_ROUTING=straight_line measures orders and ETAs with the + | straight-line estimate instead of calling any engine. + | + | DISTANCE_MATRIX_PROVIDER is still honoured as a starting value for + | installs that set it before these existed. + | + */ + 'routing' => [ + 'google_enabled' => env('ROUTING_GOOGLE_ENABLED', env('DISTANCE_MATRIX_PROVIDER') === 'google'), + 'display_engine' => env('ROUTING_DISPLAY_ENGINE', in_array(env('DISTANCE_MATRIX_PROVIDER'), ['osrm', 'valhalla', 'google'], true) ? env('DISTANCE_MATRIX_PROVIDER') : 'valhalla'), + 'optimization_engine' => env('ROUTING_OPTIMIZATION_ENGINE'), + 'server_routing' => env('ROUTING_SERVER_ROUTING', env('DISTANCE_MATRIX_PROVIDER') === 'calculate' ? 'straight_line' : 'road'), + ], + + /* + |-------------------------------------------------------------------------- + | Valhalla + |-------------------------------------------------------------------------- + | + | A self-hosted Valhalla server used for order distance, time and tracking + | ETAs. The host and key set in the Valhalla extension's admin settings (or + | an organization's own) take precedence over these variables, which are + | shared with that extension. The key is sent as the X-Api-Key header. The + | public community servers are never used by the server. + | + */ + 'valhalla' => [ + 'host' => env('VALHALLA_HOST', env('VALHALLA_BASE_URI')), + 'api_key' => env('VALHALLA_API_KEY'), + 'costing' => env('VALHALLA_COSTING', 'auto'), + 'timeout' => env('VALHALLA_TIMEOUT', 5), + ], + /* |-------------------------------------------------------------------------- | VROOM Route Optimisation Engine @@ -61,12 +106,16 @@ /* |-------------------------------------------------------------------------- - | Distance Matrix Calculator - | Options: "calculate", "google", "osrm" + | Service Quote Distance |-------------------------------------------------------------------------- + | + | Provider for the multi-stop distance used when pricing service quotes: + | "calculate" (default), "osrm" or "google". Order distance and time do + | not use this; they follow the routing engine (see Routing above). + | */ 'distance_matrix' => [ - 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate'), + 'provider' => env('DISTANCE_MATRIX_PROVIDER'), ], /* @@ -74,15 +123,12 @@ | 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" + | Live ETAs, remaining distance and route lines for active orders. They + | come from the organization's routing engine (see Routing above) and fall + | back to the straight-line estimate; these values only tune behaviour. |-------------------------------------------------------------------------- */ '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), 'route_cache_ttl_seconds' => env('TRACKING_ROUTE_CACHE_TTL_SECONDS', 600), diff --git a/server/src/Console/Commands/TrackOrderDistanceAndTime.php b/server/src/Console/Commands/TrackOrderDistanceAndTime.php index dbbb10eb7..65bee3f01 100644 --- a/server/src/Console/Commands/TrackOrderDistanceAndTime.php +++ b/server/src/Console/Commands/TrackOrderDistanceAndTime.php @@ -13,7 +13,7 @@ class TrackOrderDistanceAndTime extends Command * The name and signature of the console command. * * Options: - * --provider= : distance/time provider (calculate, google, osrm). Defaults to config(fleetops.distance_matrix.provider) + * --provider= : distance/time provider (calculate, google, osrm). Defaults to each order's company routing settings * --days= : only include orders created in last N days (default: 2) * --chunk= : records per chunk (default: 250) * --no-lock : skip single-run lock (not recommended) @@ -33,13 +33,14 @@ public function handle(): int // Always operate in UTC date_default_timezone_set('UTC'); - $provider = $this->option('provider') ?: (string) config('fleetops.distance_matrix.provider'); + // Without --provider each order resolves its own company's provider + $provider = $this->option('provider') ?: null; $days = max(1, (int) $this->option('days')); // guardrail $perChunk = max(50, (int) $this->option('chunk')); // sane lower bound $dryRun = (bool) $this->option('dry'); $useLock = !$this->option('no-lock'); - $this->info("Using provider: {$provider}"); + $this->info('Using provider: ' . ($provider ?? 'company routing settings')); $this->info("Looking back: last {$days} day(s)"); $this->info("Chunk size: {$perChunk}" . ($dryRun ? ' (dry-run)' : '')); @@ -83,7 +84,7 @@ public function handle(): int foreach ($orders as $order) { try { if (!$dryRun) { - $order->setDistanceAndTime(['provider' => $provider]); + $order->setDistanceAndTime($provider ? ['provider' => $provider] : []); } $updated++; } catch (\Throwable $e) { diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php index 6db6bdab8..52e6e4f9a 100644 --- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php @@ -20,6 +20,7 @@ use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry; use Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings; use Fleetbase\FleetOps\Orchestration\Support\RouteMetrics; +use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Traits\AuthorizesMethods; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -672,8 +673,11 @@ protected function updateWaypointSequence(string $payloadUuid, string $waypointP */ public function engines(): JsonResponse { + // Engines whose provider the administrator switched off are not offered + $companyUuid = $this->companyUuid(); + return response()->json([ - 'engines' => $this->registry->available(), + 'engines' => array_values(array_filter($this->registry->available(), fn ($engine) => RoutingProviderPolicy::allows($engine['id'], $companyUuid))), ]); } diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index 57d2bde89..eb7fccf74 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -4,11 +4,12 @@ use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs; use Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; +use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy; use Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue; use Fleetbase\FleetOps\Support\TrackingPage\TrackingPageConfig; use Fleetbase\FleetOps\Support\Utils; -use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; use Fleetbase\FleetOps\Traits\AuthorizesMethods; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\Setting; @@ -38,6 +39,8 @@ public function __construct() 'saveRoutingSettings' => 'update routing-settings', 'saveTrackingSettings' => 'update tracking-settings', 'getAdminTrackingSettings' => 'admin', + 'getAdminRoutingProviders' => 'admin', + 'saveAdminRoutingProviders' => 'admin', 'saveAdminTrackingSettings' => 'admin', 'saveMapSettings' => 'update map-settings', 'getAdminMapSettings' => 'admin', @@ -248,9 +251,16 @@ public function getNotificationSettings() */ public function saveRoutingSettings(Request $request) { - $displayEngine = $request->input('display_engine', $request->input('router', 'osrm')); + $displayEngine = $request->input('display_engine', $request->input('router', RoutingProviderPolicy::defaultDisplayEngine())); $optimizationEngine = $request->input('optimization_engine', $displayEngine); $unit = $request->input('unit', 'km'); + + foreach ([$displayEngine, $optimizationEngine] as $engine) { + if (!RoutingProviderPolicy::allows($engine, $this->policyCompanyUuid())) { + return response()->error($this->providerDisabledMessage($engine), 422); + } + } + $this->configureCompanySetting('routing', [ 'router' => $displayEngine, 'display_engine' => $displayEngine, @@ -273,10 +283,17 @@ public function saveRoutingSettings(Request $request) */ public function getRoutingSettings() { - $routingSettings = $this->lookupCompanySetting('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)); + $companyUuid = $this->policyCompanyUuid(); + $defaultDisplay = RoutingProviderPolicy::defaultDisplayEngine(); + $defaultOptimizing = RoutingProviderPolicy::defaultOptimizationEngine(); + $routingSettings = $this->lookupCompanySetting('routing', ['unit' => 'km']); + $savedDisplay = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router'))); + $savedOptimizing = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $savedDisplay)); + + // Platform defaults fill in what the organization never chose, and replace an engine the administrator switched off + $displayEngine = RoutingProviderPolicy::engineFor($savedDisplay, $defaultDisplay, $companyUuid); + $optimizationEngine = RoutingProviderPolicy::engineFor($savedOptimizing, $defaultOptimizing, $companyUuid); + $routingSettings['allowed_providers'] = RoutingProviderPolicy::allowedProviders($companyUuid); $routingSettings['router'] = $displayEngine; $routingSettings['display_engine'] = $displayEngine; $routingSettings['optimization_engine'] = $optimizationEngine; @@ -300,15 +317,10 @@ public function getRoutingSettings() */ public function saveTrackingSettings(Request $request) { - $config = $this->trackingDefaults(); - $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated'])); - if (is_string($fallbacks)) { - $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); - } + // The provider is the organization's routing engine; only tracking behaviour is saved here + $config = $this->trackingDefaults(); $this->configureCompanySetting('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)), 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)), @@ -332,8 +344,6 @@ public function getTrackingSettings() { $config = $this->trackingDefaults(); $trackingSettings = $this->lookupCompanySetting('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), 'route_cache_ttl_seconds' => data_get($config, 'route_cache_ttl_seconds', 600), @@ -341,30 +351,50 @@ public function getTrackingSettings() 'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35), 'alerts' => data_get($config, 'alerts', $this->trackingAlertDefaults()), ]); - $trackingSettings['alerts'] = $this->normalizeTrackingAlertSettings(data_get($trackingSettings, 'alerts', [])); - $trackingSettings['providers'] = $this->trackingProviderOptions(); + // Provider choices saved before tracking followed the routing engine are no longer used + unset($trackingSettings['provider'], $trackingSettings['fallbacks']); + $trackingSettings['alerts'] = $this->normalizeTrackingAlertSettings(data_get($trackingSettings, 'alerts', [])); + $trackingSettings['routing_engine'] = RoutingEngineResolver::resolve($this->policyCompanyUuid()); return response()->json($trackingSettings); } - public function getAdminTrackingSettings() + /** + * The routing provider policy: which providers organizations may use, the + * platform's default engines and per-organization exceptions. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getAdminRoutingProviders() { - return response()->json(array_merge($this->trackingDefaults(), [ - 'providers' => $this->trackingProviderOptions(), + return response()->json(array_merge(RoutingProviderPolicy::settings(), [ + 'available_providers' => collect(RoutingProviderPolicy::PROVIDERS)->map(fn ($label, $key) => ['key' => $key, 'label' => $label])->values()->all(), ])); } + /** + * Save the routing provider policy. Takes effect on every worker within + * RoutingProviderPolicy::CACHE_TTL seconds. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveAdminRoutingProviders(Request $request) + { + $this->saveRoutingProviderPolicy($request->only(['providers', 'defaults', 'server_routing', 'exceptions'])); + + return $this->getAdminRoutingProviders(); + } + + public function getAdminTrackingSettings() + { + return response()->json($this->trackingDefaults()); + } + public function saveAdminTrackingSettings(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)))); - } + $config = config('fleetops.tracking', []); $this->configureSetting('fleet-ops.tracking-settings', [ - '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)), 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)), @@ -728,7 +758,8 @@ protected function trackingDefaults(): array $config = config('fleetops.tracking', []); $systemSettings = $this->lookupSetting('fleet-ops.tracking-settings', []); - $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []); + $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []); + unset($defaults['provider'], $defaults['fallbacks']); $defaults['alerts'] = $this->normalizeTrackingAlertSettings(data_get($defaults, 'alerts', [])); return $defaults; @@ -772,21 +803,6 @@ protected function normalizeTrackingAlertSettings(array $alerts): array ]; } - protected function trackingProviderOptions(): array - { - return collect($this->trackingProviders())->map(function ($provider, $key) { - $label = $key === 'osrm' ? 'OSRM' : str($key)->replace('_', ' ')->title()->toString(); - - return [ - 'key' => $key, - 'name' => $label, - 'value' => $key, - 'label' => $label, - 'capabilities' => $provider->capabilities()->toArray(), - ]; - })->values()->all(); - } - /** * Retrieve customer history preferences for the current company. * @@ -1417,9 +1433,19 @@ protected function notificationsByPackage(string $package): array return NotificationRegistry::getNotificationsByPackage($package); } - protected function trackingProviders(): array + protected function saveRoutingProviderPolicy(array $input): array + { + return RoutingProviderPolicy::save($input); + } + + protected function policyCompanyUuid(): ?string + { + return session('company'); + } + + protected function providerDisabledMessage(string $provider): string { - return app(TrackingProviderRegistry::class)->all(); + return 'The ' . $provider . ' provider has been switched off by the administrator.'; } protected function googleMapsApiKey(): string diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 7afd523b8..22939926e 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1949,7 +1949,8 @@ public function setPreliminaryDistanceAndTime() /** * Sets the accurate distance and time for the order based on driving distance. - * Uses a utility method to calculate the driving distance and time between the current origin and destination positions. + * Uses a utility method to calculate the driving distance and time between the current origin and destination positions, + * with the provider the order's company routing settings resolve to unless `$options['provider']` is given. * * @return Order the updated Order instance */ @@ -1967,7 +1968,7 @@ public function setDistanceAndTime(array $options = []): self // Unreachable: `getDrivingDistanceAndTime()` is typed `: DistanceMatrix` // and every provider path casts both fields to float before constructing // it, so neither the null check nor the isset check can fail. - $matrix = Utils::getDrivingDistanceAndTime($origin, $destination, $options); + $matrix = Utils::getDrivingDistanceAndTime($origin, $destination, array_merge(['company_uuid' => $this->company_uuid], $options)); // @codeCoverageIgnoreStart if (!$matrix || !isset($matrix->distance, $matrix->time)) { return $this; diff --git a/server/src/Orchestration/Support/OrchestratorSettings.php b/server/src/Orchestration/Support/OrchestratorSettings.php index eca140c1a..2c3fb7127 100644 --- a/server/src/Orchestration/Support/OrchestratorSettings.php +++ b/server/src/Orchestration/Support/OrchestratorSettings.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Orchestration\Support; +use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\Models\Setting; /** @@ -52,10 +53,13 @@ public static function forCompany(?string $companyUuid): array } /** - * Identifier of the orchestration engine the organization selected. + * Identifier of the orchestration engine the organization selected, or the + * built-in engine when the administrator has switched that provider off. */ public static function engineForCompany(?string $companyUuid): string { - return (string) static::forCompany($companyUuid)['allocation_engine']; + $engine = (string) static::forCompany($companyUuid)['allocation_engine']; + + return RoutingProviderPolicy::allows($engine, $companyUuid) ? $engine : static::DEFAULT_ENGINE; } } diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb24c8932..344c6c098 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -191,6 +191,9 @@ function (\Fleetbase\FleetOps\Tracking\TrackingProviderRegistry $registry) { if (!$registry->has('osrm')) { $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider()); } + if (!$registry->has('valhalla')) { + $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\ValhallaTrackingProvider()); + } if (!$registry->has('calculated')) { $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider()); } diff --git a/server/src/Support/Encoding/Polyline6.php b/server/src/Support/Encoding/Polyline6.php new file mode 100644 index 000000000..a8e1c9952 --- /dev/null +++ b/server/src/Support/Encoding/Polyline6.php @@ -0,0 +1,11 @@ + 'google_routes', 'calculate' => 'calculated']; + + public static function resolve(?string $companyUuid = null): string + { + if (RoutingProviderPolicy::usesStraightLine()) { + return 'calculate'; + } + + foreach ([static::companyEngine($companyUuid), RoutingProviderPolicy::defaultDisplayEngine(), ...static::PLATFORM_ENGINES] as $engine) { + if ($engine && static::isAvailable($engine, $companyUuid)) { + return $engine; + } + } + + return 'calculate'; + } + + /** + * The tracking provider that serves the organization's routing engine. + */ + public static function trackingProvider(?string $companyUuid = null): string + { + $engine = static::resolve($companyUuid); + + return static::TRACKING_PROVIDERS[$engine] ?? $engine; + } + + /** + * The routing engine the organization chose, or null when it never chose one. + */ + public static function companyEngine(?string $companyUuid = null): ?string + { + try { + $settings = Setting::lookupForCompany($companyUuid, 'routing', []); + } catch (\Throwable) { + $settings = []; + } + + return data_get($settings, 'display_engine', data_get($settings, 'routing_display_engine', data_get($settings, 'router'))); + } + + public static function isAvailable(string $engine, ?string $companyUuid = null): bool + { + if (!RoutingProviderPolicy::allows($engine, $companyUuid)) { + return false; + } + + return match ($engine) { + 'osrm' => static::hasPrivateOsrmHost(), + 'valhalla' => ValhallaClient::isConfigured($companyUuid), + 'google' => filled(static::googleMapsApiKey()), + default => false, + }; + } + + public static function hasPrivateOsrmHost(): bool + { + $host = parse_url((string) config('fleetops.osrm.host'), PHP_URL_HOST); + + return filled($host) && strcasecmp($host, static::PUBLIC_OSRM_HOST) !== 0; + } + + public static function googleMapsApiKey(): ?string + { + $configuredKey = config('services.google_maps.api_key'); + if ($configuredKey) { + return $configuredKey; + } + + return class_exists(\PhpOption\Option::class) ? env('GOOGLE_MAPS_API_KEY') : null; + } +} diff --git a/server/src/Support/Routing/RoutingProviderPolicy.php b/server/src/Support/Routing/RoutingProviderPolicy.php new file mode 100644 index 000000000..ba0972968 --- /dev/null +++ b/server/src/Support/Routing/RoutingProviderPolicy.php @@ -0,0 +1,194 @@ + 'Google', + 'valhalla' => 'Valhalla', + 'osrm' => 'OSRM', + 'vroom' => 'VROOM', + ]; + + /** + * Provider keys used by individual features that belong to one provider. + */ + public const ALIASES = ['google_routes' => 'google']; + + public const ALWAYS_ALLOWED = ['calculate', 'calculated']; + + public const SERVER_ROUTING_MODES = ['road', 'straight_line']; + + public static function settings(): array + { + $settings = Cache::remember(static::CACHE_KEY, static::CACHE_TTL, fn () => static::normalize(static::stored())); + + // A cache store that cannot hold the policy must not take routing down with it + return is_array($settings) ? $settings : static::normalize(static::stored()); + } + + public static function save(array $input): array + { + $settings = static::normalize($input); + Setting::configure(static::SETTING_KEY, $settings); + Cache::forget(static::CACHE_KEY); + + return $settings; + } + + public static function normalize(array $settings): array + { + $providers = []; + foreach (array_keys(static::PROVIDERS) as $provider) { + $default = $provider === 'google' ? config('fleetops.routing.google_enabled', false) : true; + $providers[$provider] = filter_var(data_get($settings, 'providers.' . $provider, $default), FILTER_VALIDATE_BOOLEAN); + } + + $exceptions = collect(data_get($settings, 'exceptions', [])) + ->map(fn ($exception) => [ + 'company_uuid' => (string) data_get($exception, 'company_uuid', ''), + 'company_name' => data_get($exception, 'company_name'), + 'providers' => array_values(array_unique(array_intersect( + array_map(fn ($provider) => static::provider($provider), (array) data_get($exception, 'providers', [])), + array_keys(static::PROVIDERS) + ))), + ]) + ->filter(fn ($exception) => $exception['company_uuid'] !== '' && $exception['providers'] !== []) + ->unique('company_uuid') + ->values() + ->all(); + + $displayEngine = (string) (data_get($settings, 'defaults.display_engine') ?: config('fleetops.routing.display_engine') ?: 'valhalla'); + $serverRouting = (string) data_get($settings, 'server_routing', config('fleetops.routing.server_routing', 'road')); + + return [ + 'providers' => $providers, + 'defaults' => [ + 'display_engine' => $displayEngine, + // Optimization follows the routing engine unless set on its own + 'optimization_engine' => (string) (data_get($settings, 'defaults.optimization_engine') ?: config('fleetops.routing.optimization_engine') ?: $displayEngine), + ], + 'server_routing' => in_array($serverRouting, static::SERVER_ROUTING_MODES, true) ? $serverRouting : 'road', + 'exceptions' => $exceptions, + ]; + } + + /** + * The provider a feature-specific key belongs to, or null for the + * always-allowed straight-line providers. + */ + public static function provider(?string $key): ?string + { + $key = strtolower(trim((string) $key)); + if ($key === '' || in_array($key, static::ALWAYS_ALLOWED, true)) { + return null; + } + + return static::ALIASES[$key] ?? $key; + } + + public static function allows(?string $key, ?string $companyUuid = null): bool + { + $provider = static::provider($key); + if ($provider === null) { + return true; + } + + $settings = static::settings(); + + // Providers registered by other extensions are governed by those extensions + if (!array_key_exists($provider, $settings['providers']) || $settings['providers'][$provider]) { + return true; + } + + return $companyUuid !== null && collect($settings['exceptions'])->contains( + fn ($exception) => $exception['company_uuid'] === $companyUuid && in_array($provider, $exception['providers'], true) + ); + } + + /** + * @return string[] the built-in providers the organization may use + */ + public static function allowedProviders(?string $companyUuid = null): array + { + return array_values(array_filter(array_keys(static::PROVIDERS), fn ($provider) => static::allows($provider, $companyUuid))); + } + + /** + * Whether the administrator switched server-side routing off, so order distance + * and ETAs use the straight-line estimate without calling any routing engine. + */ + public static function usesStraightLine(): bool + { + return static::settings()['server_routing'] === 'straight_line'; + } + + public static function defaultDisplayEngine(): string + { + return static::settings()['defaults']['display_engine']; + } + + public static function defaultOptimizationEngine(): string + { + return static::settings()['defaults']['optimization_engine']; + } + + /** + * The engine the organization may use: its own choice when allowed, otherwise + * the platform default, otherwise Valhalla. + */ + public static function engineFor(?string $engine, string $default, ?string $companyUuid = null): string + { + foreach ([$engine, $default] as $candidate) { + if (filled($candidate) && static::allows($candidate, $companyUuid)) { + return $candidate; + } + } + + return 'valhalla'; + } + + protected static function stored(): array + { + try { + $settings = Setting::lookup(static::SETTING_KEY, []); + } catch (\Throwable) { + $settings = []; + } + + return is_array($settings) ? $settings : []; + } +} diff --git a/server/src/Support/Routing/ValhallaClient.php b/server/src/Support/Routing/ValhallaClient.php new file mode 100644 index 000000000..bac1305b6 --- /dev/null +++ b/server/src/Support/Routing/ValhallaClient.php @@ -0,0 +1,115 @@ + Setting::lookupForCompany($companyUuid, 'valhalla', [])), + static::lookup(fn () => Setting::lookup('valhalla', [])), + ]; + }); + + foreach ([...$settings, ['api_host' => config('fleetops.valhalla.host'), 'api_key' => config('fleetops.valhalla.api_key')]] as $level) { + $host = rtrim(trim((string) data_get($level, 'api_host')), '/'); + if (static::isPrivateHost($host)) { + return ['host' => $host, 'api_key' => data_get($level, 'api_key') ?: null]; + } + } + + return null; + } + + public static function isConfigured(?string $companyUuid = null): bool + { + return static::connection($companyUuid) !== null; + } + + public static function isPrivateHost(?string $host): bool + { + $hostname = parse_url((string) $host, PHP_URL_HOST); + + return filled($hostname) && !in_array(strtolower($hostname), static::PUBLIC_HOSTS, true); + } + + /** + * Routes through the given points in order and returns Valhalla's `trip` + * (summary length in kilometres, time in seconds, and one entry per leg). + * + * @param Point[] $points + * + * @throws \RuntimeException when no server is configured, the server fails or it finds no route + */ + public static function route(array $points, array $options = [], ?string $companyUuid = null): array + { + $connection = static::connection($companyUuid); + if (!$connection) { + throw new \RuntimeException('No Valhalla server is configured.'); + } + + $request = Http::timeout((int) config('fleetops.valhalla.timeout', 5))->acceptJson(); + if ($connection['api_key']) { + $request = $request->withHeaders(['X-Api-Key' => $connection['api_key']]); + } + + $response = $request->post($connection['host'] . '/route', array_merge([ + 'locations' => array_map(fn (Point $point) => ['lat' => $point->getLat(), 'lon' => $point->getLng()], array_values($points)), + 'costing' => config('fleetops.valhalla.costing', 'auto'), + 'units' => 'kilometers', + 'directions_type' => 'none', + 'shape_format' => 'polyline6', + ], $options)); + + if (!$response->successful()) { + throw new \RuntimeException('Valhalla request failed with status ' . $response->status() . ': ' . data_get($response->json(), 'error', 'unknown error')); + } + + $trip = data_get($response->json(), 'trip'); + if (!is_array($trip) || (int) data_get($trip, 'status', -1) !== 0) { + throw new \RuntimeException('Valhalla returned no route.'); + } + + return $trip; + } + + protected static function lookup(callable $lookup): array + { + try { + $settings = $lookup(); + } catch (\Throwable) { + $settings = []; + } + + return is_array($settings) ? $settings : []; + } +} diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 19167d790..a6fca2b61 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -3,6 +3,9 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; +use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; +use Fleetbase\FleetOps\Support\Routing\ValhallaClient; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; use Fleetbase\Models\Setting; @@ -10,6 +13,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; @@ -22,6 +26,11 @@ class Utils extends FleetbaseUtils */ public const DRIVING_TIME_MULTIPLIER = 7.2; + /** + * Seconds a road distance stays cached for one origin and destination pair. + */ + public const DISTANCE_MATRIX_CACHE_TTL = 86400; + /** * Resolve the preferred transaction currency for a company. * @@ -791,16 +800,17 @@ public static function rawPointToPoint(string $data): Point * * This function attempts to find the driving distance and time from the origin to the destination * using various map APIs. It first attempts to resolve the origin and destination from various input types - * into geographical coordinates. Then, based on the provider specified in the options or defaulting to configured provider, - * it queries the respective service for the driving distance and time. The result is returned as a DistanceMatrix - * object with the distance in meters and the time in seconds. + * into geographical coordinates. The provider is `$options['provider']` when given, otherwise the one + * RoutingEngineResolver picks for `$options['company_uuid']` (or the session company). When a road + * provider fails or returns no route the straight-line estimate is returned instead, so the result is never + * zero for distinct points. The result is a DistanceMatrix with the distance in meters and the time in seconds. * * @param mixed $origin The origin of the journey. Can be a Place model, a string, or an array with latitude and longitude. * @param mixed $destination The destination of the journey. Similar to the origin, it accepts multiple input types. * * @return DistanceMatrix an object containing the distance and time of the journey * - * @throws \Throwable throws any exception encountered during the resolution of points or API calls + * @throws \Throwable throws any exception encountered during the resolution of points */ public static function getDrivingDistanceAndTime($origin, $destination, $options = []): DistanceMatrix { @@ -809,25 +819,50 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options $origins = $origin->getLat() . ',' . $origin->getLng(); $destinations = $destination->getLat() . ',' . $destination->getLng(); - // Create empty distance matric - $distanceMatrix = new DistanceMatrix(0, 0); - $provider = data_get($options, 'provider', config('fleetops.distance_matrix.provider')); - switch ($provider) { - case 'calculate': - $distanceMatrix = static::calculateDrivingDistanceAndTime($origins, $destinations); - break; + $companyUuid = data_get($options, 'company_uuid', session('company')); + $provider = data_get($options, 'provider') ?: RoutingEngineResolver::resolve($companyUuid); + $estimate = static::calculateDrivingDistanceAndTime($origins, $destinations); - case 'osrm': - $distanceMatrix = static::getDistanceMatrixFromOSRM($origins, $destinations); - break; + // Nothing to route between identical points, and `calculate` is the estimate itself + if ($provider === 'calculate' || $estimate->distance <= 0) { + return $estimate; + } - case 'google': - default: - $distanceMatrix = static::getDistanceMatrixFromGoogle($origins, $destinations); - break; + try { + // An explicitly requested provider is still subject to the admin routing policy + if (!RoutingProviderPolicy::allows($provider, $companyUuid)) { + throw new \RuntimeException('The ' . $provider . ' provider is switched off for this organization.'); + } + + // The public OSRM demo server's usage policy does not allow production traffic + if ($provider === 'osrm' && !RoutingEngineResolver::hasPrivateOsrmHost()) { + throw new \RuntimeException('No private OSRM host is configured.'); + } + + $distanceMatrix = match ($provider) { + 'valhalla' => static::getDistanceMatrixFromValhalla($origins, $destinations, $companyUuid), + 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), + 'google' => static::getDistanceMatrixFromGoogle($origins, $destinations), + default => throw new \InvalidArgumentException('Unsupported distance matrix provider: ' . $provider), + }; + } catch (\Throwable $e) { + $distanceMatrix = null; + $error = $e->getMessage(); } - return $distanceMatrix; + if ($distanceMatrix && $distanceMatrix->distance > 0 && $distanceMatrix->time > 0) { + return $distanceMatrix; + } + + // A timeout, exhausted quota or missing route must not leave the order at zero + Log::warning('Road distance unavailable, using the straight-line estimate instead.', [ + 'provider' => $provider, + 'origin' => $origins, + 'destination' => $destinations, + 'error' => $error ?? 'no route returned', + ]); + + return $estimate; } /** @@ -859,7 +894,7 @@ public static function distanceMatrix($origins = [], $destinations = [], $option // Create empty distance matric $distanceMatrix = new DistanceMatrix(0, 0); - $provider = data_get($options, 'provider', config('fleetops.distance_matrix.provider')); + $provider = data_get($options, 'provider') ?: config('fleetops.distance_matrix.provider') ?: 'calculate'; switch ($provider) { case 'calculate': $distanceMatrix = static::calculateDrivingDistanceAndTime($origins, $destinations); @@ -944,7 +979,7 @@ public static function getDistanceMatrixFromGoogle(string $origins, string $dest 'origins' => $origins, 'destinations' => $destinations, 'mode' => data_get($options, 'mode', 'driving'), - 'key' => env('GOOGLE_MAPS_API_KEY'), + 'key' => RoutingEngineResolver::googleMapsApiKey(), ] )->json(); @@ -954,8 +989,10 @@ public static function getDistanceMatrixFromGoogle(string $origins, string $dest // Create distance matrix $distanceMatrix = new DistanceMatrix($distance, $time); - // Cache distance matrix - Redis::set($cacheKey, json_encode($distanceMatrix)); + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } return $distanceMatrix; } @@ -1010,8 +1047,55 @@ public static function getDistanceMatrixFromOSRM(string $origins, string $destin // Create distance matrix $distanceMatrix = new DistanceMatrix($distance, $time); - // Cache distance matrix - Redis::set($cacheKey, json_encode($distanceMatrix)); + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } + + return $distanceMatrix; + } + + /** + * Retrieves the driving distance and time from the configured Valhalla server. + * + * Origins and destinations use the same `lat,lng` strings (separated by `|`) as the + * other providers and are routed in order. Results are cached per coordinate pair; + * a failed request or missing route throws so the caller can fall back. + * + * @param string $origins a string of origins in 'latitude,longitude' format, separated by `|` + * @param string $destinations a string of destinations in a similar format to origins + * @param string|null $companyUuid the organization whose Valhalla settings apply + * + * @return DistanceMatrix the distance in metres and time in seconds + */ + public static function getDistanceMatrixFromValhalla(string $origins, string $destinations, ?string $companyUuid = null): DistanceMatrix + { + $cacheKey = 'valhalla:' . md5($origins . '_' . $destinations); + $cachedResult = Redis::get($cacheKey); + if ($cachedResult) { + $json = json_decode($cachedResult); + + if (!empty($json->distance) && !empty($json->time)) { + return new DistanceMatrix($json->distance, $json->time); + } + } + + $points = array_map(function ($coordinate) { + [$latitude, $longitude] = array_map('floatval', explode(',', $coordinate)); + + return new Point($latitude, $longitude); + }, explode('|', $origins . '|' . $destinations)); + + $trip = ValhallaClient::route($points, [], $companyUuid); + $distance = (float) data_get($trip, 'summary.length', 0) * 1000; + $time = (float) data_get($trip, 'summary.time', 0); + + $distanceMatrix = new DistanceMatrix($distance, $time); + + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } return $distanceMatrix; } diff --git a/server/src/Tracking/Providers/OsrmTrackingProvider.php b/server/src/Tracking/Providers/OsrmTrackingProvider.php index 8a431da4c..097e2f6f3 100644 --- a/server/src/Tracking/Providers/OsrmTrackingProvider.php +++ b/server/src/Tracking/Providers/OsrmTrackingProvider.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Tracking\Providers; use Fleetbase\FleetOps\Support\OSRM; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface; use Fleetbase\FleetOps\Tracking\TrackingContext; use Fleetbase\FleetOps\Tracking\TrackingOptions; @@ -22,9 +23,13 @@ public function capabilities(): TrackingProviderCapabilities return new TrackingProviderCapabilities(perLegEta: true, routeGeometry: true); } + /** + * Only a private OSRM host is used; the public demo server's usage policy + * does not allow production traffic. + */ public function canTrack(TrackingContext $context): bool { - return $context->canRoute(); + return $context->canRoute() && RoutingEngineResolver::hasPrivateOsrmHost(); } public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult diff --git a/server/src/Tracking/Providers/ValhallaTrackingProvider.php b/server/src/Tracking/Providers/ValhallaTrackingProvider.php new file mode 100644 index 000000000..f3c2dfaba --- /dev/null +++ b/server/src/Tracking/Providers/ValhallaTrackingProvider.php @@ -0,0 +1,67 @@ +canRoute() && ValhallaClient::isConfigured($context->order->company_uuid); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $trip = ValhallaClient::route($context->routePoints(), [], $context->order->company_uuid); + + $legs = []; + $points = []; + foreach (data_get($trip, 'legs', []) as $index => $leg) { + $legs[] = [ + 'index' => $index, + 'distance_m' => (float) data_get($leg, 'summary.length', 0) * 1000, + 'duration_s' => (float) data_get($leg, 'summary.time', 0), + 'duration_in_traffic_s' => null, + 'provider' => $this->key(), + ]; + + // Stitch Valhalla's polyline6 leg shapes into one polyline5, the encoding OSRM and Google return + foreach (Polyline6::decode((string) data_get($leg, 'shape', '')) as $point) { + $points[] = [$point->getLng(), $point->getLat()]; + } + } + + $polyline = $points ? Polyline::encode($points) : null; + + return new TrackingProviderResult( + provider: $this->key(), + distanceMeters: (float) data_get($trip, 'summary.length', 0) * 1000, + durationSeconds: (float) data_get($trip, 'summary.time', 0), + durationInTrafficSeconds: null, + polyline: $polyline, + coordinates: $polyline ? Polyline::decode($polyline) : [], + legs: $legs, + warnings: ['no_live_traffic'], + confidence: 'medium', + raw: $trip + ); + } +} diff --git a/server/src/Tracking/TrackingOptions.php b/server/src/Tracking/TrackingOptions.php index 66cca97ac..5a4fc2c0a 100644 --- a/server/src/Tracking/TrackingOptions.php +++ b/server/src/Tracking/TrackingOptions.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Tracking; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\Models\Setting; use Illuminate\Support\Arr; @@ -27,21 +28,24 @@ public static function fromArray(array $options = [], ?string $companyUuid = nul $systemSettings = []; } + $companyUuid = $companyUuid ?? session('company'); $config = array_merge(config('fleetops.tracking', []), is_array($systemSettings) ? $systemSettings : []); $companySettings = []; try { - $companySettings = Setting::lookupForCompany($companyUuid ?? session('company'), 'tracking', []); + $companySettings = Setting::lookupForCompany($companyUuid, 'tracking', []); } catch (\Throwable) { $companySettings = []; } - $fallbacks = Arr::get($options, 'fallbacks', Arr::get($companySettings, 'fallbacks', Arr::get($config, 'fallbacks', []))); + // An explicitly requested provider (API callers) is still subject to the routing policy + $fallbacks = Arr::get($options, 'fallbacks', []); if (is_string($fallbacks)) { $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); } return new self( - provider: Arr::get($options, 'provider', Arr::get($companySettings, 'provider', Arr::get($config, 'provider'))), + // Tracking uses the organization's routing engine, the same one that measured the order + provider: Arr::get($options, 'provider') ?: RoutingEngineResolver::trackingProvider($companyUuid), fallbacks: Arr::wrap($fallbacks), trafficEnabled: (bool) Arr::get($options, 'traffic_enabled', Arr::get($companySettings, 'traffic_enabled', Arr::get($config, 'traffic_enabled', true))), cacheTtlSeconds: (int) Arr::get($options, 'cache_ttl_seconds', Arr::get($companySettings, 'cache_ttl_seconds', Arr::get($config, 'cache_ttl_seconds', 60))), diff --git a/server/src/Tracking/TrackingProviderManager.php b/server/src/Tracking/TrackingProviderManager.php index e0c6e4e48..9a137b619 100644 --- a/server/src/Tracking/TrackingProviderManager.php +++ b/server/src/Tracking/TrackingProviderManager.php @@ -2,8 +2,8 @@ namespace Fleetbase\FleetOps\Tracking; +use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -17,6 +17,12 @@ public function track(TrackingContext $context, TrackingOptions $options): Track { $warnings = []; foreach ($this->providerOrder($options) as $providerKey) { + // Providers switched off in Admin → Fleet-Ops → Routing are never called, even when requested + if (!RoutingProviderPolicy::allows($providerKey, $context->order->company_uuid)) { + $warnings[] = 'provider_disabled:' . $providerKey; + continue; + } + $provider = $this->registry->get($providerKey); if (!$provider instanceof TrackingProviderInterface) { $warnings[] = 'provider_not_registered:' . $providerKey; @@ -56,10 +62,8 @@ public function track(TrackingContext $context, TrackingOptions $options): Track 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]) + // The straight-line estimate always closes the chain so every order gets an answer + return collect([$options->provider, ...$options->fallbacks, 'calculated']) ->filter() ->map(fn ($key) => TrackingProviderRegistry::normalizeKey($key)) ->unique() diff --git a/server/src/routes.php b/server/src/routes.php index 312b69ecf..64e16665a 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -833,6 +833,8 @@ function ($router) { $router->post('tracking-settings', 'SettingController@saveTrackingSettings'); $router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings'); $router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings'); + $router->get('admin-routing-providers', 'SettingController@getAdminRoutingProviders'); + $router->post('admin-routing-providers', 'SettingController@saveAdminRoutingProviders'); $router->get('tracking-page-settings', 'SettingController@getTrackingPageSettings'); $router->post('tracking-page-settings', 'SettingController@saveTrackingPageSettings'); $router->post('tracking-page-settings/validate-slug', 'SettingController@validateTrackingPageSlug'); diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php index b9c808bbe..f6d14ca32 100644 --- a/server/tests/CommandContractsTest.php +++ b/server/tests/CommandContractsTest.php @@ -1716,7 +1716,7 @@ public function getDropoffOrLastWaypoint(): object ]); expect($command->handle())->toBe(Command::SUCCESS) - ->and($command->messages)->toContain(['info', 'Using provider: ']) + ->and($command->messages)->toContain(['info', 'Using provider: company routing settings']) ->and($command->messages)->toContain(['info', 'Looking back: last 2 day(s)']) ->and($command->messages)->toContain(['info', 'Chunk size: 250']) ->and($command->messages)->toContain(['warn', 'Another run appears to be in progress (lock active). Use --no-lock to bypass.']) @@ -2489,6 +2489,26 @@ public function error($string, $verbosity = null) ->and(collect($command->messages)->pluck(0))->toContain('error'); }); +test('track order distance command lets each order resolve its company provider without --provider', function () { + Carbon::setTestNow(Carbon::parse('2026-07-26 12:00:00')); + + $order = new FleetOpsTrackOrderFake(201); + $command = new FleetOpsTrackOrderDistanceAndTimeProbe([ + 'provider' => null, + 'days' => 2, + 'chunk' => 100, + 'dry' => false, + 'no-lock' => true, + ], [$order]); + + expect($command->handle())->toBe(Command::SUCCESS) + ->and($command->messages)->toContain(['info', 'Using provider: company routing settings']) + ->and($command->messages)->toContain(['info', 'Updated 1/1 orders.']) + ->and($order->distanceCalls)->toBe([[]]); + + Carbon::setTestNow(); +}); + test('track order distance command exits early when no qualifying orders exist', function () { Carbon::setTestNow(Carbon::parse('2026-07-26 12:00:00')); diff --git a/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php b/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php index ecaafd307..67e8eb580 100644 --- a/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php +++ b/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php @@ -163,9 +163,12 @@ public function __call($method, $arguments) $probe->callHelper('dispatchFinalizeApiOrderCreation', 'order-1', 'sq-1', true); expect(DispatchRecorder::$dispatched)->toHaveCount(1); - // The routing engine resolves configuration through phpdotenv, which is - // unavailable in the harness — the delegation body still executes. - expect(fn () => $probe->callHelper('drivingDistanceAndTime', [1.30, 103.80], [1.35, 103.85]))->toThrow(Error::class); + // Without a configured road provider the delegation returns the straight-line estimate + config(['fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => null]); + $matrix = $probe->callHelper('drivingDistanceAndTime', [1.30, 103.80], [1.35, 103.85]); + expect($matrix)->toBeInstanceOf(Fleetbase\FleetOps\Support\DistanceMatrix::class) + ->and($matrix->distance)->toBeGreaterThan(7000)->toBeLessThan(8000) + ->and($matrix->time)->toBe(round($matrix->distance / 100) * Fleetbase\FleetOps\Support\Utils::DRIVING_TIME_MULTIPLIER); }); test('proof file storage and settings helpers persist and resolve', function () { diff --git a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php index ccd6651ea..c1a4006ed 100644 --- a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +++ b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php @@ -116,7 +116,7 @@ public function __call($method, $arguments) expect($helper('googleMapsApiKey'))->toBe('maps-key-1'); }); -test('setting helpers read notification and tracking registries', function () { +test('setting helpers read the notification registries', function () { fleetopsSettingHelpersBoot(); $controller = new SettingController(); @@ -128,8 +128,7 @@ public function __call($method, $arguments) }; expect($helper('notificationNotifiables'))->toBeArray() - ->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray() - ->and($helper('trackingProviders'))->toBeArray(); + ->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray(); }); test('history policy reconciliation clamps every organization and preserves inherited and internal settings', function () { diff --git a/server/tests/SettingControllerContractsTest.php b/server/tests/SettingControllerContractsTest.php index 82222d759..f25825347 100644 --- a/server/tests/SettingControllerContractsTest.php +++ b/server/tests/SettingControllerContractsTest.php @@ -1,7 +1,9 @@ policyCompany; + } + + protected function saveRoutingProviderPolicy(array $input): array + { + $this->savedRoutingPolicies[] = $input; + + return RoutingProviderPolicy::normalize($input); + } protected function configureSetting(string $key, mixed $value): mixed { @@ -86,11 +101,6 @@ protected function notificationsByPackage(string $package): array return $this->notifications[$package] ?? []; } - protected function trackingProviders(): array - { - return $this->providers; - } - protected function googleMapsApiKey(): string { return $this->googleMapsApiKey; @@ -136,27 +146,6 @@ protected function dispatchTelematicsPrune(): void } } -class FleetOpsTrackingProviderOptionFake -{ - public function __construct(private array $capabilities) - { - } - - public function capabilities(): object - { - return new class($this->capabilities) { - public function __construct(private array $capabilities) - { - } - - public function toArray(): array - { - return $this->capabilities; - } - }; - } -} - function fleetopsJsonPayload(mixed $response): array { return $response->getData(true); @@ -345,6 +334,19 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) $notificationSave = fleetopsJsonPayload($controller->saveNotificationSettings(new Request([ 'notificationSettings' => ['fleet-ops' => ['sms' => false]], ]))); + + // Google is billed per request and stays switched off until the administrator enables it + Cache::forget(RoutingProviderPolicy::CACHE_KEY); + $refused = $controller->saveRoutingSettings(new Request([ + 'display_engine' => 'google', + 'optimization_engine' => 'vroom', + ])); + expect($refused->getStatusCode())->toBe(422) + ->and(json_encode(fleetopsJsonPayload($refused)))->toContain('The google provider has been switched off by the administrator.') + ->and($controller->configuredCompany)->not->toHaveKey('routing'); + + config()->set('fleetops.routing.google_enabled', true); + Cache::forget(RoutingProviderPolicy::CACHE_KEY); $routingSave = fleetopsJsonPayload($controller->saveRoutingSettings(new Request([ 'display_engine' => 'google', 'optimization_engine' => 'vroom', @@ -379,29 +381,79 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) expect(fleetopsJsonPayload($controller->getRoutingSettings()))->toBe([ 'routing_display_engine' => 'mapbox', + 'allowed_providers' => ['google', 'valhalla', 'osrm', 'vroom'], 'router' => 'mapbox', 'display_engine' => 'mapbox', 'optimization_engine' => 'mapbox', 'routing_optimization_engine' => 'mapbox', 'unit' => 'km', ]); + + // Switched off again, a saved Google choice is replaced by the platform defaults + config()->set('fleetops.routing.google_enabled', false); + Cache::forget(RoutingProviderPolicy::CACHE_KEY); + $controller->lookupCompanySettings['routing'] = ['display_engine' => 'google', 'optimization_engine' => 'google', 'unit' => 'mi']; + expect(fleetopsJsonPayload($controller->getRoutingSettings()))->toMatchArray([ + 'allowed_providers' => ['valhalla', 'osrm', 'vroom'], + 'display_engine' => 'valhalla', + 'optimization_engine' => 'valhalla', + 'unit' => 'mi', + ]); + + // An organization that never chose an engine gets the platform defaults + $controller->lookupCompanySettings = []; + expect(fleetopsJsonPayload($controller->getRoutingSettings()))->toMatchArray([ + 'display_engine' => 'valhalla', + 'optimization_engine' => 'valhalla', + 'unit' => 'km', + ]); }); -test('setting controller normalizes tracking settings and provider options', function () { +test('setting controller reads and saves the admin routing provider policy', function () { + Cache::forget(RoutingProviderPolicy::CACHE_KEY); + $controller = new FleetOpsSettingControllerProbe(); + + $policy = fleetopsJsonPayload($controller->getAdminRoutingProviders()); + expect($policy['providers'])->toBe(['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true]) + ->and($policy['defaults'])->toBe(['display_engine' => 'valhalla', 'optimization_engine' => 'valhalla']) + ->and($policy['server_routing'])->toBe('road') + ->and($policy['exceptions'])->toBe([]) + ->and($policy['available_providers'])->toBe([ + ['key' => 'google', 'label' => 'Google'], + ['key' => 'valhalla', 'label' => 'Valhalla'], + ['key' => 'osrm', 'label' => 'OSRM'], + ['key' => 'vroom', 'label' => 'VROOM'], + ]); + + $controller->saveAdminRoutingProviders(new Request([ + 'providers' => ['google' => true], + 'defaults' => ['display_engine' => 'valhalla'], + 'server_routing' => 'straight_line', + 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], + 'ignored' => 'value', + ])); + + expect($controller->savedRoutingPolicies)->toBe([[ + 'providers' => ['google' => true], + 'defaults' => ['display_engine' => 'valhalla'], + 'server_routing' => 'straight_line', + 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], + ]]); +}); + +test('setting controller saves tracking behaviour while the provider follows the routing engine', function () { config()->set('fleetops.tracking', [ - 'provider' => 'osrm', - 'fallbacks' => ['calculated'], 'traffic_enabled' => false, 'cache_ttl_seconds' => 30, 'route_cache_ttl_seconds' => 120, 'default_vehicle_speed_kph' => 25, ]); + config()->set('fleetops.osrm.host', null); + config()->set('fleetops.valhalla.host', null); + Cache::forget(RoutingProviderPolicy::CACHE_KEY); - $controller = new FleetOpsSettingControllerProbe(); - $controller->providers = [ - 'osrm' => new FleetOpsTrackingProviderOptionFake(['routes']), - 'google_routes' => new FleetOpsTrackingProviderOptionFake(['traffic', 'eta']), - ]; + $controller = new FleetOpsSettingControllerProbe(); + // Saved before tracking followed the routing engine $controller->settings['fleet-ops.tracking-settings'] = [ 'provider' => 'google_routes', 'fallbacks' => 'osrm, calculated', @@ -413,8 +465,8 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ]; $saved = fleetopsJsonPayload($controller->saveTrackingSettings(new Request([ - 'provider' => 'osrm', - 'fallbacks' => 'google_routes, calculated', + 'provider' => 'google_routes', + 'fallbacks' => 'osrm, calculated', 'traffic_enabled' => '1', 'cache_ttl_seconds' => '75', 'route_cache_ttl_seconds' => '900', @@ -425,10 +477,10 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ], ]))); + // A provider in the request is ignored rather than refused expect($saved)->toMatchArray(['status' => 'ok']) + ->and($controller->configuredCompany['tracking'])->not->toHaveKeys(['provider', 'fallbacks']) ->and($controller->configuredCompany['tracking'])->toMatchArray([ - 'provider' => 'osrm', - 'fallbacks' => ['google_routes', 'calculated'], 'traffic_enabled' => true, 'cache_ttl_seconds' => 75, 'route_cache_ttl_seconds' => 900, @@ -453,35 +505,22 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ], ]; + // The response names the routing engine tracking will use; no routing server is configured here $tracking = fleetopsJsonPayload($controller->getTrackingSettings()); - expect($tracking['provider'])->toBe('google_routes') - ->and($tracking['alerts']['prolonged_stoppages']['duration_threshold_minutes'])->toBe(55) - ->and($tracking['providers'])->toBe([ - [ - 'key' => 'osrm', - 'name' => 'OSRM', - 'value' => 'osrm', - 'label' => 'OSRM', - 'capabilities' => ['routes'], - ], - [ - 'key' => 'google_routes', - 'name' => 'Google Routes', - 'value' => 'google_routes', - 'label' => 'Google Routes', - 'capabilities' => ['traffic', 'eta'], - ], - ]); + expect($tracking)->not->toHaveKeys(['provider', 'fallbacks', 'providers']) + ->and($tracking['routing_engine'])->toBe('calculate') + ->and($tracking['alerts']['prolonged_stoppages']['duration_threshold_minutes'])->toBe(55); $adminSaved = fleetopsJsonPayload($controller->saveAdminTrackingSettings(new Request([ - 'provider' => 'calculated', - 'fallbacks' => 'osrm', + 'provider' => 'calculated', + 'fallbacks' => 'osrm', + 'cache_ttl_seconds' => '20', ]))); - expect($controller->configured['fleet-ops.tracking-settings']['provider'])->toBe('calculated') - ->and($controller->configured['fleet-ops.tracking-settings']['fallbacks'])->toBe(['osrm']) - ->and($adminSaved['providers'])->toHaveCount(2) - ->and(fleetopsJsonPayload($controller->getAdminTrackingSettings())['providers'])->toHaveCount(2); + expect($controller->configured['fleet-ops.tracking-settings'])->not->toHaveKeys(['provider', 'fallbacks']) + ->and($controller->configured['fleet-ops.tracking-settings']['cache_ttl_seconds'])->toBe(20) + ->and($adminSaved)->not->toHaveKeys(['provider', 'fallbacks', 'providers']) + ->and($adminSaved['cache_ttl_seconds'])->toBe(20); }); test('setting controller handles map scheduling orchestrator and card field settings', function () { @@ -877,13 +916,13 @@ public function prune(): void $this->dispatchTelematicsPrune(); } }; - $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot(); + $originalCache = Cache::getFacadeRoot(); $originalCacheConfig = config('cache', []); $dispatcherContract = Illuminate\Contracts\Bus\Dispatcher::class; $originalDispatcher = app()->bound($dispatcherContract) ? app($dispatcherContract) : null; try { config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]); - Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\CacheManager(app())); + Cache::swap(new Illuminate\Cache\CacheManager(app())); $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher { public array $jobs = []; public bool $fail = true; @@ -908,7 +947,7 @@ public function dispatch($command) ->and($dispatcher->jobs[0])->toBeInstanceOf(Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs::class) ->and($dispatcher->jobs[0]->afterCompanyId)->toBe(0); } finally { - Illuminate\Support\Facades\Cache::swap($originalCache); + Cache::swap($originalCache); config(['cache' => $originalCacheConfig]); if ($originalDispatcher) { app()->instance($dispatcherContract, $originalDispatcher); diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php index d79c7c6dc..ba98e3e0f 100644 --- a/server/tests/TrackingIntelligenceTest.php +++ b/server/tests/TrackingIntelligenceTest.php @@ -4,9 +4,12 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Support\Encoding\Polyline; +use Fleetbase\FleetOps\Support\Encoding\Polyline6; use Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider; use Fleetbase\FleetOps\Tracking\Providers\GoogleRoutesTrackingProvider; use Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider; +use Fleetbase\FleetOps\Tracking\Providers\ValhallaTrackingProvider; use Fleetbase\FleetOps\Tracking\Support\FakeTrackingProvider; use Fleetbase\FleetOps\Tracking\TrackingContext; use Fleetbase\FleetOps\Tracking\TrackingContextBuilder; @@ -301,6 +304,13 @@ function googleRoutesTrackingContext(): TrackingContext ], ]; + // The public demo server never counts; only a private OSRM host can track + app('config')->set('fleetops.osrm.host', 'https://router.project-osrm.org'); + expect($provider->canTrack(osrmTrackingContext()))->toBeFalse(); + app('config')->set('fleetops.osrm.host', 'https://osrm.fleetbase.test'); + expect($provider->canTrack(osrmTrackingContext()))->toBeTrue(); + app('config')->set('fleetops.osrm.host', null); + $result = $provider->track(osrmTrackingContext(), new TrackingOptions()); expect($provider->key())->toBe('osrm') @@ -310,7 +320,6 @@ function googleRoutesTrackingContext(): TrackingContext 'map_matching' => false, 'route_geometry' => true, ]) - ->and($provider->canTrack(osrmTrackingContext()))->toBeTrue() ->and($result->provider)->toBe('osrm') ->and($result->distanceMeters)->toBe(1234.5) ->and($result->durationSeconds)->toBe(321.0) @@ -857,3 +866,100 @@ function googleRoutesTrackingContext(): TrackingContext expect(fn () => $provider->track($context, new TrackingOptions())) ->toThrow(RuntimeException::class, 'Google Routes returned no route.'); }); + +test('tracking skips providers the administrator switched off, even when requested', function () { + config()->set('fleetops.routing.google_enabled', false); + Illuminate\Support\Facades\Cache::forget(Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::CACHE_KEY); + + $registry = new TrackingProviderRegistry(); + $registry->register(new FakeTrackingProvider('google_routes')); + $registry->register(new FakeTrackingProvider('calculated')); + $manager = new TrackingProviderManager($registry); + $context = osrmTrackingContext(); + + $result = $manager->track($context, new TrackingOptions(provider: 'google_routes', fallbacks: ['calculated'])); + expect($result->provider)->toBe('calculated') + ->and($result->warnings)->toContain('provider_disabled:google_routes') + ->and($result->warnings)->toContain('fallback_used'); + + try { + config()->set('fleetops.routing.google_enabled', true); + Illuminate\Support\Facades\Cache::forget(Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::CACHE_KEY); + expect($manager->track($context, new TrackingOptions(provider: 'google_routes', fallbacks: ['calculated']))->provider)->toBe('google_routes'); + } finally { + config()->set('fleetops.routing.google_enabled', false); + Illuminate\Support\Facades\Cache::forget(Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::CACHE_KEY); + } +}); + +test('valhalla provider tracks through the configured server and stitches leg shapes', function () { + resetTrackingHttp(); + $provider = new ValhallaTrackingProvider(); + $context = osrmTrackingContext(); + + try { + // Unconfigured or public community servers never route production traffic + foreach ([null, 'https://valhalla1.openstreetmap.de'] as $host) { + app('config')->set('fleetops.valhalla.host', $host); + expect($provider->canTrack($context))->toBeFalse(); + } + + app('config')->set('fleetops.valhalla.host', 'https://valhalla.routing.fleetbase.test/'); + app('config')->set('fleetops.valhalla.api_key', 'test-valhalla-key'); + expect($provider->key())->toBe('valhalla') + ->and($provider->capabilities()->toArray())->toBe([ + 'traffic' => false, + 'per_leg_eta' => true, + 'map_matching' => false, + 'route_geometry' => true, + ]) + ->and($provider->canTrack($context))->toBeTrue(); + + $firstLeg = [[1.30, 103.80], [1.305, 103.805]]; + $secondLeg = [[1.305, 103.805], [1.31, 103.81]]; + $sent = null; + Http::fake(function ($request) use (&$sent, $firstLeg, $secondLeg) { + $sent = $request; + + return Http::response(['trip' => [ + 'status' => 0, + 'summary' => ['length' => 1.85, 'time' => 412.5], + 'legs' => [ + ['summary' => ['length' => 0.8, 'time' => 190.0], 'shape' => Polyline6::encode($firstLeg)], + ['summary' => ['length' => 1.05, 'time' => 222.5], 'shape' => Polyline6::encode($secondLeg)], + ], + ]]); + }); + + $result = $provider->track($context, new TrackingOptions()); + + expect((string) $sent->url())->toBe('https://valhalla.routing.fleetbase.test/route') + ->and($sent->header('X-Api-Key'))->toBe(['test-valhalla-key']) + ->and($sent->data()['locations'])->toBe([['lat' => 1.30, 'lon' => 103.80], ['lat' => 1.31, 'lon' => 103.81]]) + ->and($sent->data()['shape_format'])->toBe('polyline6') + ->and($result->provider)->toBe('valhalla') + ->and($result->distanceMeters)->toBe(1850.0) + ->and($result->durationSeconds)->toBe(412.5) + ->and($result->durationInTrafficSeconds)->toBeNull() + ->and($result->polyline)->toBe(Polyline::encode([...$firstLeg, ...$secondLeg])) + ->and($result->coordinates)->toHaveCount(4) + ->and($result->coordinates[3]->getLat())->toBe(103.81) + ->and($result->coordinates[3]->getLng())->toBe(1.31) + ->and($result->legs)->toBe([ + ['index' => 0, 'distance_m' => 800.0, 'duration_s' => 190.0, 'duration_in_traffic_s' => null, 'provider' => 'valhalla'], + ['index' => 1, 'distance_m' => 1050.0, 'duration_s' => 222.5, 'duration_in_traffic_s' => null, 'provider' => 'valhalla'], + ]) + ->and($result->warnings)->toBe(['no_live_traffic']); + + // A trip without leg shapes still reports distance and time + resetTrackingHttp(); + Http::fake(['*' => Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 2.0, 'time' => 300.0], 'legs' => []]])]); + $bare = $provider->track($context, new TrackingOptions()); + expect($bare->polyline)->toBeNull() + ->and($bare->coordinates)->toBe([]) + ->and($bare->distanceMeters)->toBe(2000.0); + } finally { + app('config')->set('fleetops.valhalla.host', null); + app('config')->set('fleetops.valhalla.api_key', null); + } +}); diff --git a/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php b/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php index f78a13420..a039e08d7 100644 --- a/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php +++ b/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php @@ -276,6 +276,65 @@ function fleetopsOrderPayloadFetch(SQLiteConnection $connection, string $uuid): expect($order->getCurrentOriginPosition()?->getLat())->toBe(-37.70); }); +test('accurate distance uses the company routing engine and keeps the estimate when it fails', function () { + $connection = fleetopsOrderPayloadBoot(); + $connection->getSchemaBuilder()->create('settings', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('key')->nullable(); + $blueprint->text('value')->nullable(); + $blueprint->timestamps(); + }); + $connection->table('settings')->insert(['key' => 'company.company-1.routing', 'value' => json_encode(['display_engine' => 'osrm'])]); + // Sunshine -> Melton, Victoria: ~25 km straight line, ~31 km by road + $connection->table('places')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'place_vicpickup', 'company_uuid' => 'company-1', 'location' => fleetopsOrderPayloadWkb(-37.78, 144.83)], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'place_vicdropoff', 'company_uuid' => 'company-1', 'location' => fleetopsOrderPayloadWkb(-37.68, 144.58)], + ]); + $connection->table('payloads')->insert(['uuid' => 'payload-1', 'company_uuid' => 'company-1', 'pickup_uuid' => '11111111-1111-4111-8111-111111111111', 'dropoff_uuid' => '22222222-2222-4222-8222-222222222222']); + $connection->table('orders')->insert(['uuid' => 'order-1', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-1']); + + $redis = new class { + public function get($key) + { + return null; + } + + public function set($key, $value, ...$options) + { + return true; + } + }; + app()->instance('redis', $redis); + Illuminate\Support\Facades\Redis::clearResolvedInstance('redis'); + // Queued listeners and the estimations command run without a session company + session(['company' => null]); + config(['fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => 'https://osrm.fleetbase.test']); + + try { + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]])]); + + Order::query()->where('uuid', 'order-1')->first()->setDistanceAndTime(); + expect((float) $connection->table('orders')->value('distance'))->toBe(31520.0) + ->and((float) $connection->table('orders')->value('time'))->toBe(1610.0); + + // The road provider failing leaves the straight-line estimate, never zero + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['code' => 'NoRoute', 'routes' => []])]); + + Order::query()->where('uuid', 'order-1')->first()->setDistanceAndTime(); + $distance = (float) $connection->table('orders')->value('distance'); + expect($distance)->toBeGreaterThan(20000)->toBeLessThan(30000) + ->and((float) $connection->table('orders')->value('time'))->toBe(round($distance / 100) * 7.2); + } finally { + config(['fleetops.osrm.host' => null]); + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + } +}); + test('distance setters leave the order untouched when an endpoint is an ungeocoded placeholder', function () { $connection = fleetopsOrderPayloadBoot(); $connection->table('places')->insert([ diff --git a/server/tests/Unit/Support/RoutingEngineResolverTest.php b/server/tests/Unit/Support/RoutingEngineResolverTest.php new file mode 100644 index 000000000..b31ed1473 --- /dev/null +++ b/server/tests/Unit/Support/RoutingEngineResolverTest.php @@ -0,0 +1,650 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + DB::clearResolvedInstance('db'); + + $connection->getSchemaBuilder()->create('settings', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('key')->nullable(); + $blueprint->text('value')->nullable(); + $blueprint->timestamps(); + }); + + config([ + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => 'https://osrm.fleetbase.test', + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, + 'fleetops.routing.google_enabled' => false, + 'fleetops.routing.display_engine' => 'osrm', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'road', + 'services.google_maps.api_key' => null, + ]); + Cache::swap(new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + + $redis = new class { + public array $store = []; + public array $sets = []; + + public function get($key) + { + return $this->store[$key] ?? null; + } + + public function set($key, $value, ...$options) + { + $this->store[$key] = $value; + $this->sets[] = [$key, $value, ...$options]; + + return true; + } + }; + app()->instance('redis', $redis); + Redis::clearResolvedInstance('redis'); + $GLOBALS['fleetopsDistanceProviderRedis'] = $redis; + + $log = new class { + public array $warnings = []; + + public function warning($message, array $context = []) + { + $this->warnings[] = [$message, $context]; + } + + public function __call($method, $arguments) + { + return null; + } + }; + app()->instance('log', $log); + Log::clearResolvedInstance('log'); + $GLOBALS['fleetopsDistanceProviderLog'] = $log; + + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + + return $connection; +} + +function fleetopsDistanceProviderRouting(SQLiteConnection $connection, string $companyUuid, array $routing): void +{ + $connection->table('settings')->insert(['key' => 'company.' . $companyUuid . '.routing', 'value' => json_encode($routing)]); +} + +function fleetopsDistanceProviderPolicy(array $policy): void +{ + DB::table('settings')->where('key', RoutingProviderPolicy::SETTING_KEY)->delete(); + RoutingProviderPolicy::save($policy); +} + +afterEach(function () { + config([ + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => null, + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, + 'fleetops.routing.google_enabled' => false, + 'fleetops.routing.display_engine' => 'osrm', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'road', + 'services.google_maps.api_key' => null, + ]); + Cache::flush(); + app()->forgetInstance('log'); + Log::clearResolvedInstance('log'); + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + session(['company' => null]); +}); + +test('environment values only seed the admin policy, which wins once saved', function () { + $connection = fleetopsDistanceProviderBoot(); + config([ + 'fleetops.routing.display_engine' => 'valhalla', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'straight_line', + ]); + Cache::flush(); + + // Unsaved: the environment provides the defaults, and optimization follows the routing engine + expect(RoutingProviderPolicy::settings()['defaults'])->toBe(['display_engine' => 'valhalla', 'optimization_engine' => 'valhalla']) + ->and(RoutingProviderPolicy::usesStraightLine())->toBeTrue() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('calculate'); + + // Saved: the admin choice wins over the environment + fleetopsDistanceProviderPolicy(['server_routing' => 'road', 'defaults' => ['display_engine' => 'osrm']]); + expect(RoutingProviderPolicy::usesStraightLine())->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); + + // The old DISTANCE_MATRIX_PROVIDER no longer overrides anything for orders + config(['fleetops.distance_matrix.provider' => 'google']); + expect(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); + + // An unknown mode is treated as road routing + expect(RoutingProviderPolicy::normalize(['server_routing' => 'teleport'])['server_routing'])->toBe('road'); +}); + +test('the administrator can switch server-side routing to straight-line in one place', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]]); + }); + + expect(RoutingEngineResolver::resolve('company-1'))->toBe('osrm') + ->and(RoutingEngineResolver::trackingProvider('company-1'))->toBe('osrm'); + + fleetopsDistanceProviderPolicy(['server_routing' => 'straight_line']); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect(RoutingEngineResolver::resolve('company-1'))->toBe('calculate') + ->and(RoutingEngineResolver::trackingProvider('company-1'))->toBe('calculated') + ->and(Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1'])->distance)->toBe($estimate->distance) + ->and($requests)->toBe(0); +}); + +test('the company routing engine is used when the server is configured for it', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['router' => 'osrm', 'display_engine' => 'osrm']); + fleetopsDistanceProviderRouting($connection, 'company-valhalla', ['display_engine' => 'valhalla']); + fleetopsDistanceProviderRouting($connection, 'company-legacy', ['router' => 'valhalla']); + fleetopsDistanceProviderRouting($connection, 'company-vroom', ['display_engine' => 'vroom']); + + // A company that never saved routing settings uses OSRM, as the map does + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve('company-unsaved'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve(null))->toBe('osrm'); + + // Valhalla needs a configured server; until then the company falls through to private OSRM + expect(RoutingEngineResolver::resolve('company-valhalla'))->toBe('osrm'); + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + expect(RoutingEngineResolver::resolve('company-valhalla'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve('company-legacy'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm'); + + // Engines the server cannot call are skipped for the platform default + expect(RoutingEngineResolver::companyEngine('company-vroom'))->toBe('vroom') + ->and(RoutingEngineResolver::isAvailable('vroom'))->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-vroom'))->toBe('osrm') + ->and(RoutingEngineResolver::companyEngine('company-unsaved'))->toBeNull(); +}); + +test('google needs the administrator to allow it, globally or for the organization', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-google', ['display_engine' => 'google']); + fleetopsDistanceProviderRouting($connection, 'company-other', ['display_engine' => 'google']); + config(['services.google_maps.api_key' => 'test-maps-key', 'fleetops.osrm.host' => 'https://router.project-osrm.org']); + + expect(RoutingEngineResolver::companyEngine('company-google'))->toBe('google') + ->and(RoutingEngineResolver::isAvailable('google', 'company-google'))->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-google'))->toBe('calculate') + ->and(RoutingEngineResolver::googleMapsApiKey())->toBe('test-maps-key'); + + // With the platform's own Valhalla configured, the organization gets road distance from it instead + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('valhalla'); + + // An exception lets one organization use Google while the others stay on Valhalla + fleetopsDistanceProviderPolicy(['exceptions' => [['company_uuid' => 'company-google', 'company_name' => 'Acme', 'providers' => ['google']]]]); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('google') + ->and(RoutingEngineResolver::resolve('company-other'))->toBe('valhalla'); + + // Without a Maps key Google cannot be used even when allowed + config(['services.google_maps.api_key' => null]); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('valhalla'); +}); + +test('the platform valhalla server serves companies whose engine is not available', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['display_engine' => 'osrm']); + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('calculate'); + + // Community servers do not count as configured + config(['fleetops.valhalla.host' => 'https://VALHALLA1.openstreetmap.de']); + expect(ValhallaClient::isConfigured())->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('calculate'); + + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test/']); + expect(ValhallaClient::isConfigured())->toBeTrue() + ->and(ValhallaClient::connection())->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => null]) + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve(null))->toBe('valhalla'); +}); + +test('order distance from valhalla converts kilometres, sends the key and caches the result', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + config([ + 'fleetops.osrm.host' => 'https://router.project-osrm.org', + 'fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test', + 'fleetops.valhalla.api_key' => 'test-valhalla-key', + ]); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + + $requests = []; + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; + + return Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 31.52, 'time' => 1610.0], 'legs' => []]]); + }); + + // Sunshine -> Melton, Victoria + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + + expect($matrix->distance)->toBe(31520.0) + ->and($matrix->time)->toBe(1610.0) + ->and($requests)->toHaveCount(1) + ->and((string) $requests[0]->url())->toBe('https://valhalla.routing.fleetbase.test/route') + ->and($requests[0]->header('X-Api-Key'))->toBe(['test-valhalla-key']) + ->and($requests[0]->data())->toBe([ + 'locations' => [['lat' => -37.78, 'lon' => 144.83], ['lat' => -37.68, 'lon' => 144.58]], + 'costing' => 'auto', + 'units' => 'kilometers', + 'directions_type' => 'none', + 'shape_format' => 'polyline6', + ]) + ->and($redis->sets)->toHaveCount(1) + ->and($redis->sets[0][0])->toStartWith('valhalla:') + ->and(array_slice($redis->sets[0], 2))->toBe(['EX', Utils::DISTANCE_MATRIX_CACHE_TTL]); + + // The cached result answers the same pair without another request + $cached = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + expect($cached->distance)->toBe(31520.0) + ->and($requests)->toHaveCount(1); +}); + +test('valhalla failures fall back to the straight-line estimate', function () { + fleetopsDistanceProviderBoot(); + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + $responses = [ + Http::response(['error_code' => 442, 'error' => 'No path could be found for input'], 400), + Http::response(['trip' => ['status' => 442]]), + Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 0, 'time' => 0]]]), + ]; + $headers = []; + Http::fake(function ($request) use (&$responses, &$headers) { + $headers[] = $request->header('X-Api-Key'); + + return array_shift($responses); + }); + + foreach (range(1, 3) as $attempt) { + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'valhalla']); + expect($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time); + } + + expect($log->warnings)->toHaveCount(3) + ->and($log->warnings[0][1]['provider'])->toBe('valhalla') + ->and($log->warnings[0][1]['error'])->toBe('Valhalla request failed with status 400: No path could be found for input') + ->and($log->warnings[1][1]['error'])->toBe('Valhalla returned no route.') + ->and($log->warnings[2][1]['error'])->toBe('no route returned') + // No key configured, so none is sent + ->and($headers[0])->toBe([]) + ->and($redis->sets)->toBe([]); +}); + +test('an unsupported provider name falls back to the straight-line estimate', function () { + fleetopsDistanceProviderBoot(); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(['*' => Http::response([], 500)]); + + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'valhala']); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($log->warnings[0][1]['error'])->toBe('Unsupported distance matrix provider: valhala'); +}); + +test('company OSRM selection never sends server traffic to the public demo server', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + + foreach (['https://router.project-osrm.org', 'https://ROUTER.project-osrm.org/', null, 'not a url'] as $host) { + config(['fleetops.osrm.host' => $host]); + expect(RoutingEngineResolver::hasPrivateOsrmHost())->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('calculate'); + } + + // Only an explicit per-call provider (the estimations command's --provider) can reach the demo server + expect(RoutingEngineResolver::trackingProvider('company-1'))->toBe('calculated'); +}); + +test('an unreadable settings store falls back to the default routing engine', function () { + $connection = fleetopsDistanceProviderBoot(); + $connection->getSchemaBuilder()->drop('settings'); + + expect(RoutingEngineResolver::companyEngine('company-1'))->toBeNull() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); +}); + +test('order distance follows the company routing engine and caches the road result', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + + $requests = []; + Http::fake(function ($request) use (&$requests) { + $requests[] = (string) $request->url(); + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.4, 'duration' => 1610.2]]]); + }); + + // Sunshine -> Melton, Victoria + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + + expect($matrix->distance)->toBe(31520.4) + ->and($matrix->time)->toBe(1610.2) + ->and($requests)->toHaveCount(1) + ->and($requests[0])->toStartWith('https://osrm.fleetbase.test/route/v1/driving/144.83,-37.78;144.58,-37.68') + ->and($redis->sets)->toHaveCount(1) + ->and(array_slice($redis->sets[0], 2))->toBe(['EX', Utils::DISTANCE_MATRIX_CACHE_TTL]) + ->and($GLOBALS['fleetopsDistanceProviderLog']->warnings)->toBe([]); + + // Without a company option the session company decides + session(['company' => 'company-1']); + $fromSession = Utils::getDrivingDistanceAndTime([-37.70, 144.70], [-37.68, 144.58]); + expect($fromSession->distance)->toBe(31520.4) + ->and($requests)->toHaveCount(2); +}); + +test('a road provider without a route falls back to the straight-line estimate and logs it', function () { + fleetopsDistanceProviderBoot(); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(['*' => Http::response(['code' => 'NoRoute', 'routes' => []])]); + + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'osrm']); + + expect($estimate->distance)->toBeGreaterThan(20000) + ->and($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time) + // Empty results are not cached, so the next run asks the provider again + ->and($redis->sets)->toBe([]) + ->and($log->warnings)->toHaveCount(1) + ->and($log->warnings[0][0])->toBe('Road distance unavailable, using the straight-line estimate instead.') + ->and($log->warnings[0][1]['provider'])->toBe('osrm') + ->and($log->warnings[0][1]['error'])->toBe('no route returned'); +}); + +test('a failing google request falls back to the straight-line estimate and logs the error', function () { + fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderPolicy(['providers' => ['google' => true]]); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(function () { + throw new ConnectionException('cURL error 28: Operation timed out'); + }); + + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'google']); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time) + ->and($log->warnings[0][1]['provider'])->toBe('google') + ->and($log->warnings[0][1]['error'])->toBe('cURL error 28: Operation timed out'); + + // A google response with a zero element is treated as no route + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Http::fake(['*' => Http::response(['rows' => [['elements' => [['status' => 'ZERO_RESULTS']]]]])]); + $zero = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'google']); + expect($zero->distance)->toBe($estimate->distance) + ->and($log->warnings)->toHaveCount(2); +}); + +test('straight-line and same-point lookups never call a road provider', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 1.0, 'duration' => 1.0]]]); + }); + + $samePoint = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.78, 144.83], ['company_uuid' => 'company-1']); + $calculated = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'calculate']); + + // With no company and no explicit provider the public demo default stays straight-line + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + $default = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect($samePoint->distance)->toBe(0.0) + ->and($samePoint->time)->toBe(0.0) + ->and($calculated->time)->toBe(round($calculated->distance / 100) * Utils::DRIVING_TIME_MULTIPLIER) + ->and($default->distance)->toBe($calculated->distance) + ->and($requests)->toBe(0); +}); + +test('the routing provider policy normalizes, saves and enforces provider access', function () { + $connection = fleetopsDistanceProviderBoot(); + + // Defaults: everything self-hosted is available, Google is not + expect(RoutingProviderPolicy::settings())->toBe([ + 'providers' => ['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true], + 'defaults' => ['display_engine' => 'osrm', 'optimization_engine' => 'osrm'], + 'server_routing' => 'road', + 'exceptions' => [], + ]) + ->and(RoutingProviderPolicy::allowedProviders('company-1'))->toBe(['valhalla', 'osrm', 'vroom']) + // Feature keys map to their provider, straight-line and third-party providers are always allowed + ->and(RoutingProviderPolicy::provider('Google_Routes'))->toBe('google') + ->and(RoutingProviderPolicy::provider(''))->toBeNull() + ->and(RoutingProviderPolicy::allows('google_routes', 'company-1'))->toBeFalse() + ->and(RoutingProviderPolicy::allows('calculated'))->toBeTrue() + ->and(RoutingProviderPolicy::allows('calculate'))->toBeTrue() + ->and(RoutingProviderPolicy::allows('greedy'))->toBeTrue() + ->and(RoutingProviderPolicy::allows(null))->toBeTrue(); + + $saved = RoutingProviderPolicy::save([ + 'providers' => ['google' => 'false', 'valhalla' => '0', 'osrm' => true], + 'defaults' => ['display_engine' => 'valhalla', 'optimization_engine' => 'vroom'], + 'exceptions' => [ + ['company_uuid' => 'company-1', 'company_name' => 'Acme', 'providers' => ['google_routes', 'valhalla', 'mapbox']], + ['company_uuid' => 'company-1', 'providers' => ['osrm']], + ['company_uuid' => '', 'providers' => ['google']], + ['company_uuid' => 'company-2', 'providers' => []], + ], + ]); + + expect($saved)->toBe([ + 'providers' => ['google' => false, 'valhalla' => false, 'osrm' => true, 'vroom' => true], + 'defaults' => ['display_engine' => 'valhalla', 'optimization_engine' => 'vroom'], + 'server_routing' => 'road', + 'exceptions' => [['company_uuid' => 'company-1', 'company_name' => 'Acme', 'providers' => ['google', 'valhalla']]], + ]) + ->and(json_decode($connection->table('settings')->where('key', RoutingProviderPolicy::SETTING_KEY)->value('value'), true))->toBe($saved) + // Saving clears the cache, so the change applies at once + ->and(RoutingProviderPolicy::settings())->toBe($saved) + ->and(RoutingProviderPolicy::defaultDisplayEngine())->toBe('valhalla') + ->and(RoutingProviderPolicy::defaultOptimizationEngine())->toBe('vroom') + ->and(RoutingProviderPolicy::allowedProviders('company-1'))->toBe(['google', 'valhalla', 'osrm', 'vroom']) + ->and(RoutingProviderPolicy::allowedProviders('company-2'))->toBe(['osrm', 'vroom']) + ->and(RoutingProviderPolicy::allowedProviders(null))->toBe(['osrm', 'vroom']); + + // Engine fallback: own choice, then the platform default, then OSRM + expect(RoutingProviderPolicy::engineFor('google', 'osrm', 'company-1'))->toBe('google') + ->and(RoutingProviderPolicy::engineFor('google', 'vroom', 'company-2'))->toBe('vroom') + ->and(RoutingProviderPolicy::engineFor(null, 'valhalla', 'company-2'))->toBe('valhalla') + ->and(RoutingProviderPolicy::engineFor('mapbox', 'osrm', 'company-2'))->toBe('mapbox'); + + // A broken settings store falls back to the defaults + $connection->getSchemaBuilder()->drop('settings'); + Cache::forget(RoutingProviderPolicy::CACHE_KEY); + expect(RoutingProviderPolicy::settings()['providers']['google'])->toBeFalse(); +}); + +test('a provider switched off by the administrator is never called for order distance', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'valhalla']); + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 31.5, 'time' => 1600]]]); + }); + + fleetopsDistanceProviderPolicy(['providers' => ['valhalla' => false, 'osrm' => false]]); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect(RoutingEngineResolver::resolve('company-1'))->toBe('calculate') + ->and(Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1'])->distance)->toBe($estimate->distance); + + // An explicit request (the estimations command's --provider) is refused too + $explicit = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'valhalla', 'company_uuid' => 'company-1']); + expect($explicit->distance)->toBe($estimate->distance) + ->and($requests)->toBe(0) + ->and($log->warnings[0][1]['error'])->toBe('The valhalla provider is switched off for this organization.'); +}); + +test('valhalla uses the extension admin settings, an organization override and then the environment', function () { + $connection = fleetopsDistanceProviderBoot(); + config(['fleetops.valhalla.host' => 'https://env-valhalla.fleetbase.test', 'fleetops.valhalla.api_key' => 'env-key']); + + expect(ValhallaClient::connection('company-1'))->toBe(['host' => 'https://env-valhalla.fleetbase.test', 'api_key' => 'env-key']); + + // The system setting saved from the Valhalla extension's admin panel wins over the environment + $connection->table('settings')->insert(['key' => 'valhalla', 'value' => json_encode(['api_host' => 'https://valhalla.routing.fleetbase.test/', 'api_key' => 'system-key'])]); + // An organization pointing at a public community server is ignored + $connection->table('settings')->insert(['key' => 'company.company-2.valhalla', 'value' => json_encode(['api_host' => 'https://valhalla1.openstreetmap.de', 'api_key' => null])]); + // An organization with its own server uses it, with its own key + $connection->table('settings')->insert(['key' => 'company.company-3.valhalla', 'value' => json_encode(['api_host' => 'https://own.valhalla.test', 'api_key' => ''])]); + Cache::flush(); + + expect(ValhallaClient::connection('company-1'))->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => 'system-key']) + ->and(ValhallaClient::connection('company-2'))->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => 'system-key']) + ->and(ValhallaClient::connection('company-3'))->toBe(['host' => 'https://own.valhalla.test', 'api_key' => null]) + ->and(ValhallaClient::connection(null))->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => 'system-key']); + + $sent = null; + Http::fake(function ($request) use (&$sent) { + $sent = $request; + + return Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 1.0, 'time' => 60.0]]]); + }); + ValhallaClient::route([new Fleetbase\LaravelMysqlSpatial\Types\Point(-37.78, 144.83), new Fleetbase\LaravelMysqlSpatial\Types\Point(-37.68, 144.58)], [], 'company-3'); + expect((string) $sent->url())->toBe('https://own.valhalla.test/route') + ->and($sent->header('X-Api-Key'))->toBe([]); + + // Nothing usable anywhere: the client refuses rather than calling a public server + $connection->getSchemaBuilder()->drop('settings'); + config(['fleetops.valhalla.host' => 'https://valhalla2.openstreetmap.de']); + Cache::flush(); + expect(ValhallaClient::connection('company-1'))->toBeNull() + ->and(fn () => ValhallaClient::route([]))->toThrow(RuntimeException::class, 'No Valhalla server is configured.'); +}); + +test('the orchestrator falls back to the built-in engine when vroom is switched off', function () { + $connection = fleetopsDistanceProviderBoot(); + $connection->table('settings')->insert(['key' => 'company.company-1.' . Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::KEY, 'value' => json_encode(['allocation_engine' => 'vroom'])]); + + expect(Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::engineForCompany('company-1'))->toBe('vroom'); + + fleetopsDistanceProviderPolicy(['providers' => ['vroom' => false]]); + expect(Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::engineForCompany('company-1'))->toBe(Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::DEFAULT_ENGINE); + + // An exception keeps VROOM for that organization + fleetopsDistanceProviderPolicy(['providers' => ['vroom' => false], 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['vroom']]]]); + expect(Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::engineForCompany('company-1'))->toBe('vroom'); +}); + +test('the setting controller saves the routing policy and scopes it to the session company', function () { + fleetopsDistanceProviderBoot(); + $controller = new class extends Fleetbase\FleetOps\Http\Controllers\Internal\v1\SettingController { + public function __construct() + { + } + + public function callHelper(string $method, ...$arguments) + { + return $this->{$method}(...$arguments); + } + }; + + session(['company' => 'company-9']); + expect($controller->callHelper('policyCompanyUuid'))->toBe('company-9'); + + $saved = $controller->callHelper('saveRoutingProviderPolicy', ['providers' => ['google' => true]]); + expect($saved['providers']['google'])->toBeTrue() + ->and(RoutingProviderPolicy::allows('google_routes', 'company-9'))->toBeTrue(); +}); + +test('osrm is never a fallback and never reaches the public demo server', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['display_engine' => 'osrm']); + config(['fleetops.routing.display_engine' => 'valhalla']); + Cache::flush(); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]]); + }); + + // A private OSRM host serves only organizations that chose OSRM; Valhalla is the default and the only fallback + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve('company-unsaved'))->toBe('calculate'); + + // An explicit OSRM request with only the demo server configured falls back to the estimate + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'osrm']); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($requests)->toBe(0) + ->and($log->warnings[0][1]['error'])->toBe('No private OSRM host is configured.'); +}); diff --git a/server/tests/Unit/Support/UtilsAdditionalTest.php b/server/tests/Unit/Support/UtilsAdditionalTest.php index 8f5e10f35..f438b8722 100644 --- a/server/tests/Unit/Support/UtilsAdditionalTest.php +++ b/server/tests/Unit/Support/UtilsAdditionalTest.php @@ -193,7 +193,12 @@ function fleetopsUtilsAdditionalPolygon(): Polygon }; }); + // Google is billed per request, so the admin routing policy must allow it + config(['fleetops.routing.google_enabled' => true]); + Cache::forget(Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::CACHE_KEY); $google = Utils::getDrivingDistanceAndTime([47.9131423, 106.9338169], [47.9141423, 106.9348169], ['provider' => 'google']); + config(['fleetops.routing.google_enabled' => false]); + Cache::forget(Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::CACHE_KEY); $osrm = Utils::distanceMatrix([[47.9131423, 106.9338169]], [[47.9141423, 106.9348169]], ['provider' => 'osrm']); expect($google->distance)->toBe(2222.0) diff --git a/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php b/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php index f6acbf63b..999db824b 100644 --- a/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php +++ b/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php @@ -220,6 +220,8 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string test('osrm distance matrices serve cached results and fetch live routes', function () { fleetopsUtilsGeoBoot(); $redis = $GLOBALS['fleetopsUtilsRedisFake']; + // Order distance never calls the public OSRM demo server, only a private host + config(['fleetops.osrm.host' => 'https://osrm.fleetbase.test']); // Cached matrix short-circuits any HTTP work $origins = '1.3,103.8'; @@ -243,6 +245,7 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string expect($live->distance)->toBe(4321.0) ->and($live->time)->toBe(765.0) ->and(count($redis->store))->toBe(2); + config(['fleetops.osrm.host' => null]); }); test('integrated vendor ids match prefixes and provider rows', function () { @@ -327,6 +330,8 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string app()->forgetInstance(Illuminate\Http\Client\Factory::class); Http::fake(['*' => Http::response(['rows' => [['elements' => [['distance' => ['value' => 2500], 'duration' => ['value' => 300]]]]]], 200)]); + // Google is billed per request, so the admin routing policy must allow it + config(['fleetops.routing.google_enabled' => true]); $live = Utils::getDrivingDistanceAndTime([1.3, 103.8], [1.35, 103.85], ['provider' => 'google']); expect($live->distance)->toBe(2500.0) ->and($live->time)->toBe(300.0); @@ -341,6 +346,7 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string Http::fake(['*' => Http::response(['code' => 'Ok', 'routes' => [['distance' => 100.0, 'duration' => 60.0]], 'waypoints' => []], 200)]); $passthrough = Utils::getDistanceMatrixFromOSRM('not-a-coordinate', '1.35,103.85'); expect($passthrough->distance)->toBe(100.0); + config(['fleetops.routing.google_enabled' => false]); }); test('feature geometries strict lookups matrices and centroids cover edge seams', function () { diff --git a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php index a064509c8..c1c2ba7cd 100644 --- a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +++ b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php @@ -13,9 +13,10 @@ use Illuminate\Database\SQLiteConnection; /** - * Covers company tracking settings resolution for TrackingOptions: the - * order's company is used when there is no company session (queued jobs, - * console commands), and the session company remains the fallback. + * Covers company resolution for TrackingOptions: the order's company is used + * when there is no company session (queued jobs, console commands), and the + * session company remains the fallback. The provider is the company's routing + * engine; the company's tracking settings only tune behaviour. */ function fleetopsTrackingOptionsBoot(): SQLiteConnection { @@ -31,11 +32,17 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection $blueprint->timestamps(); }); + // Company A routes with its own Valhalla server; company B has no routing engine the server can call. + // A provider saved in tracking settings before tracking followed the routing engine is ignored. $connection->table('settings')->insert([ + ['key' => 'company.company-a.routing', 'value' => json_encode(['display_engine' => 'valhalla'])], + ['key' => 'company.company-a.valhalla', 'value' => json_encode(['api_host' => 'https://company-a.valhalla.test'])], ['key' => 'company.company-a.tracking', 'value' => json_encode(['provider' => 'osrm', 'cache_ttl_seconds' => 15])], - ['key' => 'company.company-b.tracking', 'value' => json_encode(['provider' => 'calculated', 'cache_ttl_seconds' => 45])], + ['key' => 'company.company-b.tracking', 'value' => json_encode(['provider' => 'google_routes', 'cache_ttl_seconds' => 45])], ]); + config(['fleetops.osrm.host' => null, 'fleetops.valhalla.host' => null]); + Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); session(['company' => null]); return $connection; @@ -47,10 +54,11 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection $options = TrackingOptions::fromArray([], 'company-a'); - expect($options->provider)->toBe('osrm') + expect($options->provider)->toBe('valhalla') + ->and($options->fallbacks)->toBe([]) ->and($options->cacheTtlSeconds)->toBe(15); - // Explicit options still win over company settings + // An explicitly requested provider (API callers) still wins; the routing policy guards it later expect(TrackingOptions::fromArray(['provider' => 'google'], 'company-a')->provider)->toBe('google'); })->with([null, 'company-b']); @@ -65,6 +73,13 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection ->and($options->cacheTtlSeconds)->toBe(45); }); +test('tracking uses the straight-line estimate when the administrator switches server-side routing off', function () { + fleetopsTrackingOptionsBoot(); + Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::save(['server_routing' => 'straight_line']); + + expect(TrackingOptions::fromArray([], 'company-a')->provider)->toBe('calculated'); +}); + test('order tracker builds options from the order company', function () { fleetopsTrackingOptionsBoot(); @@ -91,7 +106,7 @@ public function track(Order $order, array|TrackingOptions $options = []): array app()->forgetInstance(TrackingIntelligenceService::class); expect($service->received)->toBeInstanceOf(TrackingOptions::class) - ->and($service->received->provider)->toBe('osrm'); + ->and($service->received->provider)->toBe('valhalla'); }); test('tracking remains available with configured defaults when the settings database is unavailable', function () { @@ -99,7 +114,7 @@ public function track(Order $order, array|TrackingOptions $options = []): array $connection->getSchemaBuilder()->drop('settings'); $original = config('fleetops.tracking', []); try { - config(['fleetops.tracking' => ['provider' => 'calculated', 'cache_ttl_seconds' => 17]]); + config(['fleetops.tracking' => ['cache_ttl_seconds' => 17]]); $options = TrackingOptions::fromArray([], 'uncached-unavailable-company'); expect($options->provider)->toBe('calculated')->and($options->cacheTtlSeconds)->toBe(17); expect(TrackingOptions::fromArray(['provider' => 'osrm'], 'uncached-unavailable-company')->provider)->toBe('osrm'); diff --git a/tests/integration/components/admin/routing-settings-test.js b/tests/integration/components/admin/routing-settings-test.js new file mode 100644 index 000000000..64b786d02 --- /dev/null +++ b/tests/integration/components/admin/routing-settings-test.js @@ -0,0 +1,98 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class FetchStubService extends Service { + posts = []; + + get(url) { + if (url === 'fleet-ops/settings/admin-routing-providers') { + return Promise.resolve({ + providers: { google: false, valhalla: true, osrm: true, vroom: true }, + defaults: { display_engine: 'valhalla', optimization_engine: 'vroom' }, + server_routing: 'road', + exceptions: [{ company_uuid: 'company-1', company_name: 'Acme Logistics', providers: ['google'] }], + available_providers: [ + { key: 'google', label: 'Google' }, + { key: 'valhalla', label: 'Valhalla' }, + { key: 'osrm', label: 'OSRM' }, + { key: 'vroom', label: 'VROOM' }, + ], + }); + } + + return Promise.resolve({ traffic_enabled: false, cache_ttl_seconds: 45 }); + } + + post(url, body) { + this.posts.push([url, body]); + return Promise.resolve(body); + } +} + +class NotificationsStubService extends Service { + successes = []; + + success(message) { + this.successes.push(message); + } + + serverError() {} +} + +class RouteEngineStubService extends Service { + allServices = [ + { key: 'osrm', name: 'OSRM' }, + { key: 'valhalla', name: 'Valhalla' }, + ]; +} + +class RouteOptimizationStubService extends Service { + allServices = [ + { key: 'osrm', name: 'OSRM' }, + { key: 'vroom', name: 'VROOM' }, + ]; +} + +module('Integration | Component | admin/routing-settings', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:notifications', NotificationsStubService); + this.owner.register('service:route-engine', RouteEngineStubService); + this.owner.register('service:route-optimization', RouteOptimizationStubService); + }); + + test('it loads the provider policy and saves provider access, defaults and exceptions', async function (assert) { + await render(hbs`
`); + + assert.dom('[data-test-provider-access]').exists({ count: 4 }); + assert.dom('[data-test-provider-access="google"]').includesText('Billed per request'); + assert.dom('[data-test-provider-exception="company-1"]').includesText('Acme Logistics'); + + // Switch Google on for everyone, then remove the now-redundant exception + await click('[data-test-provider-access="google"] [role="checkbox"]'); + await click('[data-test-provider-exception="company-1"] button.btn-danger'); + await click('#next-view-section-subheader-actions button'); + + const fetch = this.owner.lookup('service:fetch'); + assert.deepEqual(fetch.posts[0], [ + 'fleet-ops/settings/admin-routing-providers', + { + providers: { google: true, valhalla: true, osrm: true, vroom: true }, + defaults: { display_engine: 'valhalla', optimization_engine: 'vroom' }, + server_routing: 'road', + exceptions: [], + }, + ]); + // Tracking only saves behaviour; its provider is the routing engine + assert.strictEqual(fetch.posts[1][0], 'fleet-ops/settings/admin-tracking-settings'); + assert.false('provider' in fetch.posts[1][1]); + assert.false(fetch.posts[1][1].traffic_enabled); + assert.strictEqual(fetch.posts[1][1].cache_ttl_seconds, 45); + assert.deepEqual(this.owner.lookup('service:notifications').successes, ['Routing settings saved.']); + }); +}); diff --git a/tests/unit/services/leaflet-routing-control-test.js b/tests/unit/services/leaflet-routing-control-test.js index f9bf94be6..b7e79aa0e 100644 --- a/tests/unit/services/leaflet-routing-control-test.js +++ b/tests/unit/services/leaflet-routing-control-test.js @@ -1,12 +1,62 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Service | leaflet-routing-control', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.routing = {}; + const routing = () => this.routing; + const registry = { routers: {} }; + + class UniverseStubService extends Service { + application = { + hasRegistration() { + return false; + }, + register() {}, + resolveRegistration() { + return registry; + }, + }; + + getApplicationInstance() { + return this.application; + } + } + + class CurrentUserStubService extends Service { + getOption(key, defaultValue) { + return Object.keys(routing()).length ? routing() : defaultValue; + } + } + + this.owner.register('service:universe', UniverseStubService); + this.owner.register('service:current-user', CurrentUserStubService); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:leaflet-routing-control'); assert.ok(service); }); + + test('it defaults to valhalla and never uses a switched-off engine', function (assert) { + const service = this.owner.lookup('service:leaflet-routing-control'); + const osrm = { name: 'OSRM' }; + const valhalla = { name: 'Valhalla' }; + const google = { name: 'Google' }; + service.register('osrm', osrm); + service.register('valhalla', valhalla); + service.register('google', google); + + // Without saved routing settings the platform default is Valhalla + assert.strictEqual(service.getRouter(), 'valhalla'); + assert.strictEqual(service.get(), valhalla); + + this.routing = { router: 'osrm', allowed_providers: ['osrm', 'valhalla'] }; + assert.strictEqual(service.get('google'), osrm, 'a switched-off engine falls back to the organization router'); + assert.strictEqual(service.get('valhalla'), valhalla); + assert.strictEqual(service.get('mapbox'), valhalla, 'an unregistered engine falls back to Valhalla'); + }); }); diff --git a/tests/unit/services/route-engine-test.js b/tests/unit/services/route-engine-test.js index 00f9390e8..4a0accd71 100644 --- a/tests/unit/services/route-engine-test.js +++ b/tests/unit/services/route-engine-test.js @@ -6,6 +6,15 @@ module('Unit | Service | route-engine', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { + const registry = { engines: {} }; + this.registry = registry; + this.routing = { + router: 'osrm', + routing_display_engine: 'google', + routing_optimization_engine: 'vroom', + }; + const routing = () => this.routing; + class UniverseStubService extends Service { application = { hasRegistration() { @@ -13,7 +22,7 @@ module('Unit | Service | route-engine', function (hooks) { }, register() {}, resolveRegistration() { - return { engines: {} }; + return registry; }, }; @@ -24,11 +33,7 @@ module('Unit | Service | route-engine', function (hooks) { class CurrentUserStubService extends Service { getOption() { - return { - router: 'osrm', - routing_display_engine: 'google', - routing_optimization_engine: 'vroom', - }; + return routing(); } } @@ -42,4 +47,34 @@ module('Unit | Service | route-engine', function (hooks) { assert.strictEqual(service.getDisplayEngine(), 'google'); assert.strictEqual(service.getOptimizationEngine(), 'vroom'); }); + + test('it hides and never uses engines the administrator switched off', function (assert) { + const service = this.owner.lookup('service:route-engine'); + const osrm = { name: 'OSRM', computeRoute: () => 'osrm-route' }; + const google = { name: 'Google', computeRoute: () => 'google-route' }; + const mapbox = { name: 'Mapbox', computeRoute: () => 'mapbox-route' }; + service.register('osrm', osrm); + service.register('google', google); + service.register('mapbox', mapbox); + + this.routing = { routing_display_engine: 'osrm', allowed_providers: ['osrm', 'valhalla'] }; + + assert.deepEqual( + service.availableServices.map(({ key }) => key), + ['osrm', 'mapbox'], + 'google is not offered; extension engines are' + ); + assert.deepEqual( + service.allServices.map(({ key }) => key), + ['osrm', 'google', 'mapbox'], + 'admin screens still see every engine' + ); + assert.strictEqual(service.get('google'), osrm, 'a switched-off engine falls back to the display engine'); + assert.strictEqual(service.get('mapbox'), mapbox); + + // Without an allowed list nothing is filtered + this.routing = { routing_display_engine: 'google' }; + assert.strictEqual(service.get('google'), google); + assert.strictEqual(service.availableServices.length, 3); + }); }); diff --git a/tests/unit/services/route-optimization-test.js b/tests/unit/services/route-optimization-test.js index 2a709b21b..7043a8df6 100644 --- a/tests/unit/services/route-optimization-test.js +++ b/tests/unit/services/route-optimization-test.js @@ -1,12 +1,70 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Service | route-optimization', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.routing = {}; + const routing = () => this.routing; + const registry = { engines: {} }; + + class UniverseStubService extends Service { + application = { + hasRegistration() { + return false; + }, + register() {}, + resolveRegistration() { + return registry; + }, + }; + + getApplicationInstance() { + return this.application; + } + } + + this.owner.register('service:universe', UniverseStubService); + + class CurrentUserStubService extends Service { + getOption() { + return routing(); + } + } + + this.owner.register('service:current-user', CurrentUserStubService); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:route-optimization'); assert.ok(service); }); + + test('it hides and refuses engines the administrator switched off', async function (assert) { + const service = this.owner.lookup('service:route-optimization'); + service.register('osrm', { name: 'OSRM', optimize: () => Promise.resolve('osrm'), handler: () => 'osrm-handled' }); + service.register('vroom', { name: 'VROOM', optimize: () => Promise.resolve('vroom'), handler: () => 'vroom-handled' }); + + this.routing = { allowed_providers: ['osrm'] }; + + assert.deepEqual( + service.availableServices.map(({ key }) => key), + ['osrm'] + ); + assert.deepEqual(service.availableEngines, ['osrm']); + assert.deepEqual( + service.allServices.map(({ key }) => key), + ['osrm', 'vroom'] + ); + assert.strictEqual(await service.optimize('osrm'), 'osrm'); + assert.strictEqual(service.handler('osrm'), 'osrm-handled'); + + await assert.rejects(service.optimize('vroom'), /switched off by the administrator/); + assert.throws(() => service.handler('vroom'), /switched off by the administrator/); + + this.routing = {}; + assert.strictEqual(await service.optimize('vroom'), 'vroom'); + }); }); diff --git a/tests/unit/utils/routing-providers-test.js b/tests/unit/utils/routing-providers-test.js new file mode 100644 index 000000000..e45b950ad --- /dev/null +++ b/tests/unit/utils/routing-providers-test.js @@ -0,0 +1,17 @@ +import isRoutingProviderAllowed, { GOVERNED_ROUTING_PROVIDERS } from 'dummy/utils/routing-providers'; +import { module, test } from 'qunit'; + +module('Unit | Utility | routing-providers', function () { + test('built-in providers follow the allowed list', function (assert) { + assert.deepEqual(GOVERNED_ROUTING_PROVIDERS, ['google', 'valhalla', 'osrm', 'vroom']); + assert.true(isRoutingProviderAllowed('osrm', ['osrm', 'valhalla'])); + assert.false(isRoutingProviderAllowed('google', ['osrm', 'valhalla'])); + assert.false(isRoutingProviderAllowed('Valhalla', ['osrm'])); + }); + + test('extension engines and a missing allowed list are never filtered', function (assert) { + assert.true(isRoutingProviderAllowed('mapbox', ['osrm'])); + assert.true(isRoutingProviderAllowed('google', undefined)); + assert.true(isRoutingProviderAllowed(null, ['osrm'])); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8a51aa3cb..52839e7ff 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2848,7 +2848,7 @@ settings: configure-routing: Configure Routing select-routing-service: Select Routing Service routing-service: Routing Service - routing-service-help-text: Select the service which is responsible for calculating and plotting routes on the map. + routing-service-help-text: Select the service that plots routes on the map and measures each order's distance, time and live ETA. select-routing-distance-unit: Select Routing Distance Unit routing-distance-unit: Routing Distance Unit routing-distance-unit-help-text: The unit used for calculating distance and routes.