From cb17e8e91058cba213cf6f08f82110ee713a8623 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 15:12:36 +0800 Subject: [PATCH] fix(orders): measure order distance and time by road using the company's routing engine Order distance/time came from the straight-line 'calculate' provider because DISTANCE_MATRIX_PROVIDER defaults to it and the routing setting is only read by the console map. Resolve the provider from an explicit DISTANCE_MATRIX_PROVIDER, then the company's routing engine when the server can call it (private OSRM host, Google key), then 'calculate'. Fall back to the straight-line estimate with a warning when a road provider fails instead of storing 0, cache road results for 24h and stop caching empty ones. Co-Authored-By: Claude --- server/config/fleetops.php | 8 +- .../Commands/TrackOrderDistanceAndTime.php | 9 +- server/src/Models/Order.php | 5 +- .../DistanceMatrixProviderResolver.php | 89 ++++++ server/src/Support/Utils.php | 74 +++-- server/tests/CommandContractsTest.php | 22 +- .../OrderControllerPersistenceHelpersTest.php | 9 +- .../OrderPayloadConfigAndDistanceTest.php | 59 ++++ .../DistanceMatrixProviderResolverTest.php | 273 ++++++++++++++++++ 9 files changed, 512 insertions(+), 36 deletions(-) create mode 100644 server/src/Support/Routing/DistanceMatrixProviderResolver.php create mode 100644 server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 798e4e351..cd5668b09 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -63,10 +63,16 @@ |-------------------------------------------------------------------------- | Distance Matrix Calculator | Options: "calculate", "google", "osrm" + | + | Leave DISTANCE_MATRIX_PROVIDER unset to measure order distance and time + | with each company's routing engine (Fleet-Ops Settings -> Routing) when + | the server can call it: OSRM needs a self-hosted OSRM_HOST, Google needs + | GOOGLE_MAPS_API_KEY. Otherwise the straight-line "calculate" estimate is + | used. Setting it applies one provider to every company. |-------------------------------------------------------------------------- */ 'distance_matrix' => [ - 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate'), + 'provider' => env('DISTANCE_MATRIX_PROVIDER'), ], /* 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/Models/Order.php b/server/src/Models/Order.php index 0af1fae5a..f2e517c40 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1917,7 +1917,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 */ @@ -1935,7 +1936,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/Support/Routing/DistanceMatrixProviderResolver.php b/server/src/Support/Routing/DistanceMatrixProviderResolver.php new file mode 100644 index 000000000..c0ed4263f --- /dev/null +++ b/server/src/Support/Routing/DistanceMatrixProviderResolver.php @@ -0,0 +1,89 @@ + 'osrm', + 'google' => 'google', + default => null, + }; + } + + public static function isAvailable(string $provider): bool + { + return match ($provider) { + 'osrm' => static::hasPrivateOsrmHost(), + '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/Utils.php b/server/src/Support/Utils.php index 19167d790..c90ed873d 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\FleetOps\Support\Routing\DistanceMatrixProviderResolver; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; use Fleetbase\Models\Setting; @@ -10,6 +11,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 +24,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 +798,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 + * DistanceMatrixProviderResolver 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 +817,37 @@ 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; + $provider = data_get($options, 'provider') ?: DistanceMatrixProviderResolver::resolve(data_get($options, 'company_uuid', session('company'))); + $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 { + $distanceMatrix = match ($provider) { + 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), + default => static::getDistanceMatrixFromGoogle($origins, $destinations), + }; + } 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 +879,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 +964,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' => DistanceMatrixProviderResolver::googleMapsApiKey(), ] )->json(); @@ -954,8 +974,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 +1032,10 @@ 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; } 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/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/DistanceMatrixProviderResolverTest.php b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php new file mode 100644 index 000000000..337863c09 --- /dev/null +++ b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php @@ -0,0 +1,273 @@ + $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); + } + }); + Illuminate\Support\Facades\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', + 'services.google_maps.api_key' => null, + ]); + + $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)]); +} + +afterEach(function () { + config([ + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => null, + 'services.google_maps.api_key' => null, + ]); + app()->forgetInstance('log'); + Log::clearResolvedInstance('log'); + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + session(['company' => null]); +}); + +test('an explicit DISTANCE_MATRIX_PROVIDER wins over the company routing engine', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + + config(['fleetops.distance_matrix.provider' => 'google']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('google'); + + // `calculate` is the operator's kill switch back to straight-line estimates + config(['fleetops.distance_matrix.provider' => 'calculate']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); +}); + +test('the company routing engine is used when the server can call it', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['router' => 'osrm', 'display_engine' => 'osrm']); + fleetopsDistanceProviderRouting($connection, 'company-google', ['display_engine' => 'google']); + fleetopsDistanceProviderRouting($connection, 'company-legacy', ['router' => 'google']); + fleetopsDistanceProviderRouting($connection, 'company-valhalla', ['display_engine' => 'valhalla']); + + // A company that never saved routing settings uses OSRM, as the map does + expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::resolve('company-unsaved'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('osrm'); + + // Google needs a Maps API key on the server + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('calculate'); + config(['services.google_maps.api_key' => 'test-maps-key']); + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('google') + ->and(DistanceMatrixProviderResolver::resolve('company-legacy'))->toBe('google') + ->and(DistanceMatrixProviderResolver::googleMapsApiKey())->toBe('test-maps-key'); + + // Engines the server cannot call fall back to the straight-line estimate + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-valhalla'))->toBeNull() + ->and(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('calculate') + ->and(DistanceMatrixProviderResolver::isAvailable('valhalla'))->toBeFalse(); +}); + +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(DistanceMatrixProviderResolver::hasPrivateOsrmHost())->toBeFalse() + ->and(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); + } + + // The operator can still opt into the demo server explicitly + config(['fleetops.distance_matrix.provider' => 'osrm']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); +}); + +test('an unreadable settings store falls back to the default routing engine', function () { + $connection = fleetopsDistanceProviderBoot(); + $connection->getSchemaBuilder()->drop('settings'); + + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-1'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::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(); + $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); +});