@fleetbase/fleetops-engine 0.6.67 → 0.6.69
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/RELEASE.md +11 -15
- package/addon/components/contact/form.hbs +13 -2
- package/addon/components/customer/details.hbs +17 -17
- package/addon/components/customer/form.hbs +13 -2
- package/addon/components/customer/form.js +0 -56
- package/addon/components/driver/details.hbs +13 -19
- package/addon/components/driver/form.hbs +20 -35
- package/addon/components/driver/form.js +0 -58
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/modals/driver-form.hbs +2 -2
- package/addon/components/modals/reset-profile-credentials.hbs +23 -0
- package/addon/components/modals/reset-profile-credentials.js +70 -0
- package/addon/components/profile-identity-hint.hbs +13 -0
- package/addon/components/profile-identity-hint.js +49 -0
- package/addon/controllers/management/drivers/index.js +4 -0
- package/addon/extension.js +4 -2
- package/addon/services/contact-actions.js +50 -89
- package/addon/services/driver-actions.js +98 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/addon/utils/profile-login-actions.js +172 -0
- package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
- package/app/components/profile-identity-hint.js +1 -0
- package/composer.json +1 -1
- package/docs/SAFEE.md +5 -0
- package/docs/TELEMATICS_QUEUES.md +59 -8
- package/extension.json +1 -1
- package/package.json +3 -3
- package/scripts/pest-bootstrap.php +12 -0
- package/server/config/fuel-providers.php +53 -0
- package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
- package/server/resources/views/mail/driver-credentials.blade.php +21 -0
- package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
- package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
- package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
- package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
- package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
- package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
- package/server/src/Http/Resources/v1/Contact.php +3 -0
- package/server/src/Http/Resources/v1/Driver.php +3 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
- package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
- package/server/src/Mail/DriverCredentialsMail.php +55 -0
- package/server/src/Models/Contact.php +55 -90
- package/server/src/Models/Sensor.php +3 -5
- package/server/src/Observers/ContactObserver.php +2 -22
- package/server/src/Observers/DriverObserver.php +5 -6
- package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
- package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
- package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
- package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
- package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
- package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/ProfileAccountManager.php +405 -0
- package/server/src/Support/Telematics/TelematicService.php +6 -4
- package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
- package/server/src/routes.php +5 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
- package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +102 -20
- package/server/tests/CommandContractsTest.php +4 -4
- package/server/tests/ControllerHelperContractsTest.php +3 -11
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
- package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
- package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
- package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
- package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
- package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
- package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
- package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
- package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
- package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
- package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
- package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
- package/server/tests/ModelAccessorContractsTest.php +82 -20
- package/server/tests/ObserverContractsTest.php +27 -2
- package/server/tests/RequestContractsTest.php +19 -11
- package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
- package/server/tests/TelematicControllerContractsTest.php +22 -4
- package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
- package/server/tests/Unit/Models/ContactTest.php +48 -5
- package/server/tests/Unit/Models/SensorTest.php +2 -2
- package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
- package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
- package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
- package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
- package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
- package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
- package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
- package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
- package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
- package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/translations/en-us.yaml +37 -2
- package/translations/uk-ua.yaml +3475 -0
- package/addon/components/modals/reset-customer-credentials.hbs +0 -18
- package/addon/components/modals/reset-customer-credentials.js +0 -55
|
@@ -116,7 +116,7 @@ test('sensor model exposes relation builders and logging options', function () {
|
|
|
116
116
|
|
|
117
117
|
$sensor = new Sensor();
|
|
118
118
|
|
|
119
|
-
expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', '
|
|
119
|
+
expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type'])
|
|
120
120
|
->and($sensor->getSlugOptions()->slugField)->toBe('slug')
|
|
121
121
|
->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue()
|
|
122
122
|
->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class)
|
|
@@ -222,7 +222,7 @@ test('sensor scopes write expected query constraints', function () {
|
|
|
222
222
|
->and($sensor->scopeActive($query))->toBe($query)
|
|
223
223
|
->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query)
|
|
224
224
|
->and($sensor->scopeOutOfThreshold($query))->toBe($query)
|
|
225
|
-
->and($query->calls[0])->toBe(['where', ['
|
|
225
|
+
->and($query->calls[0])->toBe(['where', ['type', 'temperature']])
|
|
226
226
|
->and($query->calls[1])->toBe(['where', ['status', 'active']])
|
|
227
227
|
->and($query->calls[2][0])->toBe('where')
|
|
228
228
|
->and($query->calls[2][1][0])->toBe('last_reading_at')
|
|
@@ -176,45 +176,51 @@ test('customer creation forces the customer type and scopes orders', function ()
|
|
|
176
176
|
expect($customer->orders())->toBeInstanceOf(HasMany::class);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
test('contact observer
|
|
179
|
+
test('contact observer deletion releases the managed account and leaves team members alone', function () {
|
|
180
180
|
$connection = fleetopsSmallModelBoot();
|
|
181
|
-
|
|
181
|
+
config(['activitylog.enabled' => false]);
|
|
182
|
+
app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
|
|
183
|
+
$connection->getSchemaBuilder()->create('drivers', function ($blueprint) {
|
|
184
|
+
$blueprint->increments('id');
|
|
185
|
+
$blueprint->string('uuid')->nullable();
|
|
186
|
+
$blueprint->string('company_uuid')->nullable();
|
|
187
|
+
$blueprint->string('user_uuid')->nullable();
|
|
188
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
189
|
+
});
|
|
190
|
+
$connection->table('users')->insert([
|
|
191
|
+
['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'],
|
|
192
|
+
['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'],
|
|
193
|
+
]);
|
|
182
194
|
|
|
183
|
-
$observer
|
|
184
|
-
$reflection = new ReflectionClass($observer);
|
|
195
|
+
$observer = new ContactObserver();
|
|
185
196
|
|
|
186
197
|
$contact = new Fleetbase\FleetOps\Models\Contact();
|
|
187
|
-
$contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '
|
|
198
|
+
$contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true);
|
|
188
199
|
$contact->exists = true;
|
|
189
200
|
|
|
190
|
-
$
|
|
191
|
-
$
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
$phoneCheck = $reflection->getMethod('isPhoneUnavailable');
|
|
195
|
-
$phoneCheck->setAccessible(true);
|
|
196
|
-
expect($phoneCheck->invoke($observer, $contact))->toBeTrue();
|
|
201
|
+
$staffContact = new Fleetbase\FleetOps\Models\Contact();
|
|
202
|
+
$staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true);
|
|
203
|
+
$staffContact->exists = true;
|
|
197
204
|
|
|
198
|
-
$contact->email = 'free@example.test';
|
|
199
|
-
$contact->phone = '+6599999999';
|
|
200
|
-
expect($emailCheck->invoke($observer, $contact))->toBeFalse()
|
|
201
|
-
->and($phoneCheck->invoke($observer, $contact))->toBeFalse();
|
|
202
|
-
|
|
203
|
-
// Deletion removes the associated user account
|
|
204
205
|
$observer->deleted($contact);
|
|
205
|
-
|
|
206
|
+
$observer->deleted($staffContact);
|
|
207
|
+
|
|
208
|
+
// The managed account had no other profile, so it is deleted
|
|
209
|
+
expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull()
|
|
210
|
+
->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull();
|
|
206
211
|
});
|
|
207
212
|
|
|
208
213
|
test('contact observer rejects saves whose email or phone belongs to another account', function () {
|
|
209
214
|
$connection = fleetopsSmallModelBoot();
|
|
210
|
-
$connection->table('users')->insert([
|
|
215
|
+
$connection->table('users')->insert([
|
|
216
|
+
['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'],
|
|
217
|
+
['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'],
|
|
218
|
+
]);
|
|
211
219
|
|
|
212
220
|
$observer = new ContactObserver();
|
|
213
221
|
|
|
214
|
-
// The availability checks are private, so the guards can only be reached
|
|
215
|
-
// through saving() with a contact that genuinely collides on a real table.
|
|
216
222
|
// hasUser() keys off a well-formed uuid, so a real one keeps saving() from
|
|
217
|
-
// detouring into account provisioning before it reaches the
|
|
223
|
+
// detouring into account provisioning before it reaches the sync.
|
|
218
224
|
$contact = new Fleetbase\FleetOps\Models\Contact();
|
|
219
225
|
$contact->setRawAttributes([
|
|
220
226
|
'uuid' => 'contact-collide',
|
|
@@ -226,23 +232,16 @@ test('contact observer rejects saves whose email or phone belongs to another acc
|
|
|
226
232
|
], true);
|
|
227
233
|
$contact->exists = true;
|
|
228
234
|
|
|
229
|
-
// wasChanged() reads the post-save change set, which setRawAttributes does
|
|
230
|
-
// not populate — syncChanges promotes the pending edit into it.
|
|
231
235
|
$contact->email = 'taken@example.test';
|
|
232
|
-
$contact->syncChanges();
|
|
233
236
|
|
|
234
237
|
expect(fn () => $observer->saving($contact))
|
|
235
|
-
->toThrow(
|
|
238
|
+
->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.');
|
|
236
239
|
|
|
237
|
-
// The phone guard sits behind the email one, so it only surfaces once the
|
|
238
|
-
// email is back to an available value
|
|
239
|
-
$contact->syncOriginal();
|
|
240
240
|
$contact->email = 'free@example.test';
|
|
241
241
|
$contact->phone = '+6512345678';
|
|
242
|
-
$contact->syncChanges();
|
|
243
242
|
|
|
244
243
|
expect(fn () => $observer->saving($contact))
|
|
245
|
-
->toThrow(
|
|
244
|
+
->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.');
|
|
246
245
|
});
|
|
247
246
|
|
|
248
247
|
test('tracking status request authorizes by session and rejects duplicates', function () {
|
|
@@ -98,11 +98,16 @@ test('driver observer seams unassign orders and resolve the driver user', functi
|
|
|
98
98
|
->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull()
|
|
99
99
|
->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other');
|
|
100
100
|
|
|
101
|
-
// The linked account resolves
|
|
101
|
+
// The linked account resolves whatever its type: releaseForProfile decides
|
|
102
|
+
// whether a managed account is deleted and leaves a team member alone
|
|
102
103
|
expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1');
|
|
103
104
|
|
|
105
|
+
$managedDriver = new Driver();
|
|
106
|
+
$managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
|
|
107
|
+
expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2');
|
|
108
|
+
|
|
104
109
|
$driverWithoutUser = new Driver();
|
|
105
|
-
$driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-
|
|
110
|
+
$driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true);
|
|
106
111
|
expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull();
|
|
107
112
|
});
|
|
108
113
|
|
|
@@ -4,6 +4,10 @@ if (!class_exists('Fleetbase\Ai\Support\AiCapabilityRegistry', false)) {
|
|
|
4
4
|
eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }');
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) {
|
|
8
|
+
eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }');
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) {
|
|
8
12
|
eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }');
|
|
9
13
|
}
|
|
@@ -44,9 +48,18 @@ test('ai capability registration wires query resources and capabilities', functi
|
|
|
44
48
|
$capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class);
|
|
45
49
|
|
|
46
50
|
fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n");
|
|
47
|
-
expect($capabilityRegistry->registered)->toHaveCount(
|
|
51
|
+
expect($capabilityRegistry->registered)->toHaveCount(13)
|
|
48
52
|
->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability)))
|
|
49
|
-
->toContain(
|
|
53
|
+
->toContain(
|
|
54
|
+
Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class,
|
|
55
|
+
Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class,
|
|
56
|
+
Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class,
|
|
57
|
+
Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class,
|
|
58
|
+
Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
$commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class);
|
|
62
|
+
expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open');
|
|
50
63
|
|
|
51
64
|
// The query resources helper registers the fleet-ops queryables directly
|
|
52
65
|
$freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry();
|
|
@@ -117,4 +117,46 @@ test('search capability queries scope companies with eager relations', function
|
|
|
117
117
|
->and($helper('vehicleSearchQuery')->count())->toBe(0)
|
|
118
118
|
->and($helper('driverSearchQuery')->count())->toBe(3)
|
|
119
119
|
->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0);
|
|
120
|
+
|
|
121
|
+
foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) {
|
|
122
|
+
expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('asset status scoped queries resolve a list permission for every supported model', function () {
|
|
127
|
+
fleetopsAssetStatusCapabilityBoot();
|
|
128
|
+
|
|
129
|
+
$capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
|
|
130
|
+
$scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery');
|
|
131
|
+
$scoped->setAccessible(true);
|
|
132
|
+
|
|
133
|
+
foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) {
|
|
134
|
+
expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('capability queries apply IAM directives for the list permission when the directive macro is available', function () {
|
|
139
|
+
fleetopsAssetStatusCapabilityBoot();
|
|
140
|
+
|
|
141
|
+
if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
|
|
142
|
+
Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) {
|
|
143
|
+
$GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names;
|
|
144
|
+
|
|
145
|
+
return $this;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
$GLOBALS['fleetopsAppliedDirectivePermissions'] = [];
|
|
150
|
+
|
|
151
|
+
$capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
|
|
152
|
+
$scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission');
|
|
153
|
+
$scope->setAccessible(true);
|
|
154
|
+
$plain = new stdClass();
|
|
155
|
+
|
|
156
|
+
expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain);
|
|
157
|
+
|
|
158
|
+
$builder = Fleetbase\FleetOps\Models\Driver::query();
|
|
159
|
+
|
|
160
|
+
expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder)
|
|
161
|
+
->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver');
|
|
120
162
|
});
|
|
@@ -123,6 +123,11 @@ class FleetOpsOrderInsightsCapabilityFake extends OrderInsightsCapability
|
|
|
123
123
|
return $this->allowed && $permission === 'fleet-ops see order';
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
protected function currency(): string
|
|
127
|
+
{
|
|
128
|
+
return 'USD';
|
|
129
|
+
}
|
|
130
|
+
|
|
126
131
|
protected function orderQuery(?string $companyUuid): mixed
|
|
127
132
|
{
|
|
128
133
|
$this->companies[] = $companyUuid;
|
|
@@ -183,6 +188,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
|
|
|
183
188
|
'authorized' => true,
|
|
184
189
|
'metric' => 'orders',
|
|
185
190
|
'amount_threshold' => 125.50,
|
|
191
|
+
'amount_currency' => 'USD',
|
|
186
192
|
'count' => 7,
|
|
187
193
|
'counts_by_status' => ['completed' => 5, 'active' => 2],
|
|
188
194
|
'sample_order_ids' => ['order_1', 'order_2'],
|
|
@@ -196,7 +202,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
|
|
|
196
202
|
->and($capability->companies)->toBe(['company-123'])
|
|
197
203
|
->and($capability->query->recordedCalls())->toContain(
|
|
198
204
|
['whereBetween', 'created_at', [$start, $end]],
|
|
199
|
-
['whereHas', 'transaction', [['where', 'amount', '>',
|
|
205
|
+
['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]],
|
|
200
206
|
['count'],
|
|
201
207
|
['selectRaw', 'status, count(*) as aggregate'],
|
|
202
208
|
['groupBy', 'status'],
|
|
@@ -224,3 +230,15 @@ test('order insights resolve omits optional filters when prompt has no date or a
|
|
|
224
230
|
->and($capability->query->recordedCalls())->not->toContain(['whereBetween'])
|
|
225
231
|
->and($capability->query->recordedCalls())->not->toContain(['whereHas']);
|
|
226
232
|
});
|
|
233
|
+
|
|
234
|
+
test('order insights converts major-unit thresholds using the currency exponent', function () {
|
|
235
|
+
$capability = new FleetOpsOrderInsightsCapabilityFake();
|
|
236
|
+
$method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits');
|
|
237
|
+
$method->setAccessible(true);
|
|
238
|
+
|
|
239
|
+
expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000)
|
|
240
|
+
->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550)
|
|
241
|
+
->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000)
|
|
242
|
+
->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500)
|
|
243
|
+
->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000);
|
|
244
|
+
});
|
|
@@ -67,6 +67,17 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability
|
|
|
67
67
|
public array $queries = [];
|
|
68
68
|
public array $appliedLikes = [];
|
|
69
69
|
|
|
70
|
+
public array $failing = [];
|
|
71
|
+
|
|
72
|
+
public function exposeSearchTerms(string $prompt): array
|
|
73
|
+
{
|
|
74
|
+
return $this->searchTerms($prompt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected function reportSearchFailure(string $resource, Throwable $e): void
|
|
78
|
+
{
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
public function exposePromptMatches(string $prompt): bool
|
|
71
82
|
{
|
|
72
83
|
return $this->matchesPrompt($prompt);
|
|
@@ -103,6 +114,10 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability
|
|
|
103
114
|
|
|
104
115
|
protected function genericSearchQuery(string $modelClass)
|
|
105
116
|
{
|
|
117
|
+
if ($modelClass === Sensor::class && in_array('sensors', $this->failing, true)) {
|
|
118
|
+
throw new RuntimeException("SQLSTATE[42S22]: Column not found: 1054 Unknown column 'sensor_type'");
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
return match ($modelClass) {
|
|
107
122
|
WorkOrder::class => $this->queries['work_orders'],
|
|
108
123
|
Maintenance::class => $this->queries['maintenances'],
|
|
@@ -197,7 +212,7 @@ test('search resources resolve returns authorized resource summaries across all
|
|
|
197
212
|
$result = $capability->resolve(new AiTask(['prompt' => 'Find order ORDER-1 vehicle VEH-1 driver DRV-1 device DEV-1 sensor SNS-1 telematic TEL-1']));
|
|
198
213
|
|
|
199
214
|
expect($capability->exposePromptMatches('look up work order WO-1'))->toBeTrue()
|
|
200
|
-
->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', '
|
|
215
|
+
->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'DEV-1', 'SNS-1', 'TEL-1'])
|
|
201
216
|
->and($result['results'])->toHaveKeys(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
|
|
202
217
|
->and($result['results']['orders'][0])->toMatchArray([
|
|
203
218
|
'id' => 'order_public',
|
|
@@ -235,3 +250,72 @@ test('search resources resolve returns authorized resource summaries across all
|
|
|
235
250
|
->and($capability->queries['orders']->calls)->toContain(['limit', 5], ['get'])
|
|
236
251
|
->and($capability->appliedLikes)->not->toBeEmpty();
|
|
237
252
|
});
|
|
253
|
+
|
|
254
|
+
function fleetopsSearchResourcesEmptyQueries(): array
|
|
255
|
+
{
|
|
256
|
+
return collect(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
|
|
257
|
+
->mapWithKeys(fn ($key) => [$key => new FleetOpsSearchResourcesQueryFake()])
|
|
258
|
+
->all();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('search terms ignore ordinary words from real production prompts', function () {
|
|
262
|
+
$capability = new FleetOpsSearchResourcesCapabilityProbe();
|
|
263
|
+
|
|
264
|
+
expect($capability->exposeSearchTerms('i want turn this into a platform where i add cisterns and on a different page BUSINESS leave they orders'))->toBe([])
|
|
265
|
+
->and($capability->exposeSearchTerms('Create a few dummy orders so we can test with it'))->toBe([])
|
|
266
|
+
->and($capability->exposeSearchTerms('download order import template'))->toBe([])
|
|
267
|
+
->and($capability->exposeSearchTerms('config driver app ยังไง'))->toBe([])
|
|
268
|
+
->and($capability->exposeSearchTerms('como activar un conductor'))->toBe([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('search terms keep record references such as public ids, plates, emails, quoted and capitalized names', function () {
|
|
272
|
+
$capability = new FleetOpsSearchResourcesCapabilityProbe();
|
|
273
|
+
|
|
274
|
+
expect($capability->exposeSearchTerms('status of order order_yhkejdnzgz'))->toBe(['order_yhkejdnzgz'])
|
|
275
|
+
->and($capability->exposeSearchTerms('vehicle plate SBA1234Z'))->toBe(['SBA1234Z'])
|
|
276
|
+
->and($capability->exposeSearchTerms('driver with email ada@example.com'))->toBe(['ada@example.com'])
|
|
277
|
+
->and($capability->exposeSearchTerms('where is driver Jane Doe right now'))->toBe(['Jane', 'Doe'])
|
|
278
|
+
->and($capability->exposeSearchTerms('find "north depot" vehicles'))->toBe(['north depot']);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('search resources skips querying when the prompt has no record reference', function () {
|
|
282
|
+
$capability = new FleetOpsSearchResourcesCapabilityProbe();
|
|
283
|
+
$capability->allowedPermissions = $capability->permissions();
|
|
284
|
+
$capability->queries = fleetopsSearchResourcesEmptyQueries();
|
|
285
|
+
|
|
286
|
+
$result = $capability->resolve(new AiTask(['prompt' => 'how do I create a new order']));
|
|
287
|
+
|
|
288
|
+
expect($result['query_terms'])->toBe([])
|
|
289
|
+
->and($result['results'])->toBe([])
|
|
290
|
+
->and($capability->appliedLikes)->toBe([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('search resources never searches the dropped sensor_type column or status/uuid columns', function () {
|
|
294
|
+
$capability = new FleetOpsSearchResourcesCapabilityProbe();
|
|
295
|
+
$capability->allowedPermissions = $capability->permissions();
|
|
296
|
+
$capability->queries = fleetopsSearchResourcesEmptyQueries();
|
|
297
|
+
|
|
298
|
+
$capability->resolve(new AiTask(['prompt' => 'find SNS-1']));
|
|
299
|
+
|
|
300
|
+
$columns = collect($capability->appliedLikes)->flatMap(fn ($like) => $like[0])->unique()->all();
|
|
301
|
+
|
|
302
|
+
expect($columns)->not->toContain('sensor_type')
|
|
303
|
+
->and($columns)->not->toContain('status')
|
|
304
|
+
->and($columns)->not->toContain('uuid')
|
|
305
|
+
->and($columns)->not->toContain('type');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('a failing resource search does not discard the other results or leak the error', function () {
|
|
309
|
+
$vehicle = fleetopsSearchResourcesModel(Vehicle::class, ['public_id' => 'vehicle_public', 'uuid' => 'vehicle-uuid', 'name' => 'Van 12']);
|
|
310
|
+
|
|
311
|
+
$capability = new FleetOpsSearchResourcesCapabilityProbe();
|
|
312
|
+
$capability->allowedPermissions = $capability->permissions();
|
|
313
|
+
$capability->failing = ['sensors'];
|
|
314
|
+
$capability->queries = array_merge(fleetopsSearchResourcesEmptyQueries(), ['vehicles' => new FleetOpsSearchResourcesQueryFake([$vehicle])]);
|
|
315
|
+
|
|
316
|
+
$result = $capability->resolve(new AiTask(['prompt' => 'find VEH-12']));
|
|
317
|
+
|
|
318
|
+
expect($result['results'])->toHaveKey('vehicles')
|
|
319
|
+
->and($result['unavailable_search'])->toBe(['sensors'])
|
|
320
|
+
->and(json_encode($result))->not->toContain('SQLSTATE');
|
|
321
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands;
|
|
4
|
+
|
|
5
|
+
test('fleet-ops console commands are unique, complete, and only navigate or call allowlisted services', function () {
|
|
6
|
+
$commands = collect(FleetOpsAiConsoleCommands::all());
|
|
7
|
+
|
|
8
|
+
expect($commands->pluck('id')->duplicates())->toBeEmpty()
|
|
9
|
+
->and($commands->every(fn ($command) => filled($command['label']) && filled($command['breadcrumb']) && filled($command['description']) && !empty($command['steps'])))->toBeTrue()
|
|
10
|
+
->and($commands->every(fn ($command) => collect($command['steps'])->every(fn ($step) => $step['type'] === 'navigate'
|
|
11
|
+
? str_starts_with($step['route'], 'console.fleet-ops.')
|
|
12
|
+
: $step['type'] === 'service' && $step['engine'] === FleetOpsAiConsoleCommands::ENGINE && preg_match('/^[a-zA-Z]+$/', $step['method']))))->toBeTrue()
|
|
13
|
+
->and($commands->every(fn ($command) => ($command['audience'] ?? 'end_user') === 'end_user'))->toBeTrue();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('fleet-ops console commands cover create, import, view, and settings', function () {
|
|
17
|
+
$commands = collect(FleetOpsAiConsoleCommands::all())->keyBy('id');
|
|
18
|
+
|
|
19
|
+
expect($commands['fleet-ops.orders.create']['steps'])->toBe([['type' => 'navigate', 'route' => 'console.fleet-ops.operations.orders.index.new']])
|
|
20
|
+
->and($commands['fleet-ops.orders.create']['permissions'])->toBe(['fleet-ops create order'])
|
|
21
|
+
->and($commands['fleet-ops.orders.import']['steps'][1])->toBe(['type' => 'service', 'engine' => '@fleetbase/fleetops-engine', 'service' => 'order-actions', 'method' => 'importOrders'])
|
|
22
|
+
->and($commands['fleet-ops.customers.import']['steps'][1]['service'])->toBe('customer-actions')
|
|
23
|
+
->and($commands['fleet-ops.customers.import']['permissions'])->toBe(['fleet-ops import contact'])
|
|
24
|
+
->and($commands)->not->toHaveKey('fleet-ops.fleets.import')
|
|
25
|
+
->and($commands['fleet-ops.drivers.view']['steps'][0]['models'])->toBe(['public_id'])
|
|
26
|
+
->and($commands['fleet-ops.drivers.view']['params'])->toHaveKey('public_id')
|
|
27
|
+
->and($commands['fleet-ops.settings.map.open']['steps'][0]['route'])->toBe('console.fleet-ops.settings.map')
|
|
28
|
+
->and($commands['fleet-ops.settings.map.open']['permissions'])->toBe(['fleet-ops view map-settings'])
|
|
29
|
+
->and($commands['fleet-ops.settings.map.open']['keywords'])->toContain('google maps', 'view maps')
|
|
30
|
+
->and($commands['fleet-ops.settings.scheduling.open']['permissions'])->toBe([]);
|
|
31
|
+
});
|