- 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}}
-
-
+
+
+
+
+ Switch a provider on for specific organizations only, for example Google for a customer who pays for it.
+
+
+ {{#each this.providerExceptionRows as |row|}}
+
+
{{row.exception.company_name}}
+
+
+ {{provider.label}}
+
+
+
+
+ {{else}}
+
No exceptions. Every organization follows Provider Access.
+ {{/each}}
+
+
+
+ {{company.name}}
+
+
+
+
+
+ Live ETAs, remaining distance and route lines come from each organization's routing engine. These settings only tune their behaviour.
+
+ 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.