Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion server/config/fleetops.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],

/*
Expand Down
9 changes: 5 additions & 4 deletions server/src/Console/Commands/TrackOrderDistanceAndTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)' : ''));

Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions server/src/Models/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down
89 changes: 89 additions & 0 deletions server/src/Support/Routing/DistanceMatrixProviderResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Fleetbase\FleetOps\Support\Routing;

use Fleetbase\Models\Setting;

/**
* Decides which distance matrix provider measures an order's distance and time.
*
* In order of precedence:
* 1. `DISTANCE_MATRIX_PROVIDER` when the operator set it, so an explicit
* platform choice (or `calculate` as a kill switch) always wins.
* 2. The company's routing engine from Fleet-Ops Settings → Routing, the same
* engine that draws the route on the map (OSRM unless changed), when the
* server can serve it: OSRM needs a self-hosted `OSRM_HOST` rather than
* the public demo server, Google needs a Maps API key.
* 3. `calculate`, the straight-line estimate.
*/
class DistanceMatrixProviderResolver
{
/**
* The public OSRM demo server. Its usage policy allows at most one request
* per second, so server-side order estimates are never sent there unless
* the operator explicitly asks for OSRM.
*/
public const PUBLIC_OSRM_HOST = 'router.project-osrm.org';

public static function resolve(?string $companyUuid = null): string
{
$configured = config('fleetops.distance_matrix.provider');
if (filled($configured)) {
return (string) $configured;
}

$provider = static::fromRoutingSettings($companyUuid);
if ($provider && static::isAvailable($provider)) {
return $provider;
}

return 'calculate';
}

/**
* Maps the company's routing display engine onto a server-side provider.
* Engines the server cannot call (Valhalla, VROOM, custom engines) map to null.
*/
public static function fromRoutingSettings(?string $companyUuid = null): ?string
{
try {
$settings = Setting::lookupForCompany($companyUuid, 'routing', []);
} catch (\Throwable) {
$settings = [];
}

$engine = data_get($settings, 'display_engine', data_get($settings, 'routing_display_engine', data_get($settings, 'router', 'osrm')));

return match ($engine) {
'osrm' => '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;
}
}
74 changes: 49 additions & 25 deletions server/src/Support/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
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;
use Fleetbase\Support\Utils as FleetbaseUtils;
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;

Expand All @@ -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.
*
Expand Down Expand Up @@ -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
{
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
22 changes: 21 additions & 1 deletion server/tests/CommandContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.'])
Expand Down Expand Up @@ -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'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading
Loading