@fleetbase/fleetops-engine 0.6.56 → 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/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/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/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/vehicle/card.hbs +14 -2
- package/addon/components/vehicle/form.hbs +13 -1
- package/addon/controllers/settings/map.js +4 -0
- package/addon/extension.js +14 -0
- package/addon/routes/management/fleets/index/details.js +1 -1
- package/addon/services/map-adapter/google.js +56 -16
- package/addon/services/map-manager.js +12 -0
- package/addon/styles/fleetops-engine.css +487 -1
- package/addon/utils/map-drawer-dropdown-position.js +11 -13
- package/app/components/ai/create-order-preview.js +1 -0
- package/app/components/ai/route-preview-map.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/src/Http/Resources/v1/Fleet.php +8 -2
- package/server/src/Models/Driver.php +6 -0
- package/server/src/Models/Order.php +18 -6
- package/server/src/Models/Place.php +11 -3
- 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/Utils.php +52 -0
- package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
- package/server/tests/FleetHierarchyResourceTest.php +12 -0
- package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
- package/server/tests/PointResolutionTest.php +63 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Ai\Models\AiTask;
|
|
6
|
+
use Fleetbase\Ai\Support\AiRelativeDateResolver;
|
|
7
|
+
use Fleetbase\FleetOps\Models\Order;
|
|
8
|
+
|
|
9
|
+
class OrderInsightsCapability extends AbstractFleetOpsAICapability
|
|
10
|
+
{
|
|
11
|
+
public function key(): string
|
|
12
|
+
{
|
|
13
|
+
return 'fleet-ops.order_insights';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public function label(): string
|
|
17
|
+
{
|
|
18
|
+
return 'Fleet-Ops order insights';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public function description(): string
|
|
22
|
+
{
|
|
23
|
+
return 'Answers bounded aggregate questions about Fleet-Ops orders, statuses, and order values.';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function permissions(): array
|
|
27
|
+
{
|
|
28
|
+
return ['fleet-ops see order'];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function resolve(AiTask $task): array
|
|
32
|
+
{
|
|
33
|
+
if (!$this->can('fleet-ops see order')) {
|
|
34
|
+
return ['authorized' => false, 'message' => 'Current user cannot access Fleet-Ops orders.'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
$prompt = $this->prompt($task);
|
|
38
|
+
$window = $this->dateWindow($prompt);
|
|
39
|
+
$amount = $this->amountThreshold($prompt);
|
|
40
|
+
$query = Order::where('company_uuid', session('company'));
|
|
41
|
+
|
|
42
|
+
if ($window) {
|
|
43
|
+
$query->whereBetween('created_at', [$window['start'], $window['end']]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ($amount !== null) {
|
|
47
|
+
$query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $amount));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
$total = (clone $query)->count();
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
'authorized' => true,
|
|
54
|
+
'metric' => 'orders',
|
|
55
|
+
'date_window' => $window ? [
|
|
56
|
+
'label' => $window['label'],
|
|
57
|
+
'timezone' => $window['timezone'],
|
|
58
|
+
'start' => $window['start']->toIso8601String(),
|
|
59
|
+
'end' => $window['end']->toIso8601String(),
|
|
60
|
+
] : null,
|
|
61
|
+
'amount_threshold' => $amount,
|
|
62
|
+
'count' => $total,
|
|
63
|
+
'counts_by_status' => (clone $query)
|
|
64
|
+
->selectRaw('status, count(*) as aggregate')
|
|
65
|
+
->groupBy('status')
|
|
66
|
+
->pluck('aggregate', 'status')
|
|
67
|
+
->all(),
|
|
68
|
+
'sample_order_ids' => (clone $query)->latest()->limit(10)->pluck('public_id')->filter()->values()->all(),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected function matchesPrompt(string $prompt): bool
|
|
73
|
+
{
|
|
74
|
+
return str_contains($prompt, 'order') && $this->containsAny($prompt, ['how many', 'count', 'report', 'value', 'over', 'status', 'today', 'yesterday', 'tomorrow', 'last week', 'this week', 'next week', 'last month', 'this month', 'last 30 days', 'completed', 'active']);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected function amountThreshold(string $prompt): ?float
|
|
78
|
+
{
|
|
79
|
+
if (preg_match('/(?:over|greater than|above)\s+\$?([0-9]+(?:\.[0-9]+)?)/', $prompt, $matches)) {
|
|
80
|
+
return (float) $matches[1];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
protected function dateWindow(string $prompt): ?array
|
|
87
|
+
{
|
|
88
|
+
return $this->relativeDateResolver()->resolveWindow($prompt);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected function relativeDateResolver(): AiRelativeDateResolver
|
|
92
|
+
{
|
|
93
|
+
return function_exists('app') ? app(AiRelativeDateResolver::class) : new AiRelativeDateResolver(null);
|
|
94
|
+
}
|
|
95
|
+
}
|