@fleetbase/fleetops-engine 0.6.56 → 0.6.58
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 +9 -2
- package/extension.json +1 -1
- package/package.json +6 -4
- package/phpunit.xml.dist +4 -4
- package/pnpm-workspace.yaml +2 -1
- package/scripts/coverage-runner.php +51 -0
- package/scripts/coverage-summary.php +110 -0
- package/scripts/normalize-rgba-alpha.js +90 -0
- package/scripts/pest-bootstrap.php +177 -0
- package/scripts/pest-file-runner.php +82 -0
- package/scripts/pest-runner.php +64 -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/Orchestration/Support/OrchestrationPayloadBuilder.php +3 -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/Utils.php +52 -0
- package/server/src/Tracking/TrackingOptions.php +6 -1
- package/server/src/routes.php +0 -11
- package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
- package/server/tests/AnalyticsRoutesTest.php +5 -3
- package/server/tests/CustomerEndpointTest.php +1 -0
- package/server/tests/DeviceFilterTest.php +1 -2
- package/server/tests/FleetHierarchyResourceTest.php +12 -0
- package/server/tests/FuelProviderRegistryTest.php +7 -9
- package/server/tests/LiveControllerViewportTest.php +8 -16
- package/server/tests/LiveOrderQueryTest.php +18 -24
- package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
- package/server/tests/PointResolutionTest.php +63 -0
- package/server/tests/ServiceAreaZoneApiShapeTest.php +8 -9
- package/server/tests/ServiceRateAlgorithmWeightVariablesTest.php +1 -1
- package/server/tests/TelematicsHardeningTest.php +31 -26
- package/server/tests/TrackingIntelligenceTest.php +65 -25
- package/server/tests/VehicleDriverAssignmentTest.php +8 -9
- 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
- package/server/src/Http/Controllers/Api/v1/OrganizationController.php +0 -45
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|