@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.
Files changed (60) hide show
  1. package/addon/components/ai/create-order-preview.hbs +297 -0
  2. package/addon/components/ai/create-order-preview.js +362 -0
  3. package/addon/components/ai/route-preview-map.hbs +79 -0
  4. package/addon/components/ai/route-preview-map.js +527 -0
  5. package/addon/components/fleet/details.hbs +21 -8
  6. package/addon/components/fleet/details.js +55 -1
  7. package/addon/components/fleet/hierarchy-tree.hbs +137 -0
  8. package/addon/components/fleet/hierarchy-tree.js +528 -0
  9. package/addon/components/map/drawer/device-event-listing.js +1 -0
  10. package/addon/components/map/drawer/driver-listing.js +1 -0
  11. package/addon/components/map/drawer/place-listing.js +1 -0
  12. package/addon/components/map/drawer/vehicle-listing.js +1 -0
  13. package/addon/components/order/details.hbs +34 -1
  14. package/addon/components/order/form/details.hbs +4 -0
  15. package/addon/components/order/form.hbs +9 -1
  16. package/addon/components/vehicle/card.hbs +14 -2
  17. package/addon/components/vehicle/form.hbs +13 -1
  18. package/addon/controllers/settings/map.js +4 -0
  19. package/addon/extension.js +14 -0
  20. package/addon/routes/management/fleets/index/details.js +1 -1
  21. package/addon/services/map-adapter/google.js +56 -16
  22. package/addon/services/map-manager.js +12 -0
  23. package/addon/styles/fleetops-engine.css +487 -1
  24. package/addon/utils/map-drawer-dropdown-position.js +11 -13
  25. package/app/components/ai/create-order-preview.js +1 -0
  26. package/app/components/ai/route-preview-map.js +1 -0
  27. package/app/components/fleet/hierarchy-tree.js +1 -0
  28. package/app/services/route-engine.js +1 -0
  29. package/composer.json +1 -1
  30. package/extension.json +1 -1
  31. package/package.json +6 -4
  32. package/scripts/normalize-rgba-alpha.js +90 -0
  33. package/server/src/Http/Resources/v1/Fleet.php +8 -2
  34. package/server/src/Models/Driver.php +6 -0
  35. package/server/src/Models/Order.php +18 -6
  36. package/server/src/Models/Place.php +11 -3
  37. package/server/src/Providers/FleetOpsServiceProvider.php +26 -0
  38. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +88 -0
  39. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +108 -0
  40. package/server/src/Support/Ai/Capabilities/ConsoleNavigationCapability.php +55 -0
  41. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +508 -0
  42. package/server/src/Support/Ai/Capabilities/DocsHelpCapability.php +49 -0
  43. package/server/src/Support/Ai/Capabilities/ImportOrdersPreviewCapability.php +66 -0
  44. package/server/src/Support/Ai/Capabilities/OperationalQueryCapability.php +319 -0
  45. package/server/src/Support/Ai/Capabilities/OptimizeOrderRouteCapability.php +240 -0
  46. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +95 -0
  47. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +195 -0
  48. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +150 -0
  49. package/server/src/Support/Utils.php +52 -0
  50. package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
  51. package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
  52. package/server/tests/FleetHierarchyResourceTest.php +12 -0
  53. package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
  54. package/server/tests/PointResolutionTest.php +63 -0
  55. package/addon/components/table/cell/dropdown/action-item.hbs +0 -31
  56. package/addon/components/table/cell/dropdown/action-item.js +0 -62
  57. package/addon/components/table/cell/dropdown.hbs +0 -28
  58. package/addon/components/table/cell/dropdown.js +0 -91
  59. package/app/components/table/cell/dropdown/action-item.js +0 -1
  60. package/app/components/table/cell/dropdown.js +0 -1
