@fleetbase/fleetops-engine 0.6.55 → 0.6.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/addon/components/admin/map-settings.hbs +1 -1
- package/addon/components/ai/create-order-preview.hbs +297 -0
- package/addon/components/ai/create-order-preview.js +362 -0
- package/addon/components/ai/route-preview-map.hbs +79 -0
- package/addon/components/ai/route-preview-map.js +527 -0
- package/addon/components/device/details.js +1 -1
- package/addon/components/device-event/details.hbs +192 -0
- package/addon/components/device-event/details.js +151 -0
- package/addon/components/driver/schedule.js +1 -1
- package/addon/components/fleet/details.hbs +21 -8
- package/addon/components/fleet/details.js +55 -1
- package/addon/components/fleet/hierarchy-tree.hbs +137 -0
- package/addon/components/fleet/hierarchy-tree.js +528 -0
- package/addon/components/layout/fleet-ops-sidebar.js +15 -9
- package/addon/components/map/drawer/device-event-listing.js +1 -0
- package/addon/components/map/drawer/driver-listing.js +1 -0
- package/addon/components/map/drawer/place-listing.js +1 -0
- package/addon/components/map/drawer/vehicle-listing.js +1 -0
- package/addon/components/order/details.hbs +34 -1
- package/addon/components/order/form/details.hbs +4 -0
- package/addon/components/order/form.hbs +9 -1
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/form.hbs +2 -2
- package/addon/components/telematic/settings.hbs +1 -1
- package/addon/components/vehicle/card.hbs +14 -2
- package/addon/components/vehicle/form.hbs +13 -1
- package/addon/controllers/connectivity/devices/index/details/events.js +1 -1
- package/addon/controllers/connectivity/devices/index/details/vehicle.js +2 -2
- package/addon/controllers/connectivity/events/details.js +68 -1
- package/addon/controllers/connectivity/sensors/index.js +4 -0
- package/addon/controllers/connectivity/telematics/details/events.js +1 -1
- package/addon/controllers/connectivity/telematics/details/sensors.js +4 -0
- package/addon/controllers/settings/map.js +19 -0
- package/addon/extension.js +14 -0
- package/addon/instance-initializers/register-vroom-allocation.js +1 -1
- package/addon/routes/connectivity/events/details.js +26 -1
- package/addon/routes/management/fleets/index/details.js +1 -1
- package/addon/routes/management/vehicles/index/details.js +3 -1
- package/addon/services/map-adapter/google.js +66 -1
- package/addon/services/map-adapter/leaflet.js +1 -1
- package/addon/services/map-adapter-interface.js +1 -1
- package/addon/services/map-manager.js +16 -1
- package/addon/services/map-settings.js +15 -0
- package/addon/styles/fleetops-engine.css +487 -1
- package/addon/templates/connectivity/devices/index/details/vehicle.hbs +2 -1
- package/addon/templates/connectivity/events/details.hbs +40 -1
- package/addon/templates/settings/map.hbs +38 -9
- package/addon/utils/leaflet-plugin-loader.js +5 -5
- package/addon/utils/map-drawer-dropdown-position.js +11 -13
- package/addon/utils/route-colors.js +1 -1
- package/app/components/ai/create-order-preview.js +1 -0
- package/app/components/ai/route-preview-map.js +1 -0
- package/app/components/device-event/details.hbs +192 -0
- package/app/components/device-event/details.js +1 -0
- package/app/components/fleet/hierarchy-tree.js +1 -0
- package/app/services/route-engine.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +6 -4
- package/scripts/normalize-rgba-alpha.js +90 -0
- package/server/migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php +34 -0
- package/server/src/Exports/ContactExport.php +12 -5
- package/server/src/Exports/DeviceExport.php +92 -0
- package/server/src/Exports/DriverExport.php +28 -3
- package/server/src/Exports/EquipmentExport.php +94 -0
- package/server/src/Exports/FleetExport.php +22 -7
- package/server/src/Exports/FuelReportExport.php +16 -1
- package/server/src/Exports/IssueExport.php +25 -3
- package/server/src/Exports/MaintenanceExport.php +106 -0
- package/server/src/Exports/MaintenanceScheduleExport.php +99 -0
- package/server/src/Exports/OrderExport.php +24 -5
- package/server/src/Exports/PartExport.php +99 -0
- package/server/src/Exports/PlaceExport.php +33 -7
- package/server/src/Exports/SensorExport.php +94 -0
- package/server/src/Exports/ServiceAreaExport.php +31 -7
- package/server/src/Exports/ServiceRateExport.php +41 -1
- package/server/src/Exports/TelematicExport.php +90 -0
- package/server/src/Exports/VehicleExport.php +274 -8
- package/server/src/Exports/VendorExport.php +15 -2
- package/server/src/Exports/WorkOrderExport.php +92 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +176 -0
- package/server/src/Http/Controllers/Api/v1/DeviceController.php +225 -0
- package/server/src/Http/Controllers/Api/v1/EquipmentController.php +114 -0
- package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +185 -0
- package/server/src/Http/Controllers/Api/v1/PartController.php +120 -0
- package/server/src/Http/Controllers/Api/v1/SensorController.php +132 -0
- package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +161 -0
- package/server/src/Http/Controllers/Internal/v1/DeviceController.php +18 -0
- package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +17 -0
- package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +17 -0
- package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +17 -0
- package/server/src/Http/Controllers/Internal/v1/PartController.php +17 -0
- package/server/src/Http/Controllers/Internal/v1/SensorController.php +18 -0
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +29 -5
- package/server/src/Http/Controllers/Internal/v1/TelematicController.php +18 -0
- package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +17 -0
- package/server/src/Http/Filter/DeviceFilter.php +33 -10
- package/server/src/Http/Filter/EquipmentFilter.php +23 -0
- package/server/src/Http/Filter/FuelProviderTransactionFilter.php +57 -2
- package/server/src/Http/Filter/PartFilter.php +47 -0
- package/server/src/Http/Filter/SensorFilter.php +34 -6
- package/server/src/Http/Filter/WorkOrderFilter.php +23 -0
- package/server/src/Http/Requests/CreateDeviceRequest.php +51 -0
- package/server/src/Http/Requests/CreateEquipmentRequest.php +35 -0
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +50 -0
- package/server/src/Http/Requests/CreatePartRequest.php +40 -0
- package/server/src/Http/Requests/CreateSensorRequest.php +47 -0
- package/server/src/Http/Requests/CreateWorkOrderRequest.php +42 -0
- package/server/src/Http/Requests/UpdateDeviceRequest.php +7 -0
- package/server/src/Http/Requests/UpdateEquipmentRequest.php +7 -0
- package/server/src/Http/Requests/UpdateFuelTransactionRequest.php +7 -0
- package/server/src/Http/Requests/UpdatePartRequest.php +7 -0
- package/server/src/Http/Requests/UpdateSensorRequest.php +7 -0
- package/server/src/Http/Requests/UpdateWorkOrderRequest.php +7 -0
- package/server/src/Http/Resources/v1/Device.php +72 -0
- package/server/src/Http/Resources/v1/Equipment.php +57 -0
- package/server/src/Http/Resources/v1/Fleet.php +8 -2
- package/server/src/Http/Resources/v1/FuelTransaction.php +69 -0
- package/server/src/Http/Resources/v1/Part.php +64 -0
- package/server/src/Http/Resources/v1/Sensor.php +68 -0
- package/server/src/Jobs/SyncTelematicDevicesJob.php +194 -38
- package/server/src/Models/Driver.php +6 -0
- package/server/src/Models/Order.php +20 -7
- package/server/src/Models/Place.php +11 -3
- package/server/src/Observers/PurchaseRateObserver.php +2 -1
- package/server/src/Providers/FleetOpsServiceProvider.php +26 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +88 -0
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +108 -0
- package/server/src/Support/Ai/Capabilities/ConsoleNavigationCapability.php +55 -0
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +508 -0
- package/server/src/Support/Ai/Capabilities/DocsHelpCapability.php +49 -0
- package/server/src/Support/Ai/Capabilities/ImportOrdersPreviewCapability.php +66 -0
- package/server/src/Support/Ai/Capabilities/OperationalQueryCapability.php +319 -0
- package/server/src/Support/Ai/Capabilities/OptimizeOrderRouteCapability.php +240 -0
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +95 -0
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +195 -0
- package/server/src/Support/Ai/FleetOpsAiQueryResources.php +150 -0
- package/server/src/Support/Analytics/RevenueTrend.php +3 -8
- package/server/src/Support/Metrics/ActiveRevenueQuery.php +121 -0
- package/server/src/Support/Metrics/AvgOrderValueMetric.php +1 -7
- package/server/src/Support/Metrics/EarningsMetric.php +1 -10
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +104 -5
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +550 -54
- package/server/src/Support/Telematics/TelematicService.php +28 -9
- package/server/src/Support/Utils.php +52 -0
- package/server/src/routes.php +67 -14
- package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
- package/server/tests/ExportCoverageTest.php +79 -0
- package/server/tests/FleetHierarchyResourceTest.php +12 -0
- package/server/tests/MetricsRegistryTest.php +45 -5
- package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
- package/server/tests/PointResolutionTest.php +63 -0
- package/server/tests/PublicFleetOpsRestEndpointsTest.php +191 -0
- package/server/tests/TelematicsHardeningTest.php +636 -1
- package/addon/components/table/cell/dropdown/action-item.hbs +0 -31
- package/addon/components/table/cell/dropdown/action-item.js +0 -62
- package/addon/components/table/cell/dropdown.hbs +0 -28
- package/addon/components/table/cell/dropdown.js +0 -91
- package/app/components/table/cell/dropdown/action-item.js +0 -1
- package/app/components/table/cell/dropdown.js +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
|
|
7
|
+
class DocsHelpCapability extends AbstractFleetOpsAICapability
|
|
8
|
+
{
|
|
9
|
+
public function key(): string
|
|
10
|
+
{
|
|
11
|
+
return 'fleet-ops.docs_help';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public function label(): string
|
|
15
|
+
{
|
|
16
|
+
return 'Fleet-Ops docs help';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public function description(): string
|
|
20
|
+
{
|
|
21
|
+
return 'Provides curated official Fleetbase documentation references for Fleet-Ops how-to questions.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function resolve(AiTask $task): array
|
|
25
|
+
{
|
|
26
|
+
$prompt = $this->prompt($task);
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
'references' => array_values(array_filter([
|
|
30
|
+
$this->reference('Fleet-Ops overview', 'https://fleetbase.io/docs/fleet-ops', 'Fleet-Ops capabilities, concepts, orders, resources, maintenance, connectivity, analytics, and reports.'),
|
|
31
|
+
$this->containsAny($prompt, ['order', 'dispatch', 'create']) ? $this->reference('Fleet-Ops quickstart', 'https://fleetbase.io/docs/fleet-ops/getting-started/quickstart', 'Create your first order, assign a driver, and dispatch.') : null,
|
|
32
|
+
$this->containsAny($prompt, ['driver app', 'navigator', 'mobile']) ? $this->reference('Navigator app setup', 'https://fleetbase.io/docs/fleet-ops/getting-started/navigator-app-setup', 'Set up Navigator for drivers.') : null,
|
|
33
|
+
$this->containsAny($prompt, ['maintenance', 'work order', 'parts']) ? $this->reference('Maintenance overview', 'https://fleetbase.io/docs/fleet-ops/maintenance', 'Maintenance schedules, work orders, equipment, and parts.') : null,
|
|
34
|
+
$this->containsAny($prompt, ['telematic', 'device', 'sensor', 'connectivity']) ? $this->reference('Connectivity overview', 'https://fleetbase.io/docs/fleet-ops/connectivity', 'Telematics, devices, sensors, and connectivity events.') : null,
|
|
35
|
+
$this->containsAny($prompt, ['analytics', 'report', 'reports']) ? $this->reference('Analytics overview', 'https://fleetbase.io/docs/fleet-ops/analytics', 'Fleet-Ops analytics and report workflows.') : null,
|
|
36
|
+
])),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
protected function matchesPrompt(string $prompt): bool
|
|
41
|
+
{
|
|
42
|
+
return $this->containsAny($prompt, ['how do i', 'how to', 'docs', 'documentation', 'help me', 'where do i', 'guide']);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
protected function reference(string $title, string $url, string $description): array
|
|
46
|
+
{
|
|
47
|
+
return compact('title', 'url', 'description');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
|
|
7
|
+
class ImportOrdersPreviewCapability extends AbstractFleetOpsAICapability
|
|
8
|
+
{
|
|
9
|
+
public function key(): string
|
|
10
|
+
{
|
|
11
|
+
return 'fleet-ops.import_orders_preview';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public function label(): string
|
|
15
|
+
{
|
|
16
|
+
return 'Import Fleet-Ops orders preview';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public function description(): string
|
|
20
|
+
{
|
|
21
|
+
return 'Explains and drafts preview-only Fleet-Ops import requirements for order/resource spreadsheets.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function type(): string
|
|
25
|
+
{
|
|
26
|
+
return 'action';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public function mode(): string
|
|
30
|
+
{
|
|
31
|
+
return 'preview';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function permissions(): array
|
|
35
|
+
{
|
|
36
|
+
return ['fleet-ops import order'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public function resolve(AiTask $task): array
|
|
40
|
+
{
|
|
41
|
+
return [
|
|
42
|
+
'preview_only' => true,
|
|
43
|
+
'action' => 'import_orders',
|
|
44
|
+
'authorized' => $this->can('fleet-ops import order'),
|
|
45
|
+
'message' => 'This pilot can inspect intent and explain import requirements, but it will not process uploaded spreadsheets.',
|
|
46
|
+
'accepted_sources' => ['xlsx', 'csv'],
|
|
47
|
+
'minimum_columns' => [
|
|
48
|
+
'pickup address or pickup place',
|
|
49
|
+
'dropoff address or dropoff place',
|
|
50
|
+
'customer/contact reference',
|
|
51
|
+
'order type/configuration when required',
|
|
52
|
+
'item/entity details when required',
|
|
53
|
+
],
|
|
54
|
+
'draft_hints' => [
|
|
55
|
+
'Ask the user to upload a spreadsheet through the Fleet-Ops import flow.',
|
|
56
|
+
'Do not claim rows were imported.',
|
|
57
|
+
'Offer to summarize expected columns or validation issues once file inspection support exists.',
|
|
58
|
+
],
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected function matchesPrompt(string $prompt): bool
|
|
63
|
+
{
|
|
64
|
+
return $this->containsAny($prompt, ['import', 'spreadsheet', 'excel', 'xlsx', 'csv']) && $this->containsAny($prompt, ['order', 'orders', 'resources', 'vehicles', 'drivers']);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
use Fleetbase\Ai\Services\AiQueryExecutor;
|
|
7
|
+
use Fleetbase\Ai\Support\AiRelativeDateResolver;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
9
|
+
use Fleetbase\FleetOps\Models\ServiceArea;
|
|
10
|
+
use Fleetbase\FleetOps\Models\Zone;
|
|
11
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
12
|
+
|
|
13
|
+
class OperationalQueryCapability extends AbstractFleetOpsAICapability
|
|
14
|
+
{
|
|
15
|
+
public function key(): string
|
|
16
|
+
{
|
|
17
|
+
return 'fleet-ops.operational_query';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public function label(): string
|
|
21
|
+
{
|
|
22
|
+
return 'Fleet-Ops operational query';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public function description(): string
|
|
26
|
+
{
|
|
27
|
+
return 'Executes safe allowlisted Fleet-Ops operational queries and aggregate summaries.';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public function permissions(): array
|
|
31
|
+
{
|
|
32
|
+
return [
|
|
33
|
+
'fleet-ops list driver',
|
|
34
|
+
'fleet-ops list vehicle',
|
|
35
|
+
'fleet-ops list device',
|
|
36
|
+
'fleet-ops list order',
|
|
37
|
+
'fleet-ops list fleet',
|
|
38
|
+
'fleet-ops list service-area',
|
|
39
|
+
'fleet-ops list zone',
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public function resolve(AiTask $task): array
|
|
44
|
+
{
|
|
45
|
+
$prompt = $this->prompt($task);
|
|
46
|
+
$executor = app(AiQueryExecutor::class);
|
|
47
|
+
$queries = [];
|
|
48
|
+
|
|
49
|
+
if ($this->mentions($prompt, ['driver', 'drivers'])) {
|
|
50
|
+
$queries['drivers'] = $this->driverQueries($prompt, $executor);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ($this->mentions($prompt, ['vehicle', 'vehicles'])) {
|
|
54
|
+
$queries['vehicles'] = $this->onlineResourceQueries('fleet-ops.vehicles', $prompt, $executor);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ($this->mentions($prompt, ['device', 'devices'])) {
|
|
58
|
+
$queries['devices'] = $this->onlineResourceQueries('fleet-ops.devices', $prompt, $executor);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ($this->mentions($prompt, ['order', 'orders'])) {
|
|
62
|
+
$queries['orders'] = $this->orderQueries($prompt, $executor);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ($this->mentions($prompt, ['fleet', 'fleets'])) {
|
|
66
|
+
$queries['fleets'] = [
|
|
67
|
+
'total' => $executor->count('fleet-ops.fleets'),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
'authorized' => true,
|
|
73
|
+
'query_engine' => 'fleetbase_ai_allowlisted_operational_query',
|
|
74
|
+
'instruction' => 'Answer only from these executed Fleetbase query summaries. If a requested metric is missing or empty, say that Fleetbase did not return enough data for that specific metric.',
|
|
75
|
+
'queries' => array_filter($queries),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
protected function matchesPrompt(string $prompt): bool
|
|
80
|
+
{
|
|
81
|
+
return $this->mentions($prompt, ['driver', 'drivers', 'vehicle', 'vehicles', 'device', 'devices', 'order', 'orders', 'fleet', 'fleets'])
|
|
82
|
+
&& $this->mentions($prompt, ['how many', 'count', 'where', 'located', 'location', 'majority', 'online', 'offline', 'without', 'unassigned', 'assigned', 'break down', 'status', 'service area', 'zone', 'today', 'yesterday', 'tomorrow', 'this week', 'last week', 'next week', 'this month', 'last month', 'last 30 days']);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected function driverQueries(string $prompt, AiQueryExecutor $executor): array
|
|
86
|
+
{
|
|
87
|
+
$dateWindow = $this->dateWindow($prompt);
|
|
88
|
+
$dateFilters = $dateWindow ? $this->dateFilters($dateWindow, 'updated_at') : [];
|
|
89
|
+
|
|
90
|
+
$queries = [
|
|
91
|
+
'total' => $executor->count('fleet-ops.drivers'),
|
|
92
|
+
'online' => $executor->count('fleet-ops.drivers', [['field' => 'online', 'operator' => '=', 'value' => true]]),
|
|
93
|
+
'offline' => $executor->count('fleet-ops.drivers', [['field' => 'online', 'operator' => 'false_or_null']]),
|
|
94
|
+
'counts_by_status' => $executor->countsBy('fleet-ops.drivers', 'status'),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
if ($dateWindow) {
|
|
98
|
+
$queries['date_window'] = $this->dateWindowPayload($dateWindow, 'updated_at');
|
|
99
|
+
$queries['total_in_window'] = $executor->count('fleet-ops.drivers', $dateFilters);
|
|
100
|
+
$queries['online_in_window'] = $executor->count('fleet-ops.drivers', array_merge($dateFilters, [['field' => 'online', 'operator' => '=', 'value' => true]]));
|
|
101
|
+
$queries['offline_in_window'] = $executor->count('fleet-ops.drivers', array_merge($dateFilters, [['field' => 'online', 'operator' => 'false_or_null']]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ($this->mentions($prompt, ['without vehicle', 'without vehicles', 'unassigned vehicle', 'no vehicle'])) {
|
|
105
|
+
$queries['without_vehicle'] = [
|
|
106
|
+
'count' => $executor->count('fleet-ops.drivers', [['field' => 'vehicle_uuid', 'operator' => 'null']]),
|
|
107
|
+
'samples' => $executor->samples('fleet-ops.drivers', [['field' => 'vehicle_uuid', 'operator' => 'null']], 10),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if ($this->mentions($prompt, ['where', 'located', 'location', 'majority', 'service area', 'zone'])) {
|
|
112
|
+
$filters = array_merge($dateFilters, $this->mentions($prompt, ['online']) ? [['field' => 'online', 'operator' => '=', 'value' => true]] : []);
|
|
113
|
+
|
|
114
|
+
$queries['location_summary'] = $executor->locationSummary('fleet-ops.drivers', $filters, 250);
|
|
115
|
+
$queries['service_area_distribution'] = $this->driverGeofenceDistribution($filters);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return $queries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
protected function onlineResourceQueries(string $resource, string $prompt, AiQueryExecutor $executor): array
|
|
122
|
+
{
|
|
123
|
+
$dateWindow = $this->dateWindow($prompt);
|
|
124
|
+
$dateField = $resource === 'fleet-ops.devices' ? 'last_online_at' : 'updated_at';
|
|
125
|
+
$dateFilters = $dateWindow ? $this->dateFilters($dateWindow, $dateField) : [];
|
|
126
|
+
|
|
127
|
+
$queries = [
|
|
128
|
+
'total' => $executor->count($resource),
|
|
129
|
+
'online' => $executor->count($resource, [['field' => 'online', 'operator' => '=', 'value' => true]]),
|
|
130
|
+
'offline' => $executor->count($resource, [['field' => 'online', 'operator' => 'false_or_null']]),
|
|
131
|
+
'counts_by_status' => $executor->countsBy($resource, 'status'),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
if ($dateWindow) {
|
|
135
|
+
$queries['date_window'] = $this->dateWindowPayload($dateWindow, $dateField);
|
|
136
|
+
$queries['total_in_window'] = $executor->count($resource, $dateFilters);
|
|
137
|
+
$queries['online_in_window'] = $executor->count($resource, array_merge($dateFilters, [['field' => 'online', 'operator' => '=', 'value' => true]]));
|
|
138
|
+
$queries['offline_in_window'] = $executor->count($resource, array_merge($dateFilters, [['field' => 'online', 'operator' => 'false_or_null']]));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if ($this->mentions($prompt, ['where', 'located', 'location', 'majority']) && $resource === 'fleet-ops.vehicles') {
|
|
142
|
+
$queries['location_summary'] = $executor->locationSummary($resource, array_merge($dateFilters, $this->mentions($prompt, ['online']) ? [['field' => 'online', 'operator' => '=', 'value' => true]] : []), 250);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return $queries;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
protected function orderQueries(string $prompt, AiQueryExecutor $executor): array
|
|
149
|
+
{
|
|
150
|
+
$filters = $this->orderDateFilters($prompt);
|
|
151
|
+
|
|
152
|
+
if ($this->mentions($prompt, ['active'])) {
|
|
153
|
+
$filters[] = ['field' => 'status', 'operator' => 'not_in', 'value' => ['canceled', 'completed', 'expired']];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
$queries = [
|
|
157
|
+
'total' => $executor->count('fleet-ops.orders', $filters),
|
|
158
|
+
'counts_by_status' => $executor->countsBy('fleet-ops.orders', 'status', $filters),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
if ($dateWindow = $this->dateWindow($prompt)) {
|
|
162
|
+
$queries['date_window'] = $this->dateWindowPayload($dateWindow, 'created_at');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if ($this->mentions($prompt, ['without driver', 'unassigned driver', 'no driver'])) {
|
|
166
|
+
$queries['without_driver'] = $executor->count('fleet-ops.orders', array_merge($filters, [['field' => 'driver_assigned_uuid', 'operator' => 'null']]));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if ($this->mentions($prompt, ['assigned driver', 'with driver'])) {
|
|
170
|
+
$queries['with_driver'] = $executor->count('fleet-ops.orders', array_merge($filters, [['field' => 'driver_assigned_uuid', 'operator' => 'not_null']]));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if ($this->mentions($prompt, ['without vehicle', 'unassigned vehicle', 'no vehicle'])) {
|
|
174
|
+
$queries['without_vehicle'] = $executor->count('fleet-ops.orders', array_merge($filters, [['field' => 'vehicle_assigned_uuid', 'operator' => 'null']]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return $queries;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
protected function driverGeofenceDistribution(array $filters = []): array
|
|
181
|
+
{
|
|
182
|
+
if (!$this->can('fleet-ops list driver')) {
|
|
183
|
+
return ['authorized' => false, 'resource' => 'fleet-ops.drivers'];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
$query = Driver::where('company_uuid', session('company'))
|
|
187
|
+
->applyDirectivesForPermissions('fleet-ops list driver');
|
|
188
|
+
|
|
189
|
+
foreach ($filters as $filter) {
|
|
190
|
+
if (($filter['field'] ?? null) === 'online') {
|
|
191
|
+
$query->where('online', (bool) ($filter['value'] ?? false));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (($filter['field'] ?? null) === 'updated_at' && in_array($filter['operator'] ?? null, ['>=', '<='], true)) {
|
|
195
|
+
$query->where('updated_at', $filter['operator'], $filter['value']);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
$drivers = $this->whereValidDriverLocation($query)
|
|
200
|
+
->latest()
|
|
201
|
+
->limit(250)
|
|
202
|
+
->get(['uuid', 'public_id', 'company_uuid', 'location', 'city', 'country', 'online']);
|
|
203
|
+
|
|
204
|
+
if ($drivers->isEmpty()) {
|
|
205
|
+
return [
|
|
206
|
+
'authorized' => true,
|
|
207
|
+
'valid_location_count' => 0,
|
|
208
|
+
'service_areas' => [],
|
|
209
|
+
'zones' => [],
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
$serviceAreaCounts = [];
|
|
214
|
+
$zoneCounts = [];
|
|
215
|
+
|
|
216
|
+
foreach ($drivers as $driver) {
|
|
217
|
+
$point = $driver->location;
|
|
218
|
+
if (!is_object($point) || !method_exists($point, 'getLat') || !method_exists($point, 'getLng')) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
$wkt = sprintf('POINT(%s %s)', $point->getLng(), $point->getLat());
|
|
223
|
+
|
|
224
|
+
if ($this->can('fleet-ops list service-area')) {
|
|
225
|
+
ServiceArea::where('company_uuid', session('company'))
|
|
226
|
+
->whereNotNull('border')
|
|
227
|
+
->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
|
|
228
|
+
->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
|
|
229
|
+
->get(['uuid', 'name', 'public_id'])
|
|
230
|
+
->each(function (ServiceArea $serviceArea) use (&$serviceAreaCounts) {
|
|
231
|
+
$key = $serviceArea->uuid;
|
|
232
|
+
$serviceAreaCounts[$key] ??= [
|
|
233
|
+
'uuid' => $serviceArea->uuid,
|
|
234
|
+
'public_id' => $serviceArea->public_id,
|
|
235
|
+
'name' => $serviceArea->name,
|
|
236
|
+
'count' => 0,
|
|
237
|
+
];
|
|
238
|
+
$serviceAreaCounts[$key]['count']++;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if ($this->can('fleet-ops list zone')) {
|
|
243
|
+
Zone::where('company_uuid', session('company'))
|
|
244
|
+
->whereNotNull('border')
|
|
245
|
+
->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
|
|
246
|
+
->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
|
|
247
|
+
->get(['uuid', 'name', 'public_id', 'service_area_uuid'])
|
|
248
|
+
->each(function (Zone $zone) use (&$zoneCounts) {
|
|
249
|
+
$key = $zone->uuid;
|
|
250
|
+
$zoneCounts[$key] ??= [
|
|
251
|
+
'uuid' => $zone->uuid,
|
|
252
|
+
'public_id' => $zone->public_id,
|
|
253
|
+
'name' => $zone->name,
|
|
254
|
+
'service_area_uuid' => $zone->service_area_uuid,
|
|
255
|
+
'count' => 0,
|
|
256
|
+
];
|
|
257
|
+
$zoneCounts[$key]['count']++;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return [
|
|
263
|
+
'authorized' => true,
|
|
264
|
+
'valid_location_count' => $drivers->count(),
|
|
265
|
+
'service_areas' => collect($serviceAreaCounts)->sortByDesc('count')->take(10)->values()->all(),
|
|
266
|
+
'zones' => collect($zoneCounts)->sortByDesc('count')->take(10)->values()->all(),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
protected function whereValidDriverLocation(Builder $query): Builder
|
|
271
|
+
{
|
|
272
|
+
return $query->whereNotNull('location')->whereRaw('
|
|
273
|
+
ST_Y(location) BETWEEN -90 AND 90
|
|
274
|
+
AND ST_X(location) BETWEEN -180 AND 180
|
|
275
|
+
AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
|
|
276
|
+
');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
protected function orderDateFilters(string $prompt): array
|
|
280
|
+
{
|
|
281
|
+
$window = $this->dateWindow($prompt);
|
|
282
|
+
|
|
283
|
+
return $window ? $this->dateFilters($window, 'created_at') : [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
protected function dateWindow(string $prompt): ?array
|
|
287
|
+
{
|
|
288
|
+
return $this->relativeDateResolver()->resolveWindow($prompt);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
protected function dateFilters(array $window, string $field): array
|
|
292
|
+
{
|
|
293
|
+
return [
|
|
294
|
+
['field' => $field, 'operator' => '>=', 'value' => $window['start']],
|
|
295
|
+
['field' => $field, 'operator' => '<=', 'value' => $window['end']],
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected function dateWindowPayload(array $window, string $field): array
|
|
300
|
+
{
|
|
301
|
+
return [
|
|
302
|
+
'label' => $window['label'],
|
|
303
|
+
'timezone' => $window['timezone'],
|
|
304
|
+
'field' => $field,
|
|
305
|
+
'start' => $window['start']->toIso8601String(),
|
|
306
|
+
'end' => $window['end']->toIso8601String(),
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
protected function relativeDateResolver(): AiRelativeDateResolver
|
|
311
|
+
{
|
|
312
|
+
return function_exists('app') ? app(AiRelativeDateResolver::class) : new AiRelativeDateResolver(null);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
protected function mentions(string $prompt, array $terms): bool
|
|
316
|
+
{
|
|
317
|
+
return $this->containsAny($prompt, $terms);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Contracts\AIActionCapabilityInterface;
|
|
6
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
7
|
+
use Fleetbase\FleetOps\Models\Order;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Place;
|
|
9
|
+
use Illuminate\Support\Facades\DB;
|
|
10
|
+
|
|
11
|
+
class OptimizeOrderRouteCapability extends AbstractFleetOpsAICapability implements AIActionCapabilityInterface
|
|
12
|
+
{
|
|
13
|
+
public function key(): string
|
|
14
|
+
{
|
|
15
|
+
return 'fleet-ops.optimize_order_route';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public function label(): string
|
|
19
|
+
{
|
|
20
|
+
return 'Optimize Fleet-Ops order route';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public function description(): string
|
|
24
|
+
{
|
|
25
|
+
return 'Previews and applies confirmed waypoint resequencing for a Fleet-Ops order route.';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public function type(): string
|
|
29
|
+
{
|
|
30
|
+
return 'action';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public function mode(): string
|
|
34
|
+
{
|
|
35
|
+
return 'confirmation_required';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function permissions(): array
|
|
39
|
+
{
|
|
40
|
+
return ['fleet-ops optimize order', 'fleet-ops update-route-for order'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public function previewOnly(): bool
|
|
44
|
+
{
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public function executable(): bool
|
|
49
|
+
{
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public function inputSchema(): array
|
|
54
|
+
{
|
|
55
|
+
return [
|
|
56
|
+
'order' => 'Fleet-Ops order UUID or public ID.',
|
|
57
|
+
'waypoints' => 'Ordered waypoint place UUIDs generated by the preview.',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public function shouldPreview(AiTask $task): bool
|
|
62
|
+
{
|
|
63
|
+
return $this->matchesPrompt($this->prompt($task));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public function resolve(AiTask $task): array
|
|
67
|
+
{
|
|
68
|
+
return $this->preview($task);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public function preview(AiTask $task, array $input = []): array
|
|
72
|
+
{
|
|
73
|
+
$authorized = $this->canAll($this->permissions());
|
|
74
|
+
$orders = $this->resolveOrders($task);
|
|
75
|
+
|
|
76
|
+
if ($orders->count() > 1) {
|
|
77
|
+
return [
|
|
78
|
+
'action' => $this->key(),
|
|
79
|
+
'authorized' => $authorized,
|
|
80
|
+
'ready' => false,
|
|
81
|
+
'preview_only' => true,
|
|
82
|
+
'message' => 'Fleetbase AI found multiple orders. Multi-order route sequencing should use the Fleet-Ops Orchestrator preview before commit.',
|
|
83
|
+
'apply_label' => 'Open Orchestrator',
|
|
84
|
+
'cancel_label' => 'Cancel',
|
|
85
|
+
'missing_fields' => ['single order selection for direct route apply'],
|
|
86
|
+
'fields' => $orders->map(fn (Order $order) => ['label' => 'Order', 'value' => $order->public_id ?: $order->uuid])->values()->all(),
|
|
87
|
+
'draft' => ['orders' => $orders->pluck('public_id')->filter()->values()->all(), 'mode' => 'optimize_routes'],
|
|
88
|
+
'risks' => ['Multi-order optimization can affect manifests and should be committed through Orchestrator review.'],
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$order = $orders->first();
|
|
93
|
+
if (!$order) {
|
|
94
|
+
return [
|
|
95
|
+
'action' => $this->key(),
|
|
96
|
+
'authorized' => $authorized,
|
|
97
|
+
'ready' => false,
|
|
98
|
+
'message' => 'Fleetbase AI needs a specific Fleet-Ops order ID before it can optimize a route.',
|
|
99
|
+
'apply_label' => 'Optimize route',
|
|
100
|
+
'cancel_label' => 'Cancel',
|
|
101
|
+
'missing_fields' => ['order UUID or public ID'],
|
|
102
|
+
'fields' => [],
|
|
103
|
+
'draft' => [],
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$order->loadMissing(['payload.pickup', 'payload.dropoff', 'payload.waypoints.place']);
|
|
108
|
+
$waypoints = $order->payload?->waypoints?->sortBy('order')->values() ?? collect();
|
|
109
|
+
$optimized = $this->optimizeWaypoints($order, $waypoints);
|
|
110
|
+
$changed = $waypoints->pluck('place_uuid')->values()->all() !== $optimized->pluck('place_uuid')->values()->all();
|
|
111
|
+
$missing = array_values(array_filter([
|
|
112
|
+
$authorized ? null : 'permission to optimize and update Fleet-Ops order routes',
|
|
113
|
+
$waypoints->count() >= 2 ? null : 'at least two route waypoints',
|
|
114
|
+
$changed ? null : 'a route with a different optimized waypoint sequence',
|
|
115
|
+
]));
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
'action' => $this->key(),
|
|
119
|
+
'authorized' => $authorized,
|
|
120
|
+
'ready' => empty($missing),
|
|
121
|
+
'message' => empty($missing) ? 'Fleetbase AI prepared an optimized waypoint sequence. Review it before applying.' : 'Fleetbase AI cannot apply a route optimization until the route has optimizable waypoints.',
|
|
122
|
+
'apply_label' => 'Apply route',
|
|
123
|
+
'cancel_label' => 'Cancel',
|
|
124
|
+
'missing_fields' => $missing,
|
|
125
|
+
'fields' => [
|
|
126
|
+
['label' => 'Order', 'value' => $order->public_id ?: $order->uuid],
|
|
127
|
+
['label' => 'Current sequence', 'value' => $this->stopLabels($waypoints)->implode(' -> ')],
|
|
128
|
+
['label' => 'Proposed sequence', 'value' => $this->stopLabels($optimized)->implode(' -> ')],
|
|
129
|
+
],
|
|
130
|
+
'draft' => [
|
|
131
|
+
'order_uuid' => $order->uuid,
|
|
132
|
+
'waypoints' => $optimized->values()->map(fn ($waypoint, int $index) => [
|
|
133
|
+
'place_uuid' => $waypoint->place_uuid,
|
|
134
|
+
'order' => $index,
|
|
135
|
+
'type' => $waypoint->type ?: 'dropoff',
|
|
136
|
+
])->all(),
|
|
137
|
+
],
|
|
138
|
+
'risks' => ['Applying this changes the order waypoint sequence only after confirmation.'],
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public function apply(AiTask $task, array $preview = [], array $input = []): array
|
|
143
|
+
{
|
|
144
|
+
if (!$this->canAll($this->permissions())) {
|
|
145
|
+
throw new \RuntimeException('You do not have permission to optimize Fleet-Ops order routes.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!data_get($preview, 'ready')) {
|
|
149
|
+
throw new \RuntimeException('This route optimization preview is not ready to apply.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$order = Order::where('company_uuid', session('company'))
|
|
153
|
+
->where('uuid', data_get($preview, 'draft.order_uuid'))
|
|
154
|
+
->with(['payload'])
|
|
155
|
+
->first();
|
|
156
|
+
|
|
157
|
+
if (!$order || !$order->payload) {
|
|
158
|
+
throw new \RuntimeException('Unable to find the Fleet-Ops order route to update.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
$waypoints = array_values((array) data_get($preview, 'draft.waypoints', []));
|
|
162
|
+
if (count($waypoints) < 2) {
|
|
163
|
+
throw new \RuntimeException('The route optimization preview does not include enough waypoints to apply.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
DB::transaction(function () use ($order, $waypoints) {
|
|
167
|
+
$order->payload->updateWaypoints($waypoints);
|
|
168
|
+
$order->is_route_optimized = true;
|
|
169
|
+
$order->save();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return [
|
|
173
|
+
'action' => $this->key(),
|
|
174
|
+
'status' => 'completed',
|
|
175
|
+
'message' => 'Fleet-Ops order route was optimized.',
|
|
176
|
+
'resource' => [
|
|
177
|
+
'type' => 'order',
|
|
178
|
+
'id' => $order->public_id,
|
|
179
|
+
'uuid' => $order->uuid,
|
|
180
|
+
'route' => 'console.fleet-ops.operations.orders.index.details',
|
|
181
|
+
'models' => array_filter([$order->public_id ?: $order->uuid]),
|
|
182
|
+
],
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
protected function matchesPrompt(string $prompt): bool
|
|
187
|
+
{
|
|
188
|
+
return str_contains($prompt, 'order') && $this->containsAny($prompt, ['optimize route', 'optimise route', 'route optimization', 'route optimisation', 'optimize the route', 'optimise the route']);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
protected function resolveOrders(AiTask $task)
|
|
192
|
+
{
|
|
193
|
+
$terms = $this->searchTerms((string) $task->prompt);
|
|
194
|
+
|
|
195
|
+
return Order::where('company_uuid', session('company'))
|
|
196
|
+
->where(function ($query) use ($terms) {
|
|
197
|
+
$this->whereLikeAny($query, ['uuid', 'public_id', 'internal_id'], $terms);
|
|
198
|
+
})
|
|
199
|
+
->with(['payload.pickup', 'payload.dropoff', 'payload.waypoints.place'])
|
|
200
|
+
->limit(10)
|
|
201
|
+
->get();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
protected function optimizeWaypoints(Order $order, $waypoints)
|
|
205
|
+
{
|
|
206
|
+
if ($waypoints->count() < 2) {
|
|
207
|
+
return $waypoints;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
$remaining = $waypoints->values();
|
|
211
|
+
$optimized = collect();
|
|
212
|
+
$cursor = $order->payload?->pickup;
|
|
213
|
+
|
|
214
|
+
while ($remaining->isNotEmpty()) {
|
|
215
|
+
$nearest = $remaining
|
|
216
|
+
->sortBy(fn ($waypoint) => $this->distance($cursor, $waypoint->place))
|
|
217
|
+
->first();
|
|
218
|
+
|
|
219
|
+
$optimized->push($nearest);
|
|
220
|
+
$remaining = $remaining->reject(fn ($waypoint) => $waypoint->uuid === $nearest->uuid)->values();
|
|
221
|
+
$cursor = $nearest->place;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return $optimized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
protected function distance(?Place $from, ?Place $to): float
|
|
228
|
+
{
|
|
229
|
+
if (!$from || !$to || !is_numeric($from->lat) || !is_numeric($from->lng) || !is_numeric($to->lat) || !is_numeric($to->lng)) {
|
|
230
|
+
return PHP_FLOAT_MAX;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return sqrt((float) (($from->lat - $to->lat) ** 2 + ($from->lng - $to->lng) ** 2));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
protected function stopLabels($waypoints)
|
|
237
|
+
{
|
|
238
|
+
return $waypoints->map(fn ($waypoint) => $waypoint->place?->name ?: $waypoint->place?->public_id ?: $waypoint->place_uuid);
|
|
239
|
+
}
|
|
240
|
+
}
|