@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,108 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
use Fleetbase\FleetOps\Models\Device;
|
|
7
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Sensor;
|
|
9
|
+
use Fleetbase\FleetOps\Models\Telematic;
|
|
10
|
+
use Fleetbase\FleetOps\Models\Vehicle;
|
|
11
|
+
|
|
12
|
+
class AssetStatusCapability extends AbstractFleetOpsAICapability
|
|
13
|
+
{
|
|
14
|
+
public function key(): string
|
|
15
|
+
{
|
|
16
|
+
return 'fleet-ops.asset_status';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public function label(): string
|
|
20
|
+
{
|
|
21
|
+
return 'Fleet-Ops asset status';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function description(): string
|
|
25
|
+
{
|
|
26
|
+
return 'Summarizes Fleet-Ops vehicle, device, sensor, and telematic status counts.';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public function permissions(): array
|
|
30
|
+
{
|
|
31
|
+
return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function resolve(AiTask $task): array
|
|
35
|
+
{
|
|
36
|
+
return [
|
|
37
|
+
'drivers' => $this->driverStatus(),
|
|
38
|
+
'vehicles' => $this->statusCounts(Vehicle::class, 'fleet-ops see vehicle'),
|
|
39
|
+
'devices' => $this->deviceStatus(),
|
|
40
|
+
'sensors' => $this->statusCounts(Sensor::class, 'fleet-ops see sensor'),
|
|
41
|
+
'telematics' => $this->statusCounts(Telematic::class, 'fleet-ops see telematic'),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
protected function matchesPrompt(string $prompt): bool
|
|
46
|
+
{
|
|
47
|
+
return $this->containsAny($prompt, ['offline', 'online', 'asset status', 'driver status', 'vehicle status', 'device status', 'sensor status', 'telematic status', 'drivers down', 'devices down', 'vehicles down']);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected function statusCounts(string $modelClass, string $permission): array
|
|
51
|
+
{
|
|
52
|
+
if (!$this->can($permission)) {
|
|
53
|
+
return ['authorized' => false];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
'authorized' => true,
|
|
58
|
+
'total' => $modelClass::where('company_uuid', session('company'))->count(),
|
|
59
|
+
'counts_by_status' => $modelClass::where('company_uuid', session('company'))
|
|
60
|
+
->selectRaw('status, count(*) as aggregate')
|
|
61
|
+
->groupBy('status')
|
|
62
|
+
->pluck('aggregate', 'status')
|
|
63
|
+
->all(),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
protected function deviceStatus(): array
|
|
68
|
+
{
|
|
69
|
+
if (!$this->can('fleet-ops see device')) {
|
|
70
|
+
return ['authorized' => false];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
'authorized' => true,
|
|
75
|
+
'total' => Device::where('company_uuid', session('company'))->count(),
|
|
76
|
+
'online' => Device::where('company_uuid', session('company'))->where('online', true)->count(),
|
|
77
|
+
'offline' => Device::where('company_uuid', session('company'))->where(function ($query) {
|
|
78
|
+
$query->where('online', false)->orWhereNull('online');
|
|
79
|
+
})->count(),
|
|
80
|
+
'counts_by_status' => Device::where('company_uuid', session('company'))
|
|
81
|
+
->selectRaw('status, count(*) as aggregate')
|
|
82
|
+
->groupBy('status')
|
|
83
|
+
->pluck('aggregate', 'status')
|
|
84
|
+
->all(),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected function driverStatus(): array
|
|
89
|
+
{
|
|
90
|
+
if (!$this->can('fleet-ops see driver')) {
|
|
91
|
+
return ['authorized' => false];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
'authorized' => true,
|
|
96
|
+
'total' => Driver::where('company_uuid', session('company'))->count(),
|
|
97
|
+
'online' => Driver::where('company_uuid', session('company'))->where('online', true)->count(),
|
|
98
|
+
'offline' => Driver::where('company_uuid', session('company'))->where(function ($query) {
|
|
99
|
+
$query->where('online', false)->orWhereNull('online');
|
|
100
|
+
})->count(),
|
|
101
|
+
'counts_by_status' => Driver::where('company_uuid', session('company'))
|
|
102
|
+
->selectRaw('status, count(*) as aggregate')
|
|
103
|
+
->groupBy('status')
|
|
104
|
+
->pluck('aggregate', 'status')
|
|
105
|
+
->all(),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
|
|
7
|
+
class ConsoleNavigationCapability extends SearchResourcesCapability
|
|
8
|
+
{
|
|
9
|
+
public function key(): string
|
|
10
|
+
{
|
|
11
|
+
return 'fleet-ops.console_navigation';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public function label(): string
|
|
15
|
+
{
|
|
16
|
+
return 'Fleet-Ops console navigation preview';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public function description(): string
|
|
20
|
+
{
|
|
21
|
+
return 'Returns preview-only console route suggestions for Fleet-Ops resources.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function type(): string
|
|
25
|
+
{
|
|
26
|
+
return 'ui';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public function mode(): string
|
|
30
|
+
{
|
|
31
|
+
return 'navigation_preview';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function resolve(AiTask $task): array
|
|
35
|
+
{
|
|
36
|
+
$search = parent::resolve($task);
|
|
37
|
+
$items = collect(data_get($search, 'results', []))
|
|
38
|
+
->flatten(1)
|
|
39
|
+
->filter(fn ($result) => is_array($result) && isset($result['route']))
|
|
40
|
+
->take(5)
|
|
41
|
+
->values()
|
|
42
|
+
->all();
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
'preview_only' => true,
|
|
46
|
+
'message' => 'Fleetbase AI can suggest where to navigate, but this pilot does not directly open panels yet.',
|
|
47
|
+
'suggestions' => $items,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected function matchesPrompt(string $prompt): bool
|
|
52
|
+
{
|
|
53
|
+
return $this->containsAny($prompt, ['open', 'show me', 'go to', 'navigate', 'take me to']) && parent::matchesPrompt($prompt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,508 @@
|
|
|
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\Ai\Support\AiRelativeDateResolver;
|
|
8
|
+
use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController;
|
|
9
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
10
|
+
use Fleetbase\FleetOps\Models\OrderConfig;
|
|
11
|
+
use Fleetbase\FleetOps\Models\Place;
|
|
12
|
+
use Fleetbase\FleetOps\Models\Vehicle;
|
|
13
|
+
use Fleetbase\FleetOps\Support\PlaceSearch;
|
|
14
|
+
use Fleetbase\FleetOps\Support\Utils;
|
|
15
|
+
use Illuminate\Http\JsonResponse;
|
|
16
|
+
use Illuminate\Http\Request;
|
|
17
|
+
use Illuminate\Support\Arr;
|
|
18
|
+
use Illuminate\Support\Str;
|
|
19
|
+
|
|
20
|
+
class CreateOrderPreviewCapability extends AbstractFleetOpsAICapability implements AIActionCapabilityInterface
|
|
21
|
+
{
|
|
22
|
+
public function key(): string
|
|
23
|
+
{
|
|
24
|
+
return 'fleet-ops.create_order';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public function label(): string
|
|
28
|
+
{
|
|
29
|
+
return 'Create Fleet-Ops order preview';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public function description(): string
|
|
33
|
+
{
|
|
34
|
+
return 'Builds and applies confirmed Fleet-Ops order creation drafts.';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public function type(): string
|
|
38
|
+
{
|
|
39
|
+
return 'action';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public function mode(): string
|
|
43
|
+
{
|
|
44
|
+
return 'confirmation_required';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public function permissions(): array
|
|
48
|
+
{
|
|
49
|
+
return ['fleet-ops create order'];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public function previewOnly(): bool
|
|
53
|
+
{
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public function executable(): bool
|
|
58
|
+
{
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public function inputSchema(): array
|
|
63
|
+
{
|
|
64
|
+
return [
|
|
65
|
+
'order_config_uuid' => 'Fleet-Ops order config UUID. Defaults to the company transport config when available.',
|
|
66
|
+
'payload.pickup_uuid' => 'Existing Fleet-Ops place UUID for pickup.',
|
|
67
|
+
'payload.dropoff_uuid' => 'Existing Fleet-Ops place UUID for dropoff.',
|
|
68
|
+
'payload.waypoints' => 'Optional ordered waypoint place UUIDs.',
|
|
69
|
+
'customer' => 'Optional contact/vendor UUID.',
|
|
70
|
+
'scheduled_at' => 'Optional scheduled date/time for the order.',
|
|
71
|
+
'dispatched' => 'Optional boolean. Defaults to false for AI-created drafts.',
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public function shouldPreview(AiTask $task): bool
|
|
76
|
+
{
|
|
77
|
+
return $this->matchesPrompt($this->prompt($task));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public function resolve(AiTask $task): array
|
|
81
|
+
{
|
|
82
|
+
return $this->preview($task);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public function preview(AiTask $task, array $input = []): array
|
|
86
|
+
{
|
|
87
|
+
$authorized = $this->canAll($this->permissions());
|
|
88
|
+
$draft = $this->buildDraft($task, $input);
|
|
89
|
+
$orderConfig = $this->resolveOrderConfig($draft);
|
|
90
|
+
$pickup = data_get($draft, 'payload.pickup') ?: data_get($draft, 'payload.pickup_query');
|
|
91
|
+
$dropoff = data_get($draft, 'payload.dropoff') ?: data_get($draft, 'payload.dropoff_query');
|
|
92
|
+
$driver = $this->resolveDriver($draft);
|
|
93
|
+
$vehicle = $this->resolveVehicle($draft);
|
|
94
|
+
|
|
95
|
+
if ($orderConfig) {
|
|
96
|
+
$draft['order_config_uuid'] = $orderConfig->uuid;
|
|
97
|
+
$draft['type'] = $orderConfig->key;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ($driver) {
|
|
101
|
+
$draft['driver'] = $driver->uuid;
|
|
102
|
+
$draft['driver_assigned_uuid'] = $driver->uuid;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if ($vehicle) {
|
|
106
|
+
$draft['vehicle_assigned_uuid'] = $vehicle->uuid;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
$missing = array_values(array_filter([
|
|
110
|
+
$authorized ? null : 'permission to create Fleet-Ops orders',
|
|
111
|
+
$orderConfig ? null : 'order configuration',
|
|
112
|
+
$pickup ? null : 'pickup address or place',
|
|
113
|
+
$dropoff ? null : 'dropoff address or place',
|
|
114
|
+
]));
|
|
115
|
+
|
|
116
|
+
return [
|
|
117
|
+
'action' => $this->key(),
|
|
118
|
+
'authorized' => $authorized,
|
|
119
|
+
'ready' => empty($missing),
|
|
120
|
+
'missing_fields' => $missing,
|
|
121
|
+
'message' => empty($missing) ? 'Fleetbase AI prepared a Fleet-Ops order draft. Review it before applying.' : 'Fleetbase AI can create the order after these required details are resolved.',
|
|
122
|
+
'apply_label' => 'Create order',
|
|
123
|
+
'cancel_label' => 'Cancel',
|
|
124
|
+
'draft' => $draft,
|
|
125
|
+
'route_preview' => $this->routePreview($draft),
|
|
126
|
+
'options' => [
|
|
127
|
+
'pod_methods' => $this->podMethods(),
|
|
128
|
+
],
|
|
129
|
+
'fields' => [
|
|
130
|
+
['label' => 'Order config', 'value' => $orderConfig?->name ?? $orderConfig?->key],
|
|
131
|
+
['label' => 'Schedule', 'value' => data_get($draft, 'scheduled_at')],
|
|
132
|
+
['label' => 'Pickup', 'value' => data_get($pickup, 'address') ?? data_get($pickup, 'name')],
|
|
133
|
+
['label' => 'Dropoff', 'value' => data_get($dropoff, 'address') ?? data_get($dropoff, 'name')],
|
|
134
|
+
['label' => 'Driver', 'value' => $driver?->name],
|
|
135
|
+
['label' => 'Vehicle', 'value' => $vehicle?->display_name ?? $vehicle?->name],
|
|
136
|
+
['label' => 'Dispatch immediately', 'value' => data_get($draft, 'dispatched') ? 'Yes' : 'No'],
|
|
137
|
+
],
|
|
138
|
+
'risks' => [
|
|
139
|
+
'The order will be created only after confirmation.',
|
|
140
|
+
'AI-created drafts do not dispatch by default.',
|
|
141
|
+
],
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public function apply(AiTask $task, array $preview = [], array $input = []): array
|
|
146
|
+
{
|
|
147
|
+
if (!$this->canAll($this->permissions())) {
|
|
148
|
+
throw new \RuntimeException('You do not have permission to create Fleet-Ops orders.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!data_get($preview, 'ready')) {
|
|
152
|
+
throw new \RuntimeException('This order draft is missing required fields and cannot be applied yet.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
$draft = array_replace_recursive((array) data_get($preview, 'draft', []), (array) data_get($input, 'draft', $input));
|
|
156
|
+
$draft = $this->sanitizeDraftForApply($draft);
|
|
157
|
+
Arr::forget($draft, [
|
|
158
|
+
'payload.pickup_query',
|
|
159
|
+
'payload.dropoff_query',
|
|
160
|
+
'driver_query',
|
|
161
|
+
'vehicle_query',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/** @var OrderController $controller */
|
|
165
|
+
$controller = app(OrderController::class);
|
|
166
|
+
$response = $controller->createRecord(new Request(['order' => $draft]));
|
|
167
|
+
|
|
168
|
+
if ($response instanceof JsonResponse && $response->getStatusCode() >= 400) {
|
|
169
|
+
throw new \RuntimeException((string) $response->getContent());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
$order = data_get($response, 'order');
|
|
173
|
+
|
|
174
|
+
return [
|
|
175
|
+
'action' => $this->key(),
|
|
176
|
+
'status' => 'completed',
|
|
177
|
+
'message' => 'Fleet-Ops order was created.',
|
|
178
|
+
'resource' => [
|
|
179
|
+
'type' => 'order',
|
|
180
|
+
'id' => data_get($order, 'public_id') ?? data_get($order, 'id'),
|
|
181
|
+
'uuid' => data_get($order, 'uuid'),
|
|
182
|
+
'route' => 'console.fleet-ops.operations.orders.index.details',
|
|
183
|
+
'models' => array_filter([data_get($order, 'public_id') ?? data_get($order, 'uuid')]),
|
|
184
|
+
],
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
protected function sanitizeDraftForApply(array $draft): array
|
|
189
|
+
{
|
|
190
|
+
foreach (['pickup', 'dropoff', 'return'] as $role) {
|
|
191
|
+
$uuid = data_get($draft, "payload.{$role}_uuid");
|
|
192
|
+
if ($this->hasExistingPlaceUuid($uuid)) {
|
|
193
|
+
Arr::forget($draft, "payload.{$role}");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return $draft;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
protected function hasExistingPlaceUuid($uuid): bool
|
|
201
|
+
{
|
|
202
|
+
return is_string($uuid) && Str::isUuid($uuid) && Place::where('uuid', $uuid)->exists();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
protected function matchesPrompt(string $prompt): bool
|
|
206
|
+
{
|
|
207
|
+
return str_contains($prompt, 'order') && $this->containsAny($prompt, ['create', 'new order', 'make an order', 'book an order']);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected function buildDraft(AiTask $task, array $input = []): array
|
|
211
|
+
{
|
|
212
|
+
$existing = (array) data_get($task->metadata, 'action_previews.0.draft', []);
|
|
213
|
+
$draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input));
|
|
214
|
+
|
|
215
|
+
$draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN);
|
|
216
|
+
$draft['payload'] = (array) data_get($draft, 'payload', []);
|
|
217
|
+
if (blank(data_get($draft, 'scheduled_at'))) {
|
|
218
|
+
unset($draft['scheduled_at']);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
foreach (['pickup', 'dropoff'] as $role) {
|
|
222
|
+
$query = data_get($draft, "payload.{$role}_query");
|
|
223
|
+
$place = data_get($draft, "payload.{$role}");
|
|
224
|
+
|
|
225
|
+
if (!$place && $query) {
|
|
226
|
+
$place = $this->resolvePlace($query);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if ($place) {
|
|
230
|
+
$draft['payload'][$role] = $place;
|
|
231
|
+
if (!empty($place['uuid'])) {
|
|
232
|
+
$draft['payload']["{$role}_uuid"] = $place['uuid'];
|
|
233
|
+
}
|
|
234
|
+
} elseif ($query) {
|
|
235
|
+
$draft['payload'][$role] = $this->provisionalPlace($query);
|
|
236
|
+
unset($draft['payload']["{$role}_uuid"]);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return $draft;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
protected function draftFromPrompt(string $prompt): array
|
|
244
|
+
{
|
|
245
|
+
$orderConfig = OrderConfig::defaultOrCreate();
|
|
246
|
+
$draft = [
|
|
247
|
+
'order_config_uuid' => $orderConfig?->uuid,
|
|
248
|
+
'type' => $orderConfig?->key,
|
|
249
|
+
'payload' => [],
|
|
250
|
+
'dispatched' => false,
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
[$pickup, $dropoff] = $this->addressPairFromPrompt($prompt);
|
|
254
|
+
if ($pickup) {
|
|
255
|
+
$draft['payload']['pickup_query'] = $pickup;
|
|
256
|
+
if ($place = $this->resolvePlace($pickup)) {
|
|
257
|
+
$draft['payload']['pickup'] = $place;
|
|
258
|
+
if (!empty($place['uuid'])) {
|
|
259
|
+
$draft['payload']['pickup_uuid'] = $place['uuid'];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if ($dropoff) {
|
|
265
|
+
$draft['payload']['dropoff_query'] = $dropoff;
|
|
266
|
+
if ($place = $this->resolvePlace($dropoff)) {
|
|
267
|
+
$draft['payload']['dropoff'] = $place;
|
|
268
|
+
if (!empty($place['uuid'])) {
|
|
269
|
+
$draft['payload']['dropoff_uuid'] = $place['uuid'];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (str_contains(Str::lower($prompt), 'dispatch')) {
|
|
275
|
+
$draft['dispatched'] = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if ($scheduledAt = $this->scheduledAtFromPrompt($prompt)) {
|
|
279
|
+
$draft['scheduled_at'] = $scheduledAt;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (preg_match('/(?:note|notes)[:\s]+(.+)$/i', $prompt, $matches)) {
|
|
283
|
+
$draft['notes'] = trim($matches[1]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if ($this->containsAny(Str::lower($prompt), ['proof of delivery', 'pod', 'signature', 'photo proof', 'scan proof'])) {
|
|
287
|
+
$draft['pod_required'] = true;
|
|
288
|
+
$draft['pod_method'] = str_contains(Str::lower($prompt), 'signature') ? 'signature' : (str_contains(Str::lower($prompt), 'photo') ? 'photo' : 'scan');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return $draft;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
protected function scheduledAtFromPrompt(string $prompt): ?string
|
|
295
|
+
{
|
|
296
|
+
return $this->relativeDateResolver()->resolveDateTime($prompt)?->toIso8601String();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected function relativeDateResolver(): AiRelativeDateResolver
|
|
300
|
+
{
|
|
301
|
+
return function_exists('app') ? app(AiRelativeDateResolver::class) : new AiRelativeDateResolver(null);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
protected function addressPairFromPrompt(string $prompt): array
|
|
305
|
+
{
|
|
306
|
+
preg_match_all('/"([^"]+)"|\'([^\']+)\'/', $prompt, $quoted);
|
|
307
|
+
$quotedValues = collect($quoted[1] ?? [])
|
|
308
|
+
->merge($quoted[2] ?? [])
|
|
309
|
+
->filter()
|
|
310
|
+
->values();
|
|
311
|
+
|
|
312
|
+
if ($quotedValues->count() >= 2) {
|
|
313
|
+
return [$quotedValues->get(0), $quotedValues->get(1)];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (preg_match('/\bfrom\s+(.+?)(?=\s*,?\s+(?:to|towards)\s+)\s*,?\s+(?:to|towards)\s+(.+?)(?=\s+\b(?:with|for|on|using|require|requiring)\b|$)/i', $prompt, $matches)) {
|
|
317
|
+
return [$this->cleanAddress($matches[1]), $this->cleanAddress($matches[2])];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
$pickup = $this->extractLabeledAddress($prompt, ['pickup', 'pick up'], ['dropoff', 'drop off', 'delivery']);
|
|
321
|
+
$dropoff = $this->extractLabeledAddress($prompt, ['dropoff', 'drop off', 'delivery'], ['return', 'with', 'for', 'on', 'using', 'require', 'requiring']);
|
|
322
|
+
|
|
323
|
+
if ($pickup || $dropoff) {
|
|
324
|
+
return [$pickup, $dropoff];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return [null, null];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
protected function extractLabeledAddress(string $prompt, array $labels, array $nextLabels = []): ?string
|
|
331
|
+
{
|
|
332
|
+
$labelPattern = collect($labels)
|
|
333
|
+
->map(fn ($label) => preg_quote($label, '/'))
|
|
334
|
+
->implode('|');
|
|
335
|
+
$nextPattern = collect($nextLabels)
|
|
336
|
+
->map(fn ($label) => preg_quote($label, '/'))
|
|
337
|
+
->implode('|');
|
|
338
|
+
$lookahead = $nextPattern ? '(?=\s*,?\s*(?:and\s+)?(?:the\s+)?(?:' . $nextPattern . ')(?:\s+(?:at|to|from))?\s+|$)' : '(?=$)';
|
|
339
|
+
|
|
340
|
+
if (!preg_match('/\b(?:the\s+)?(?:' . $labelPattern . ')(?:\s+(?:at|to|from))?\s+(.+?)' . $lookahead . '/i', $prompt, $matches)) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return $this->cleanAddress($matches[1]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
protected function cleanAddress(?string $address): ?string
|
|
348
|
+
{
|
|
349
|
+
$address = trim((string) $address);
|
|
350
|
+
$address = preg_replace('/^(?:,|\band\b|\s)+/i', '', $address);
|
|
351
|
+
$address = preg_replace('/(?:,|\band\b|\s)+$/i', '', $address);
|
|
352
|
+
$address = preg_replace('/\s+/', ' ', $address);
|
|
353
|
+
|
|
354
|
+
return $address !== '' ? $address : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
protected function resolvePlace(?string $query): ?array
|
|
358
|
+
{
|
|
359
|
+
$query = $this->cleanAddress($query);
|
|
360
|
+
if (!$query) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
$places = PlaceSearch::search(Place::where('company_uuid', session('company')), $query, ['geo' => true, 'limit' => 8]);
|
|
365
|
+
$place = $places->first();
|
|
366
|
+
|
|
367
|
+
return $place instanceof Place ? $this->serializePlace($place, $query) : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
protected function provisionalPlace(string $query): array
|
|
371
|
+
{
|
|
372
|
+
return [
|
|
373
|
+
'id' => null,
|
|
374
|
+
'uuid' => null,
|
|
375
|
+
'public_id' => null,
|
|
376
|
+
'name' => $query,
|
|
377
|
+
'address' => $query,
|
|
378
|
+
'latitude' => null,
|
|
379
|
+
'longitude' => null,
|
|
380
|
+
'query' => $query,
|
|
381
|
+
'source' => 'unresolved',
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
protected function serializePlace(Place $place, ?string $query = null): array
|
|
386
|
+
{
|
|
387
|
+
[$latitude, $longitude] = $this->placeCoordinates($place);
|
|
388
|
+
|
|
389
|
+
return [
|
|
390
|
+
'id' => $place->public_id,
|
|
391
|
+
'uuid' => $place->uuid,
|
|
392
|
+
'public_id' => $place->public_id,
|
|
393
|
+
'name' => $place->name,
|
|
394
|
+
'street1' => $place->street1,
|
|
395
|
+
'street2' => $place->street2,
|
|
396
|
+
'city' => $place->city,
|
|
397
|
+
'province' => $place->province,
|
|
398
|
+
'postal_code' => $place->postal_code,
|
|
399
|
+
'country' => $place->country,
|
|
400
|
+
'address' => $place->address ?: $place->toAddressString(['name']),
|
|
401
|
+
'latitude' => $latitude,
|
|
402
|
+
'longitude' => $longitude,
|
|
403
|
+
'query' => $query,
|
|
404
|
+
'source' => $place->exists ? 'saved' : 'geocoded',
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
protected function placeCoordinates(Place $place): array
|
|
409
|
+
{
|
|
410
|
+
$latitude = data_get($place, 'latitude');
|
|
411
|
+
$longitude = data_get($place, 'longitude');
|
|
412
|
+
|
|
413
|
+
if ((!is_numeric($latitude) || !is_numeric($longitude)) && $place->location) {
|
|
414
|
+
$point = Utils::getPointFromCoordinates($place->location);
|
|
415
|
+
|
|
416
|
+
if ($point) {
|
|
417
|
+
$latitude = method_exists($point, 'getLat') ? $point->getLat() : data_get($point, 'coordinates.1');
|
|
418
|
+
$longitude = method_exists($point, 'getLng') ? $point->getLng() : data_get($point, 'coordinates.0');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return [
|
|
423
|
+
is_numeric($latitude) ? (float) $latitude : null,
|
|
424
|
+
is_numeric($longitude) ? (float) $longitude : null,
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
protected function routePreview(array $draft): array
|
|
429
|
+
{
|
|
430
|
+
$rawStops = [
|
|
431
|
+
['role' => 'pickup', 'place' => data_get($draft, 'payload.pickup')],
|
|
432
|
+
];
|
|
433
|
+
foreach ((array) data_get($draft, 'payload.waypoints', []) as $index => $waypoint) {
|
|
434
|
+
$rawStops[] = ['role' => 'waypoint', 'label' => 'Stop ' . ($index + 1), 'place' => data_get($waypoint, 'place') ?: $waypoint];
|
|
435
|
+
}
|
|
436
|
+
$rawStops[] = ['role' => 'dropoff', 'place' => data_get($draft, 'payload.dropoff')];
|
|
437
|
+
|
|
438
|
+
$stops = collect($rawStops)
|
|
439
|
+
->filter(fn ($stop) => is_array($stop['place'] ?? null))
|
|
440
|
+
->map(fn ($stop) => [
|
|
441
|
+
'role' => $stop['role'],
|
|
442
|
+
'label' => $stop['label'] ?? Str::title($stop['role']),
|
|
443
|
+
'address' => data_get($stop, 'place.address') ?? data_get($stop, 'place.name'),
|
|
444
|
+
'latitude' => data_get($stop, 'place.latitude'),
|
|
445
|
+
'longitude' => data_get($stop, 'place.longitude'),
|
|
446
|
+
'coordinates' => is_numeric(data_get($stop, 'place.latitude')) && is_numeric(data_get($stop, 'place.longitude')) ? [(float) data_get($stop, 'place.latitude'), (float) data_get($stop, 'place.longitude')] : null,
|
|
447
|
+
])
|
|
448
|
+
->values()
|
|
449
|
+
->all();
|
|
450
|
+
|
|
451
|
+
return [
|
|
452
|
+
'stops' => $stops,
|
|
453
|
+
'coordinates' => collect($stops)
|
|
454
|
+
->filter(fn ($stop) => is_numeric($stop['latitude']) && is_numeric($stop['longitude']))
|
|
455
|
+
->map(fn ($stop) => [$stop['latitude'], $stop['longitude']])
|
|
456
|
+
->values()
|
|
457
|
+
->all(),
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
protected function resolveOrderConfig(array $draft): ?OrderConfig
|
|
462
|
+
{
|
|
463
|
+
return OrderConfig::resolveFromIdentifier([data_get($draft, 'order_config_uuid'), data_get($draft, 'type')]) ?? OrderConfig::defaultOrCreate();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
protected function resolveDriver(array $draft): ?Driver
|
|
467
|
+
{
|
|
468
|
+
$identifier = data_get($draft, 'driver') ?? data_get($draft, 'driver_assigned_uuid') ?? data_get($draft, 'driver_query');
|
|
469
|
+
if (!$identifier) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return Driver::where('company_uuid', session('company'))
|
|
474
|
+
->where(function ($query) use ($identifier) {
|
|
475
|
+
$query->where('uuid', $identifier)->orWhere('public_id', $identifier)->orWhereHas('user', fn ($user) => $user->where('name', 'like', '%' . $identifier . '%'));
|
|
476
|
+
})
|
|
477
|
+
->first();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
protected function resolveVehicle(array $draft): ?Vehicle
|
|
481
|
+
{
|
|
482
|
+
$identifier = data_get($draft, 'vehicle') ?? data_get($draft, 'vehicle_assigned_uuid') ?? data_get($draft, 'vehicle_query');
|
|
483
|
+
if (!$identifier) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return Vehicle::where('company_uuid', session('company'))
|
|
488
|
+
->where(function ($query) use ($identifier) {
|
|
489
|
+
$query->where('uuid', $identifier)->orWhere('public_id', $identifier)->orWhere('plate_number', 'like', '%' . $identifier . '%')->orWhere('name', 'like', '%' . $identifier . '%');
|
|
490
|
+
})
|
|
491
|
+
->first();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
protected function podMethods(): array
|
|
495
|
+
{
|
|
496
|
+
$methods = config('fleetops.pod_methods', ['scan', 'signature', 'photo']);
|
|
497
|
+
if (is_string($methods)) {
|
|
498
|
+
$methods = explode(',', $methods);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return collect((array) $methods)
|
|
502
|
+
->map(fn ($method) => trim((string) $method))
|
|
503
|
+
->filter()
|
|
504
|
+
->unique()
|
|
505
|
+
->values()
|
|
506
|
+
->all();
|
|
507
|
+
}
|
|
508
|
+
}
|