@@ -0,0 +1,195 @@
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\Maintenance;
9
+ use Fleetbase\FleetOps\Models\Order;
10
+ use Fleetbase\FleetOps\Models\Sensor;
11
+ use Fleetbase\FleetOps\Models\Telematic;
12
+ use Fleetbase\FleetOps\Models\Vehicle;
13
+ use Fleetbase\FleetOps\Models\WorkOrder;
14
+
15
+ class SearchResourcesCapability extends AbstractFleetOpsAICapability
16
+ {
17
+ public function key(): string
18
+ {
19
+ return 'fleet-ops.search_resources';
20
+ }
21
+
22
+ public function label(): string
23
+ {
24
+ return 'Search Fleet-Ops resources';
25
+ }
26
+
27
+ public function description(): string
28
+ {
29
+ return 'Finds relevant Fleet-Ops orders, vehicles, drivers, work orders, maintenances, devices, sensors, and telematics.';
30
+ }
31
+
32
+ public function permissions(): array
33
+ {
34
+ return [
35
+ 'fleet-ops see order',
36
+ 'fleet-ops see vehicle',
37
+ 'fleet-ops see driver',
38
+ 'fleet-ops see work-order',
39
+ 'fleet-ops see maintenance',
40
+ 'fleet-ops see device',
41
+ 'fleet-ops see sensor',
42
+ 'fleet-ops see telematic',
43
+ ];
44
+ }
45
+
46
+ public function resolve(AiTask $task): array
47
+ {
48
+ $prompt = (string) $task->prompt;
49
+ $terms = $this->searchTerms($prompt);
50
+
51
+ return [
52
+ 'query_terms' => $terms,
53
+ 'results' => array_filter([
54
+ 'orders' => $this->orders($terms),
55
+ 'vehicles' => $this->vehicles($terms),
56
+ 'drivers' => $this->drivers($terms),
57
+ 'work_orders' => $this->workOrders($terms),
58
+ 'maintenances' => $this->maintenances($terms),
59
+ 'devices' => $this->devices($terms),
60
+ 'sensors' => $this->sensors($terms),
61
+ 'telematics' => $this->telematics($terms),
62
+ ]),
63
+ ];
64
+ }
65
+
66
+ protected function matchesPrompt(string $prompt): bool
67
+ {
68
+ return $this->containsAny($prompt, ['find', 'show', 'open', 'look up', 'tell me about', 'status of', 'order', 'vehicle', 'driver', 'work order', 'maintenance', 'device', 'sensor', 'telematic']);
69
+ }
70
+
71
+ protected function orders(array $terms): array
72
+ {
73
+ if (!$this->can('fleet-ops see order')) {
74
+ return [];
75
+ }
76
+
77
+ return Order::with(['transaction', 'trackingNumber'])
78
+ ->where('company_uuid', session('company'))
79
+ ->where(function ($query) use ($terms) {
80
+ $this->whereLikeAny($query, ['public_id', 'internal_id', 'uuid', 'status', 'type'], $terms);
81
+ $query->orWhereHas('trackingNumber', fn ($tracking) => $this->whereLikeAny($tracking, ['tracking_number', 'barcode'], $terms));
82
+ })
83
+ ->limit(5)
84
+ ->get()
85
+ ->map(fn (Order $order) => [
86
+ 'id' => $order->public_id,
87
+ 'uuid' => $order->uuid,
88
+ 'tracking' => $order->tracking,
89
+ 'status' => $order->status,
90
+ 'type' => $order->type,
91
+ 'transaction_amount' => $order->transaction_amount,
92
+ 'transaction_currency' => $order->transaction_currency,
93
+ 'route' => 'console.fleet-ops.operations.orders.index.details',
94
+ 'models' => [$order->public_id ?: $order->uuid],
95
+ ])
96
+ ->values()
97
+ ->all();
98
+ }
99
+
100
+ protected function vehicles(array $terms): array
101
+ {
102
+ if (!$this->can('fleet-ops see vehicle')) {
103
+ return [];
104
+ }
105
+
106
+ return Vehicle::where('company_uuid', session('company'))
107
+ ->where(fn ($query) => $this->whereLikeAny($query, ['name', 'make', 'model', 'plate_number', 'vin', 'public_id', 'internal_id'], $terms))
108
+ ->limit(5)
109
+ ->get()
110
+ ->map(fn (Vehicle $vehicle) => [
111
+ 'id' => $vehicle->public_id,
112
+ 'uuid' => $vehicle->uuid,
113
+ 'name' => $vehicle->display_name ?: $vehicle->name,
114
+ 'plate' => $vehicle->plate_number,
115
+ 'status' => $vehicle->status,
116
+ 'route' => 'console.fleet-ops.management.vehicles.index.details',
117
+ 'models' => [$vehicle->public_id ?: $vehicle->uuid],
118
+ ])
119
+ ->values()
120
+ ->all();
121
+ }
122
+
123
+ protected function drivers(array $terms): array
124
+ {
125
+ if (!$this->can('fleet-ops see driver')) {
126
+ return [];
127
+ }
128
+
129
+ return Driver::with('user')
130
+ ->where('company_uuid', session('company'))
131
+ ->where(function ($query) use ($terms) {
132
+ $this->whereLikeAny($query, ['public_id', 'uuid', 'drivers_license_number', 'status'], $terms);
133
+ $query->orWhereHas('user', fn ($user) => $this->whereLikeAny($user, ['name', 'email', 'phone'], $terms));
134
+ })
135
+ ->limit(5)
136
+ ->get()
137
+ ->map(fn (Driver $driver) => [
138
+ 'id' => $driver->public_id,
139
+ 'uuid' => $driver->uuid,
140
+ 'name' => data_get($driver, 'user.name'),
141
+ 'status' => $driver->status,
142
+ 'route' => 'console.fleet-ops.management.drivers.index.details',
143
+ 'models' => [$driver->public_id ?: $driver->uuid],
144
+ ])
145
+ ->values()
146
+ ->all();
147
+ }
148
+
149
+ protected function workOrders(array $terms): array
150
+ {
151
+ return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'uuid', 'code', 'subject', 'status', 'priority'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms);
152
+ }
153
+
154
+ protected function maintenances(array $terms): array
155
+ {
156
+ return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'uuid', 'status', 'type', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms);
157
+ }
158
+
159
+ protected function devices(array $terms): array
160
+ {
161
+ return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'uuid', 'name', 'device_id', 'imei', 'serial_number', 'status'], 'console.fleet-ops.connectivity.devices.index.details', $terms);
162
+ }
163
+
164
+ protected function sensors(array $terms): array
165
+ {
166
+ return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'uuid', 'name', 'internal_id', 'serial_number', 'imei', 'type', 'sensor_type', 'status'], 'console.fleet-ops.connectivity.sensors.index.details', $terms);
167
+ }
168
+
169
+ protected function telematics(array $terms): array
170
+ {
171
+ return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'uuid', 'name', 'provider', 'status'], 'console.fleet-ops.connectivity.telematics.details', $terms);
172
+ }
173
+
174
+ protected function generic(string $modelClass, string $permission, array $columns, string $route, array $terms): array
175
+ {
176
+ if (!$this->can($permission)) {
177
+ return [];
178
+ }
179
+
180
+ return $modelClass::where('company_uuid', session('company'))
181
+ ->where(fn ($query) => $this->whereLikeAny($query, $columns, $terms))
182
+ ->limit(5)
183
+ ->get()
184
+ ->map(fn ($record) => [
185
+ 'id' => $record->public_id,
186
+ 'uuid' => $record->uuid,
187
+ 'name' => $record->name ?? $record->subject ?? $record->summary ?? $record->description ?? null,
188
+ 'status' => $record->status ?? null,
189
+ 'route' => $route,
190
+ 'models' => [$record->public_id ?: $record->uuid],
191
+ ])
192
+ ->values()
193
+ ->all();
194
+ }
195
+ }
@@ -0,0 +1,150 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Ai;
4
+
5
+ use Fleetbase\Ai\Support\AiQueryableResource;
6
+ use Fleetbase\Ai\Support\AiQueryRegistry;
7
+ use Fleetbase\FleetOps\Models\Device;
8
+ use Fleetbase\FleetOps\Models\Driver;
9
+ use Fleetbase\FleetOps\Models\Fleet;
10
+ use Fleetbase\FleetOps\Models\Order;
11
+ use Fleetbase\FleetOps\Models\ServiceArea;
12
+ use Fleetbase\FleetOps\Models\Vehicle;
13
+ use Fleetbase\FleetOps\Models\Zone;
14
+
15
+ class FleetOpsAiQueryResources
16
+ {
17
+ public static function register(AiQueryRegistry $registry): void
18
+ {
19
+ $registry->register(new AiQueryableResource(
20
+ key: 'fleet-ops.drivers',
21
+ label: 'Fleet-Ops drivers',
22
+ module: 'fleet-ops',
23
+ modelClass: Driver::class,
24
+ permission: 'fleet-ops see driver',
25
+ aliases: ['drivers', 'driver'],
26
+ fields: [
27
+ 'online' => ['column' => 'online', 'type' => 'boolean'],
28
+ 'status' => ['column' => 'status', 'type' => 'string'],
29
+ 'current_status' => ['column' => 'current_status', 'type' => 'string'],
30
+ 'city' => ['column' => 'city', 'type' => 'string'],
31
+ 'country' => ['column' => 'country', 'type' => 'string'],
32
+ 'vehicle_uuid' => ['column' => 'vehicle_uuid', 'type' => 'uuid'],
33
+ 'updated_at' => ['column' => 'updated_at', 'type' => 'datetime'],
34
+ 'created_at' => ['column' => 'created_at', 'type' => 'datetime'],
35
+ ],
36
+ sampleFields: ['public_id', 'name', 'status', 'current_status', 'online', 'city', 'country', 'vehicle_uuid'],
37
+ locationField: 'location',
38
+ directivePermission: 'fleet-ops list driver',
39
+ maxLimit: 500,
40
+ ));
41
+
42
+ $registry->register(new AiQueryableResource(
43
+ key: 'fleet-ops.vehicles',
44
+ label: 'Fleet-Ops vehicles',
45
+ module: 'fleet-ops',
46
+ modelClass: Vehicle::class,
47
+ permission: 'fleet-ops see vehicle',
48
+ aliases: ['vehicles', 'vehicle'],
49
+ fields: [
50
+ 'online' => ['column' => 'online', 'type' => 'boolean'],
51
+ 'status' => ['column' => 'status', 'type' => 'string'],
52
+ 'driver_uuid' => ['column' => 'driver_uuid', 'type' => 'uuid'],
53
+ 'city' => ['column' => 'city', 'type' => 'string'],
54
+ 'country' => ['column' => 'country', 'type' => 'string'],
55
+ 'updated_at' => ['column' => 'updated_at', 'type' => 'datetime'],
56
+ 'created_at' => ['column' => 'created_at', 'type' => 'datetime'],
57
+ ],
58
+ sampleFields: ['public_id', 'display_name', 'status', 'online', 'city', 'country', 'driver_uuid'],
59
+ locationField: 'location',
60
+ directivePermission: 'fleet-ops list vehicle',
61
+ maxLimit: 500,
62
+ ));
63
+
64
+ $registry->register(new AiQueryableResource(
65
+ key: 'fleet-ops.devices',
66
+ label: 'Fleet-Ops devices',
67
+ module: 'fleet-ops',
68
+ modelClass: Device::class,
69
+ permission: 'fleet-ops see device',
70
+ aliases: ['devices', 'device'],
71
+ fields: [
72
+ 'online' => ['column' => 'online', 'type' => 'boolean'],
73
+ 'status' => ['column' => 'status', 'type' => 'string'],
74
+ 'provider' => ['column' => 'provider', 'type' => 'string'],
75
+ 'telematic_uuid' => ['column' => 'telematic_uuid', 'type' => 'uuid'],
76
+ 'attachable_uuid' => ['column' => 'attachable_uuid', 'type' => 'uuid'],
77
+ 'last_online_at' => ['column' => 'last_online_at', 'type' => 'datetime'],
78
+ 'updated_at' => ['column' => 'updated_at', 'type' => 'datetime'],
79
+ ],
80
+ sampleFields: ['public_id', 'name', 'status', 'online', 'provider', 'telematic_uuid', 'attachable_uuid'],
81
+ directivePermission: 'fleet-ops list device',
82
+ maxLimit: 500,
83
+ ));
84
+
85
+ $registry->register(new AiQueryableResource(
86
+ key: 'fleet-ops.orders',
87
+ label: 'Fleet-Ops orders',
88
+ module: 'fleet-ops',
89
+ modelClass: Order::class,
90
+ permission: 'fleet-ops see order',
91
+ aliases: ['orders', 'order'],
92
+ fields: [
93
+ 'status' => ['column' => 'status', 'type' => 'string'],
94
+ 'type' => ['column' => 'type', 'type' => 'string'],
95
+ 'driver_assigned_uuid' => ['column' => 'driver_assigned_uuid', 'type' => 'uuid'],
96
+ 'vehicle_assigned_uuid' => ['column' => 'vehicle_assigned_uuid', 'type' => 'uuid'],
97
+ 'created_at' => ['column' => 'created_at', 'type' => 'datetime'],
98
+ 'scheduled_at' => ['column' => 'scheduled_at', 'type' => 'datetime'],
99
+ ],
100
+ sampleFields: ['public_id', 'status', 'type', 'driver_assigned_uuid', 'vehicle_assigned_uuid', 'created_at'],
101
+ directivePermission: 'fleet-ops list order',
102
+ maxLimit: 500,
103
+ ));
104
+
105
+ $registry->register(new AiQueryableResource(
106
+ key: 'fleet-ops.fleets',
107
+ label: 'Fleet-Ops fleets',
108
+ module: 'fleet-ops',
109
+ modelClass: Fleet::class,
110
+ permission: 'fleet-ops see fleet',
111
+ aliases: ['fleets', 'fleet'],
112
+ fields: [
113
+ 'status' => ['column' => 'status', 'type' => 'string'],
114
+ 'task' => ['column' => 'task', 'type' => 'string'],
115
+ 'updated_at' => ['column' => 'updated_at', 'type' => 'datetime'],
116
+ ],
117
+ sampleFields: ['public_id', 'name', 'status', 'task'],
118
+ directivePermission: 'fleet-ops list fleet',
119
+ ));
120
+
121
+ $registry->register(new AiQueryableResource(
122
+ key: 'fleet-ops.service_areas',
123
+ label: 'Fleet-Ops service areas',
124
+ module: 'fleet-ops',
125
+ modelClass: ServiceArea::class,
126
+ permission: 'fleet-ops see service-area',
127
+ aliases: ['service areas', 'service area', 'service_areas'],
128
+ fields: [
129
+ 'status' => ['column' => 'status', 'type' => 'string'],
130
+ 'country' => ['column' => 'country', 'type' => 'string'],
131
+ 'type' => ['column' => 'type', 'type' => 'string'],
132
+ ],
133
+ sampleFields: ['public_id', 'name', 'status', 'country', 'type'],
134
+ ));
135
+
136
+ $registry->register(new AiQueryableResource(
137
+ key: 'fleet-ops.zones',
138
+ label: 'Fleet-Ops zones',
139
+ module: 'fleet-ops',
140
+ modelClass: Zone::class,
141
+ permission: 'fleet-ops see zone',
142
+ aliases: ['zones', 'zone'],
143
+ fields: [
144
+ 'status' => ['column' => 'status', 'type' => 'string'],
145
+ 'service_area_uuid' => ['column' => 'service_area_uuid', 'type' => 'uuid'],
146
+ ],
147
+ sampleFields: ['public_id', 'name', 'status', 'service_area_uuid'],
148
+ ));
149
+ }
150
+ }
@@ -248,6 +248,10 @@ class Utils extends FleetbaseUtils
248
248
  $coordinates = static::extractPointWktFromQueryExpression($coordinates) ?? $coordinates;
249
249
  }
250
250
 
251
+ if ($geoJsonPoint = static::pointFromGeoJson($coordinates)) {
252
+ return $geoJsonPoint;
253
+ }
254
+
251
255
  if ($coordinates instanceof Point) {
252
256
  $latitude = $coordinates->getLat();
253
257
  $longitude = $coordinates->getLng();
@@ -452,6 +456,10 @@ class Utils extends FleetbaseUtils
452
456
  return $coordinates;
453
457
  }
454
458
 
459
+ if ($geoJsonPoint = static::pointFromGeoJson($coordinates)) {
460
+ return $geoJsonPoint;
461
+ }
462
+
455
463
  if (static::isGeoJson($coordinates)) {
456
464
  $coordinatesJson = null;
457
465
 
@@ -541,6 +549,10 @@ class Utils extends FleetbaseUtils
541
549
  $coordinates = $coordinates->location;
542
550
  }
543
551
 
552
+ if ($geoJsonPoint = static::pointFromGeoJson($coordinates)) {
553
+ $coordinates = $geoJsonPoint;
554
+ }
555
+
544
556
  if ($coordinates instanceof Point) {
545
557
  $latitude = $coordinates->getLat();
546
558
  $longitude = $coordinates->getLng();
@@ -639,6 +651,10 @@ class Utils extends FleetbaseUtils
639
651
  return $coordinates;
640
652
  }
641
653
 
654
+ if ($geoJsonPoint = static::pointFromGeoJson($coordinates)) {
655
+ return $geoJsonPoint;
656
+ }
657
+
642
658
  if (is_null($coordinates) || !static::isCoordinates($coordinates)) {
643
659
  return new Point(0, 0);
644
660
  }
@@ -649,6 +665,42 @@ class Utils extends FleetbaseUtils
649
665
  return new Point($latitude, $longitude);
650
666
  }
651
667
 
668
+ /**
669
+ * Resolve direct GeoJSON Point values and Feature-wrapped Point values.
670
+ */
671
+ protected static function pointFromGeoJson($coordinates): ?Point
672
+ {
673
+ if (is_string($coordinates) && static::isJson($coordinates)) {
674
+ $coordinates = json_decode($coordinates, true);
675
+ }
676
+
677
+ if (!is_array($coordinates) && !is_object($coordinates)) {
678
+ return null;
679
+ }
680
+
681
+ if (data_get($coordinates, 'type') === 'Feature') {
682
+ $coordinates = data_get($coordinates, 'geometry');
683
+ }
684
+
685
+ if (data_get($coordinates, 'type') !== 'Point') {
686
+ return null;
687
+ }
688
+
689
+ $pointCoordinates = data_get($coordinates, 'coordinates');
690
+ if (!is_array($pointCoordinates) || count($pointCoordinates) < 2) {
691
+ return null;
692
+ }
693
+
694
+ $longitude = $pointCoordinates[0];
695
+ $latitude = $pointCoordinates[1];
696
+
697
+ if (!is_numeric($latitude) || !is_numeric($longitude)) {
698
+ return null;
699
+ }
700
+
701
+ return new Point((float) $latitude, (float) $longitude);
702
+ }
703
+
652
704
  /**
653
705
  * Converts a point to a WKT (Well-Known Text) representation for SQL insert.
654
706
  *
@@ -0,0 +1,131 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Place;
4
+ use Fleetbase\FleetOps\Support\Ai\Capabilities\CreateOrderPreviewCapability;
5
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ function fleetopsAiCreateOrderCapability()
9
+ {
10
+ return (new ReflectionClass(CreateOrderPreviewCapability::class))->newInstanceWithoutConstructor();
11
+ }
12
+
13
+ function fleetopsAiCreateOrderProtectedMethod(string $method): ReflectionMethod
14
+ {
15
+ $reflection = new ReflectionClass(CreateOrderPreviewCapability::class);
16
+ $method = $reflection->getMethod($method);
17
+ $method->setAccessible(true);
18
+
19
+ return $method;
20
+ }
21
+
22
+ test('create order preview parser extracts pickup and dropoff labels', function (string $prompt, array $expected) {
23
+ $capability = fleetopsAiCreateOrderCapability();
24
+ $method = fleetopsAiCreateOrderProtectedMethod('addressPairFromPrompt');
25
+
26
+ expect($method->invoke($capability, $prompt))->toBe($expected);
27
+ })->with([
28
+ 'pickup comma and dropoff' => [
29
+ 'Create an order with the pickup 16 simon walk singapore, and the dropoff 18 hougang ave',
30
+ ['16 simon walk singapore', '18 hougang ave'],
31
+ ],
32
+ 'pickup at and drop off to' => [
33
+ 'Create an order pickup at 16 simon walk singapore and drop off to 18 hougang ave',
34
+ ['16 simon walk singapore', '18 hougang ave'],
35
+ ],
36
+ 'from to' => [
37
+ 'Create an order from 16 simon walk singapore to 18 hougang ave',
38
+ ['16 simon walk singapore', '18 hougang ave'],
39
+ ],
40
+ ]);
41
+
42
+ test('create order preview keeps unresolved addresses as provisional places', function () {
43
+ $capability = fleetopsAiCreateOrderCapability();
44
+ $method = fleetopsAiCreateOrderProtectedMethod('provisionalPlace');
45
+
46
+ expect($method->invoke($capability, '16 simon walk singapore'))->toMatchArray([
47
+ 'uuid' => null,
48
+ 'name' => '16 simon walk singapore',
49
+ 'address' => '16 simon walk singapore',
50
+ 'latitude' => null,
51
+ 'longitude' => null,
52
+ 'source' => 'unresolved',
53
+ ]);
54
+ });
55
+
56
+ test('create order preview serializes coordinates from place location', function () {
57
+ $capability = fleetopsAiCreateOrderCapability();
58
+ $method = fleetopsAiCreateOrderProtectedMethod('serializePlace');
59
+ $place = new Place([
60
+ 'name' => '16 Simon Walk',
61
+ 'street1' => '16 Simon Walk',
62
+ 'city' => 'Singapore',
63
+ 'postal_code' => '545870',
64
+ 'location' => new SpatialPoint(1.3621663, 103.8845049),
65
+ ]);
66
+
67
+ expect($method->invoke($capability, $place, '16 simon walk singapore'))->toMatchArray([
68
+ 'latitude' => 1.3621663,
69
+ 'longitude' => 103.8845049,
70
+ ]);
71
+ });
72
+
73
+ test('create order preview route preview excludes return stops', function () {
74
+ $capability = fleetopsAiCreateOrderCapability();
75
+ $method = fleetopsAiCreateOrderProtectedMethod('routePreview');
76
+
77
+ $preview = $method->invoke($capability, [
78
+ 'payload' => [
79
+ 'pickup' => ['address' => '16 Simon Walk', 'latitude' => 1.3621663, 'longitude' => 103.8845049],
80
+ 'dropoff' => ['address' => '18 Hougang Avenue 8', 'latitude' => 1.3701, 'longitude' => 103.8912],
81
+ 'return' => ['address' => 'Ignored return', 'latitude' => 1.3, 'longitude' => 103.8],
82
+ ],
83
+ ]);
84
+
85
+ expect(collect($preview['stops'])->pluck('role')->all())
86
+ ->toBe(['pickup', 'dropoff'])
87
+ ->and($preview['coordinates'])->toHaveCount(2);
88
+ });
89
+
90
+ test('create order apply draft removes embedded places when endpoint uuids exist', function () {
91
+ $capability = new class extends CreateOrderPreviewCapability {
92
+ public function sanitizeForTest(array $draft): array
93
+ {
94
+ return $this->sanitizeDraftForApply($draft);
95
+ }
96
+
97
+ protected function hasExistingPlaceUuid($uuid): bool
98
+ {
99
+ return in_array($uuid, ['pickup_uuid_test', 'dropoff_uuid_test'], true);
100
+ }
101
+ };
102
+
103
+ $draft = $capability->sanitizeForTest([
104
+ 'payload' => [
105
+ 'pickup_uuid' => 'pickup_uuid_test',
106
+ 'dropoff_uuid' => 'dropoff_uuid_test',
107
+ 'pickup' => ['uuid' => 'pickup_uuid_test', 'location' => ['type' => 'Point', 'coordinates' => [103.851, 1.2816]]],
108
+ 'dropoff' => ['uuid' => 'dropoff_uuid_test', 'location' => ['type' => 'Point', 'coordinates' => [103.8318, 1.3048]]],
109
+ 'return' => ['location' => ['type' => 'Point', 'coordinates' => [103.8, 1.3]]],
110
+ ],
111
+ ]);
112
+
113
+ expect($draft['payload'])->toHaveKeys(['pickup_uuid', 'dropoff_uuid', 'return'])
114
+ ->and($draft['payload'])->not->toHaveKeys(['pickup', 'dropoff']);
115
+ });
116
+
117
+ test('create order preview resolves relative schedule phrases', function () {
118
+ $timezone = date_default_timezone_get();
119
+ date_default_timezone_set('Asia/Singapore');
120
+ Carbon::setTestNow(Carbon::parse('2026-06-30 15:00:00', 'Asia/Singapore'));
121
+
122
+ try {
123
+ $capability = fleetopsAiCreateOrderCapability();
124
+ $method = fleetopsAiCreateOrderProtectedMethod('scheduledAtFromPrompt');
125
+
126
+ expect($method->invoke($capability, 'Create an order scheduled for 3 days from now'))->toBe('2026-07-03T15:00:00+08:00');
127
+ } finally {
128
+ Carbon::setTestNow();
129
+ date_default_timezone_set($timezone);
130
+ }
131
+ });
@@ -0,0 +1,75 @@
1
+ <?php
2
+
3
+ use Fleetbase\Ai\Support\AiQueryRegistry;
4
+ use Fleetbase\FleetOps\Support\Ai\Capabilities\AssetStatusCapability;
5
+ use Fleetbase\FleetOps\Support\Ai\Capabilities\OperationalQueryCapability;
6
+ use Fleetbase\FleetOps\Support\Ai\FleetOpsAiQueryResources;
7
+ use Illuminate\Support\Carbon;
8
+
9
+ function fleetopsAiOperationalCapability()
10
+ {
11
+ return (new ReflectionClass(OperationalQueryCapability::class))->newInstanceWithoutConstructor();
12
+ }
13
+
14
+ function fleetopsAiOperationalProtectedMethod(string $method): ReflectionMethod
15
+ {
16
+ $reflection = new ReflectionClass(OperationalQueryCapability::class);
17
+ $method = $reflection->getMethod($method);
18
+ $method->setAccessible(true);
19
+
20
+ return $method;
21
+ }
22
+
23
+ test('fleet-ops registers safe ai query resources', function () {
24
+ $registry = new AiQueryRegistry();
25
+
26
+ FleetOpsAiQueryResources::register($registry);
27
+
28
+ expect($registry->find('drivers')->key)->toBe('fleet-ops.drivers')
29
+ ->and($registry->find('orders')->hasField('driver_assigned_uuid'))->toBeTrue()
30
+ ->and($registry->find('devices')->hasField('online'))->toBeTrue()
31
+ ->and($registry->find('service area')->key)->toBe('fleet-ops.service_areas');
32
+ });
33
+
34
+ test('operational query capability matches common fleet-ops data questions', function (string $prompt) {
35
+ $capability = fleetopsAiOperationalCapability();
36
+ $method = fleetopsAiOperationalProtectedMethod('matchesPrompt');
37
+
38
+ expect($method->invoke($capability, strtolower($prompt)))->toBeTrue();
39
+ })->with([
40
+ 'How many drivers are currently online?',
41
+ 'Where are most of my drivers located?',
42
+ 'How many vehicles were online yesterday?',
43
+ 'Which service area has the most online drivers?',
44
+ 'Show me drivers without vehicles.',
45
+ 'How many active orders have no assigned driver?',
46
+ 'Break down orders by status for this month.',
47
+ ]);
48
+
49
+ test('asset status capability includes driver online prompts', function () {
50
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
51
+ $method = (new ReflectionClass(AssetStatusCapability::class))->getMethod('matchesPrompt');
52
+ $method->setAccessible(true);
53
+
54
+ expect($method->invoke($capability, 'how many drivers are online'))->toBeTrue();
55
+ });
56
+
57
+ test('operational query date filters use resolved local windows', function () {
58
+ $timezone = date_default_timezone_get();
59
+ date_default_timezone_set('Asia/Singapore');
60
+ Carbon::setTestNow(Carbon::parse('2026-06-30 15:00:00', 'Asia/Singapore'));
61
+
62
+ try {
63
+ $capability = fleetopsAiOperationalCapability();
64
+ $method = fleetopsAiOperationalProtectedMethod('orderDateFilters');
65
+ $filters = $method->invoke($capability, 'How many orders were created last week?');
66
+
67
+ expect($filters)->toHaveCount(2)
68
+ ->and($filters[0])->toMatchArray(['field' => 'created_at', 'operator' => '>='])
69
+ ->and($filters[0]['value']->toIso8601String())->toBe('2026-06-22T00:00:00+08:00')
70
+ ->and($filters[1]['value']->toIso8601String())->toBe('2026-06-28T23:59:59+08:00');
71
+ } finally {
72
+ Carbon::setTestNow();
73
+ date_default_timezone_set($timezone);
74
+ }
75
+ });
@@ -0,0 +1,12 @@
1
+ <?php
2
+
3
+ test('fleet resource expands subfleet drivers and vehicles for hierarchy payloads', function () {
4
+ $resource = file_get_contents(__DIR__ . '/../src/Http/Resources/v1/Fleet.php');
5
+
6
+ expect($resource)
7
+ ->toContain("if (in_array('subfleets', \$with, true))")
8
+ ->toContain("if (in_array('drivers', \$with, true))")
9
+ ->toContain("\$this->loadMissing('subFleets.drivers')")
10
+ ->toContain("if (in_array('vehicles', \$with, true))")
11
+ ->toContain("\$this->loadMissing('subFleets.vehicles')");
12
+ });