@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
|
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Models;
|
|
|
5
5
|
use Fleetbase\Casts\Json;
|
|
6
6
|
use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
|
|
7
7
|
use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
|
|
8
|
+
use Fleetbase\FleetOps\Support\ProfileAccountManager;
|
|
8
9
|
use Fleetbase\FleetOps\Support\Utils;
|
|
9
10
|
use Fleetbase\Models\CompanyUser;
|
|
10
11
|
use Fleetbase\Models\Model;
|
|
@@ -157,9 +158,13 @@ class Contact extends Model
|
|
|
157
158
|
return $this->belongsTo(User::class, 'user_uuid');
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The contact's login account: a managed `contact`/`customer` account, or a
|
|
163
|
+
* team member's account linked by email or phone.
|
|
164
|
+
*/
|
|
160
165
|
public function user(): BelongsTo|Builder
|
|
161
166
|
{
|
|
162
|
-
return $this->belongsTo(User::class, 'user_uuid')
|
|
167
|
+
return $this->belongsTo(User::class, 'user_uuid');
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
public function photo(): BelongsTo
|
|
@@ -316,74 +321,16 @@ class Contact extends Model
|
|
|
316
321
|
*/
|
|
317
322
|
public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User
|
|
318
323
|
{
|
|
319
|
-
// Check if user already exist with email or phone number
|
|
320
|
-
$existingUser = null;
|
|
321
|
-
if ($contact->email || $contact->phone) {
|
|
322
|
-
$existingUser = User::where(function ($query) use ($contact) {
|
|
323
|
-
$query->where('company_uuid', $contact->company_uuid)
|
|
324
|
-
->orWhereHas('companyUsers', function ($query) use ($contact) {
|
|
325
|
-
$query->where('company_uuid', $contact->company_uuid);
|
|
326
|
-
});
|
|
327
|
-
})
|
|
328
|
-
->where(function ($query) use ($contact) {
|
|
329
|
-
if ($contact->email) {
|
|
330
|
-
$query->where('email', $contact->email);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if ($contact->phone) {
|
|
334
|
-
$method = $contact->email ? 'orWhere' : 'where';
|
|
335
|
-
$query->{$method}('phone', Utils::formatPhoneNumber($contact->phone));
|
|
336
|
-
}
|
|
337
|
-
})
|
|
338
|
-
->whereNull('deleted_at')
|
|
339
|
-
->first();
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if ($existingUser) {
|
|
343
|
-
if ($contact->isCustomer()) {
|
|
344
|
-
$contact->assertCustomerUserCanBeAssigned($existingUser);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Check if existing user belongs to another contact
|
|
348
|
-
$existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first();
|
|
349
|
-
if ($existingUserContact) {
|
|
350
|
-
throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser);
|
|
351
|
-
}
|
|
352
|
-
// Assign the user to this contact instead
|
|
353
|
-
$contact->setAttribute('user_uuid', $existingUser->uuid);
|
|
354
|
-
if ($update) {
|
|
355
|
-
$contact->update(['user_uuid' => $existingUser->uuid]);
|
|
356
|
-
}
|
|
357
|
-
$contact->setRelation('user', $existingUser);
|
|
358
|
-
|
|
359
|
-
return $existingUser;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Load company
|
|
363
324
|
$contact->loadMissing('company');
|
|
364
325
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
'
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
'
|
|
372
|
-
|
|
373
|
-
'timezone' => $contact->company->timezone ?? date_default_timezone_get(),
|
|
374
|
-
'status' => 'pending',
|
|
375
|
-
]);
|
|
376
|
-
|
|
377
|
-
// Set user type
|
|
378
|
-
$user->setType($contact->type);
|
|
379
|
-
|
|
380
|
-
// Assign to company without triggering core organization invitations.
|
|
381
|
-
static::assignUserToContactCompany($contact, $user);
|
|
382
|
-
|
|
383
|
-
// Assign customer role
|
|
384
|
-
if ($contact->isCustomer()) {
|
|
385
|
-
$user->assignSingleRole('Fleet-Ops Customer');
|
|
386
|
-
}
|
|
326
|
+
$user = ProfileAccountManager::resolveForProfile(
|
|
327
|
+
$contact->company_uuid,
|
|
328
|
+
$contact->isCustomer() ? 'customer' : 'contact',
|
|
329
|
+
$contact->name,
|
|
330
|
+
$contact->email,
|
|
331
|
+
$contact->phone,
|
|
332
|
+
['timezone' => $contact->company->timezone ?? null]
|
|
333
|
+
);
|
|
387
334
|
|
|
388
335
|
// Set user to contact
|
|
389
336
|
$contact->setAttribute('user_uuid', $user->uuid);
|
|
@@ -412,6 +359,13 @@ class Contact extends Model
|
|
|
412
359
|
|
|
413
360
|
$this->assertCustomerUserCanBeAssigned($user);
|
|
414
361
|
|
|
362
|
+
// A team member's account keeps its own organization role
|
|
363
|
+
if (ProfileAccountManager::isStaffAccount($user)) {
|
|
364
|
+
$this->setRelation('user', $user);
|
|
365
|
+
|
|
366
|
+
return $user;
|
|
367
|
+
}
|
|
368
|
+
|
|
415
369
|
$this->loadMissing('company');
|
|
416
370
|
if ($this->company) {
|
|
417
371
|
$companyUser = CompanyUser::firstOrCreate(
|
|
@@ -511,7 +465,8 @@ class Contact extends Model
|
|
|
511
465
|
// Get the company user instance
|
|
512
466
|
$companyUser = $user->getCompanyUser($this->company);
|
|
513
467
|
|
|
514
|
-
|
|
468
|
+
// A team member's account keeps its own organization role
|
|
469
|
+
if ($companyUser && ProfileAccountManager::isManagedAccount($user)) {
|
|
515
470
|
$companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
|
|
516
471
|
}
|
|
517
472
|
|
|
@@ -546,6 +501,11 @@ class Contact extends Model
|
|
|
546
501
|
return;
|
|
547
502
|
}
|
|
548
503
|
|
|
504
|
+
// A team member of the organization can hold a customer profile
|
|
505
|
+
if (ProfileAccountManager::isStaffAccount($user) && ProfileAccountManager::isCompanyMember($user, $this->company_uuid)) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
549
509
|
throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user);
|
|
550
510
|
}
|
|
551
511
|
|
|
@@ -553,6 +513,10 @@ class Contact extends Model
|
|
|
553
513
|
{
|
|
554
514
|
$field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account');
|
|
555
515
|
|
|
516
|
+
if (ProfileAccountManager::isManagedAccount($user)) {
|
|
517
|
+
return 'This ' . $field . ' is already used by a ' . $user->type . ' and cannot be used for a customer account.';
|
|
518
|
+
}
|
|
519
|
+
|
|
556
520
|
return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.';
|
|
557
521
|
}
|
|
558
522
|
|
|
@@ -591,32 +555,31 @@ class Contact extends Model
|
|
|
591
555
|
return User::where('uuid', $this->user_uuid)->first();
|
|
592
556
|
}
|
|
593
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Push the contact's changed proxy fields (name, email, phone, timezone) to
|
|
560
|
+
* its login account. A team member's account only takes the name.
|
|
561
|
+
*
|
|
562
|
+
* @throws \Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException
|
|
563
|
+
*/
|
|
594
564
|
public function syncWithUser(): bool
|
|
595
565
|
{
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
$updates['name'] = $this->name;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
if ($this->isDirty('email')) {
|
|
603
|
-
$updates['email'] = $this->email;
|
|
566
|
+
// A new contact's account was just resolved from these values
|
|
567
|
+
if (!$this->exists) {
|
|
568
|
+
return false;
|
|
604
569
|
}
|
|
605
570
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
$updates['timezone'] = $this->timezone;
|
|
571
|
+
$updates = [];
|
|
572
|
+
foreach (['name', 'email', 'phone', 'timezone'] as $field) {
|
|
573
|
+
if ($this->isDirty($field)) {
|
|
574
|
+
$updates[$field] = $this->{$field};
|
|
575
|
+
}
|
|
612
576
|
}
|
|
613
577
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return $user->update($updates);
|
|
578
|
+
if (empty($updates)) {
|
|
579
|
+
return false;
|
|
617
580
|
}
|
|
618
581
|
|
|
619
|
-
return
|
|
582
|
+
return ProfileAccountManager::syncProxyFields($this->getUser(), $updates);
|
|
620
583
|
}
|
|
621
584
|
|
|
622
585
|
/**
|
|
@@ -638,12 +601,14 @@ class Contact extends Model
|
|
|
638
601
|
*/
|
|
639
602
|
public function deleteUser(): ?bool
|
|
640
603
|
{
|
|
641
|
-
$this->
|
|
642
|
-
if ($
|
|
643
|
-
return
|
|
604
|
+
$user = $this->getUser();
|
|
605
|
+
if (!ProfileAccountManager::isManagedAccount($user)) {
|
|
606
|
+
return false;
|
|
644
607
|
}
|
|
645
608
|
|
|
646
|
-
|
|
609
|
+
ProfileAccountManager::releaseForProfile($user, $this->company_uuid);
|
|
610
|
+
|
|
611
|
+
return $user->trashed();
|
|
647
612
|
}
|
|
648
613
|
|
|
649
614
|
public function getUser(): ?User
|
|
@@ -75,7 +75,6 @@ class Sensor extends Model
|
|
|
75
75
|
*/
|
|
76
76
|
protected $filterParams = [
|
|
77
77
|
'type',
|
|
78
|
-
'sensor_type',
|
|
79
78
|
'status',
|
|
80
79
|
'device_uuid',
|
|
81
80
|
'serial_number',
|
|
@@ -214,7 +213,7 @@ class Sensor extends Model
|
|
|
214
213
|
public function getSlugOptions(): SlugOptions
|
|
215
214
|
{
|
|
216
215
|
return SlugOptions::create()
|
|
217
|
-
->generateSlugsFrom(['name', '
|
|
216
|
+
->generateSlugsFrom(['name', 'type'])
|
|
218
217
|
->saveSlugsTo('slug')
|
|
219
218
|
->doNotGenerateSlugsOnUpdate();
|
|
220
219
|
}
|
|
@@ -232,7 +231,6 @@ class Sensor extends Model
|
|
|
232
231
|
'sensorable_uuid',
|
|
233
232
|
'sensorable_type',
|
|
234
233
|
'type',
|
|
235
|
-
'sensor_type',
|
|
236
234
|
'internal_id',
|
|
237
235
|
'name',
|
|
238
236
|
'unit',
|
|
@@ -396,7 +394,7 @@ class Sensor extends Model
|
|
|
396
394
|
*/
|
|
397
395
|
public function scopeByType($query, string $type)
|
|
398
396
|
{
|
|
399
|
-
return $query->where('
|
|
397
|
+
return $query->where('type', $type);
|
|
400
398
|
}
|
|
401
399
|
|
|
402
400
|
/**
|
|
@@ -493,7 +491,7 @@ class Sensor extends Model
|
|
|
493
491
|
'message' => $this->generateThresholdAlertMessage($value, $thresholdStatus),
|
|
494
492
|
'context' => [
|
|
495
493
|
'sensor_name' => $this->name,
|
|
496
|
-
'sensor_type' => $this->
|
|
494
|
+
'sensor_type' => $this->type,
|
|
497
495
|
'value' => $value,
|
|
498
496
|
'unit' => $this->unit,
|
|
499
497
|
'threshold_status' => $thresholdStatus,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Observers;
|
|
4
4
|
|
|
5
5
|
use Fleetbase\FleetOps\Models\Contact;
|
|
6
|
-
use Fleetbase\Models\User;
|
|
7
6
|
|
|
8
7
|
class ContactObserver
|
|
9
8
|
{
|
|
@@ -42,17 +41,8 @@ class ContactObserver
|
|
|
42
41
|
$contact->normalizeCustomerUser();
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
throw new \Exception('Email attempting to update for ' . $contact->type . ' is not available.');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Validate phone is available to user
|
|
51
|
-
if (!empty($contact->phone) && $contact->wasChanged('phone') && $this->isPhoneUnavailable($contact)) {
|
|
52
|
-
throw new \Exception('Phone attempting to update for ' . $contact->type . ' is not available.');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Sync updates from contact to user
|
|
44
|
+
// Sync updates from contact to its login account. This throws when the
|
|
45
|
+
// new email or phone is already used by another account.
|
|
56
46
|
$contact->syncWithUser();
|
|
57
47
|
}
|
|
58
48
|
|
|
@@ -66,14 +56,4 @@ class ContactObserver
|
|
|
66
56
|
// Delete the assosciated user account
|
|
67
57
|
$contact->deleteUser();
|
|
68
58
|
}
|
|
69
|
-
|
|
70
|
-
private function isEmailUnavailable(Contact $contact)
|
|
71
|
-
{
|
|
72
|
-
return User::where('email', $contact->email)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['email' => $contact->email, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private function isPhoneUnavailable(Contact $contact)
|
|
76
|
-
{
|
|
77
|
-
return User::where('phone', $contact->phone)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['phone' => $contact->phone, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists();
|
|
78
|
-
}
|
|
79
59
|
}
|
|
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Observers;
|
|
|
5
5
|
use Fleetbase\FleetOps\Models\Driver;
|
|
6
6
|
use Fleetbase\FleetOps\Models\Order;
|
|
7
7
|
use Fleetbase\FleetOps\Support\LiveCacheService;
|
|
8
|
+
use Fleetbase\FleetOps\Support\ProfileAccountManager;
|
|
8
9
|
use Fleetbase\LaravelMysqlSpatial\Types\Point;
|
|
9
10
|
use Fleetbase\Models\User;
|
|
10
11
|
|
|
@@ -64,11 +65,9 @@ class DriverObserver
|
|
|
64
65
|
// Unassign them from any order they are assigned to
|
|
65
66
|
$this->unassignOrders($driver);
|
|
66
67
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
$user->delete();
|
|
71
|
-
}
|
|
68
|
+
// Delete the driver's managed login account, which frees its email and
|
|
69
|
+
// phone. A team member's account linked to the driver is left alone.
|
|
70
|
+
ProfileAccountManager::releaseForProfile($this->findDriverUser($driver), $driver->company_uuid);
|
|
72
71
|
|
|
73
72
|
$this->invalidateLiveCache();
|
|
74
73
|
}
|
|
@@ -85,6 +84,6 @@ class DriverObserver
|
|
|
85
84
|
|
|
86
85
|
protected function findDriverUser(Driver $driver): ?User
|
|
87
86
|
{
|
|
88
|
-
return User::where(
|
|
87
|
+
return $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null;
|
|
89
88
|
}
|
|
90
89
|
}
|
|
@@ -274,6 +274,12 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
if (Utils::classExists(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class)) {
|
|
278
|
+
$this->callAfterResolving(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class, function ($commands) {
|
|
279
|
+
$commands->registerMany(\Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands::all());
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
277
283
|
$this->callAfterResolving(\Fleetbase\Ai\Support\AiCapabilityRegistry::class, function (\Fleetbase\Ai\Support\AiCapabilityRegistry $registry) {
|
|
278
284
|
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability());
|
|
279
285
|
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OperationalQueryCapability());
|
|
@@ -284,6 +290,15 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
284
290
|
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\CreateOrderPreviewCapability());
|
|
285
291
|
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OptimizeOrderRouteCapability());
|
|
286
292
|
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\ImportOrdersPreviewCapability());
|
|
293
|
+
|
|
294
|
+
// Tool-calling versions share their capability keys, so they replace the keyword-driven ones when
|
|
295
|
+
// the installed AI extension supports tools and leave older AI extensions unaffected.
|
|
296
|
+
if (interface_exists(\Fleetbase\Ai\Contracts\AIToolCapabilityInterface::class)) {
|
|
297
|
+
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool());
|
|
298
|
+
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool());
|
|
299
|
+
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool());
|
|
300
|
+
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool());
|
|
301
|
+
}
|
|
287
302
|
});
|
|
288
303
|
}
|
|
289
304
|
}
|
|
@@ -11,6 +11,17 @@ use Illuminate\Support\Str;
|
|
|
11
11
|
|
|
12
12
|
abstract class AbstractFleetOpsAICapability extends AbstractAICapability implements AIContextCapabilityInterface
|
|
13
13
|
{
|
|
14
|
+
/**
|
|
15
|
+
* Words that never identify a record and must not be used as search terms.
|
|
16
|
+
*/
|
|
17
|
+
protected const SEARCH_STOPWORDS = [
|
|
18
|
+
'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'where', 'when', 'which', 'who', 'how', 'many', 'much',
|
|
19
|
+
'can', 'could', 'would', 'should', 'please', 'help', 'want', 'need', 'show', 'find', 'open', 'look', 'tell', 'about',
|
|
20
|
+
'status', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'device', 'devices', 'sensor', 'sensors',
|
|
21
|
+
'telematic', 'telematics', 'maintenance', 'maintenances', 'work', 'fleet', 'fleets', 'ops', 'create', 'new', 'all',
|
|
22
|
+
'today', 'yesterday', 'tomorrow', 'week', 'month', 'fleetbase', 'import', 'template', 'download', 'dummy', 'test',
|
|
23
|
+
];
|
|
24
|
+
|
|
14
25
|
public function module(): string
|
|
15
26
|
{
|
|
16
27
|
return 'fleet-ops';
|
|
@@ -50,6 +61,18 @@ abstract class AbstractFleetOpsAICapability extends AbstractAICapability impleme
|
|
|
50
61
|
return Auth::can($permission);
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Applies the current user's IAM directives (record-level scoping) for a permission to a query.
|
|
66
|
+
*/
|
|
67
|
+
protected function scopeToPermission($query, string $permission)
|
|
68
|
+
{
|
|
69
|
+
if ($query instanceof Builder && Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
|
|
70
|
+
return $query->applyDirectivesForPermissions($permission);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return $query;
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
protected function canAll(array $permissions): bool
|
|
54
77
|
{
|
|
55
78
|
foreach ($permissions as $permission) {
|
|
@@ -61,18 +84,52 @@ abstract class AbstractFleetOpsAICapability extends AbstractAICapability impleme
|
|
|
61
84
|
return true;
|
|
62
85
|
}
|
|
63
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Extracts record-reference-like search terms from a prompt.
|
|
89
|
+
*
|
|
90
|
+
* Only terms that plausibly identify a record are kept: quoted phrases, tokens containing a digit
|
|
91
|
+
* (public ids, plates, tracking numbers), emails and other delimited identifiers, and capitalized
|
|
92
|
+
* names that are not the first word. Ordinary words are never used as LIKE terms, and an empty
|
|
93
|
+
* array is returned when the prompt does not reference a specific record.
|
|
94
|
+
*/
|
|
64
95
|
protected function searchTerms(string $prompt): array
|
|
65
96
|
{
|
|
66
|
-
|
|
97
|
+
$prompt = trim($prompt);
|
|
98
|
+
$terms = [];
|
|
67
99
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
100
|
+
preg_match_all('/"([^"]{2,64})"|\'([^\']{2,64})\'/u', $prompt, $quoted);
|
|
101
|
+
foreach (array_merge($quoted[1] ?? [], $quoted[2] ?? []) as $phrase) {
|
|
102
|
+
if (trim($phrase) !== '') {
|
|
103
|
+
$terms[] = trim($phrase);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}@._\-]*[\p{L}\p{N}]/u', $prompt, $matches, PREG_OFFSET_CAPTURE);
|
|
108
|
+
foreach ($matches[0] ?? [] as [$token, $offset]) {
|
|
109
|
+
if ($this->isReferenceTerm($token, $offset === 0)) {
|
|
110
|
+
$terms[] = $token;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return collect($terms)
|
|
115
|
+
->unique(fn ($term) => Str::lower($term))
|
|
71
116
|
->take(6)
|
|
72
117
|
->values()
|
|
73
118
|
->all();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
protected function isReferenceTerm(string $token, bool $isFirstWord = false): bool
|
|
122
|
+
{
|
|
123
|
+
if (mb_strlen($token) < 3 || in_array(Str::lower($token), static::SEARCH_STOPWORDS, true)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Public ids, plates, tracking numbers, emails, and upper-case codes such as ORDER-ABC.
|
|
128
|
+
if (preg_match('/\p{N}|[@_]/u', $token) || preg_match('/^[\p{Lu}\p{N}]+(?:-[\p{Lu}\p{N}]+)+$/u', $token)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
74
131
|
|
|
75
|
-
return
|
|
132
|
+
return !$isFirstWord && preg_match('/^\p{Lu}\p{Ll}+$/u', $token) === 1;
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
protected function whereLikeAny(Builder $builder, array $columns, array $terms): void
|
|
@@ -28,7 +28,7 @@ class AssetStatusCapability extends AbstractFleetOpsAICapability
|
|
|
28
28
|
|
|
29
29
|
public function permissions(): array
|
|
30
30
|
{
|
|
31
|
-
return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
|
|
31
|
+
return ['fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
public function resolve(AiTask $task): array
|
|
@@ -92,27 +92,46 @@ class AssetStatusCapability extends AbstractFleetOpsAICapability
|
|
|
92
92
|
|
|
93
93
|
protected function totalForModel(string $modelClass): int
|
|
94
94
|
{
|
|
95
|
-
return $modelClass
|
|
95
|
+
return $this->scopedQuery($modelClass)->count();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
protected function onlineCountForModel(string $modelClass): int
|
|
99
99
|
{
|
|
100
|
-
return $modelClass
|
|
100
|
+
return $this->scopedQuery($modelClass)->where('online', true)->count();
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
protected function offlineCountForModel(string $modelClass): int
|
|
104
104
|
{
|
|
105
|
-
return $modelClass
|
|
105
|
+
return $this->scopedQuery($modelClass)->where(function ($query) {
|
|
106
106
|
$query->where('online', false)->orWhereNull('online');
|
|
107
107
|
})->count();
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
protected function countsByStatusForModel(string $modelClass): array
|
|
111
111
|
{
|
|
112
|
-
return $modelClass
|
|
112
|
+
return $this->scopedQuery($modelClass)
|
|
113
113
|
->selectRaw('status, count(*) as aggregate')
|
|
114
114
|
->groupBy('status')
|
|
115
115
|
->pluck('aggregate', 'status')
|
|
116
116
|
->all();
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Company-scoped query with the user's IAM directives for listing the resource applied.
|
|
121
|
+
*/
|
|
122
|
+
protected function scopedQuery(string $modelClass)
|
|
123
|
+
{
|
|
124
|
+
$permission = match ($modelClass) {
|
|
125
|
+
Driver::class => 'fleet-ops list driver',
|
|
126
|
+
Vehicle::class => 'fleet-ops list vehicle',
|
|
127
|
+
Device::class => 'fleet-ops list device',
|
|
128
|
+
Sensor::class => 'fleet-ops list sensor',
|
|
129
|
+
Telematic::class => 'fleet-ops list telematic',
|
|
130
|
+
default => null,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
$query = $modelClass::where('company_uuid', session('company'));
|
|
134
|
+
|
|
135
|
+
return $permission ? $this->scopeToPermission($query, $permission) : $query;
|
|
136
|
+
}
|
|
118
137
|
}
|
|
@@ -222,8 +222,12 @@ class CreateOrderPreviewCapability extends AbstractFleetOpsAICapability implemen
|
|
|
222
222
|
|
|
223
223
|
protected function buildDraft(AiTask $task, array $input = []): array
|
|
224
224
|
{
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
// A tool call supplies structured fields, so the prompt is not re-parsed. A refresh edits the
|
|
226
|
+
// preview it was opened from, which the AI service passes as existing_draft.
|
|
227
|
+
$fromTool = data_get($input, 'source') === 'tool';
|
|
228
|
+
$existing = (array) (data_get($input, 'existing_draft') ?? ($fromTool ? [] : data_get($task->metadata, 'action_previews.0.draft', [])));
|
|
229
|
+
$changes = (array) data_get($input, 'draft', Arr::except($input, ['source', 'existing_draft']));
|
|
230
|
+
$draft = array_replace_recursive($this->draftFromPrompt($fromTool ? '' : (string) $task->prompt), $existing, $changes);
|
|
227
231
|
|
|
228
232
|
$draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN);
|
|
229
233
|
$draft['payload'] = (array) data_get($draft, 'payload', []);
|
|
@@ -34,17 +34,20 @@ class OrderInsightsCapability extends AbstractFleetOpsAICapability
|
|
|
34
34
|
return ['authorized' => false, 'message' => 'Current user cannot access Fleet-Ops orders.'];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
$prompt
|
|
38
|
-
$window
|
|
39
|
-
$amount
|
|
40
|
-
$
|
|
37
|
+
$prompt = $this->prompt($task);
|
|
38
|
+
$window = $this->dateWindow($prompt);
|
|
39
|
+
$amount = $this->amountThreshold($prompt);
|
|
40
|
+
$currency = $this->currency();
|
|
41
|
+
$minor = $amount !== null ? $this->toMinorUnits($amount, $currency) : null;
|
|
42
|
+
$query = $this->orderQuery(session('company'));
|
|
41
43
|
|
|
42
44
|
if ($window) {
|
|
43
45
|
$query->whereBetween('created_at', [$window['start'], $window['end']]);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
if ($
|
|
47
|
-
|
|
48
|
+
if ($minor !== null) {
|
|
49
|
+
// Transaction amounts are stored in the currency's minor unit (e.g. cents).
|
|
50
|
+
$query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $minor));
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
$total = (clone $query)->count();
|
|
@@ -59,6 +62,7 @@ class OrderInsightsCapability extends AbstractFleetOpsAICapability
|
|
|
59
62
|
'end' => $window['end']->toIso8601String(),
|
|
60
63
|
] : null,
|
|
61
64
|
'amount_threshold' => $amount,
|
|
65
|
+
'amount_currency' => $amount !== null ? $currency : null,
|
|
62
66
|
'count' => $total,
|
|
63
67
|
'counts_by_status' => (clone $query)
|
|
64
68
|
->selectRaw('status, count(*) as aggregate')
|
|
@@ -83,6 +87,35 @@ class OrderInsightsCapability extends AbstractFleetOpsAICapability
|
|
|
83
87
|
return null;
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Converts a major-unit amount (e.g. 500.00) to the minor units transactions are stored in,
|
|
92
|
+
* honoring the currency's ISO-4217 exponent (JPY has none, BHD has three).
|
|
93
|
+
*/
|
|
94
|
+
protected function toMinorUnits(float $amount, string $currency): int
|
|
95
|
+
{
|
|
96
|
+
$exponent = 2;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
if (class_exists(\Money\Currencies\ISOCurrencies::class)) {
|
|
100
|
+
$exponent = (new \Money\Currencies\ISOCurrencies())->subunitFor(new \Money\Currency(strtoupper($currency)));
|
|
101
|
+
}
|
|
102
|
+
} catch (\Throwable) {
|
|
103
|
+
$exponent = 2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (int) round($amount * (10 ** $exponent));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @codeCoverageIgnore
|
|
111
|
+
*/
|
|
112
|
+
protected function currency(): string
|
|
113
|
+
{
|
|
114
|
+
$company = function_exists('session') ? \Fleetbase\Models\Company::where('uuid', session('company'))->first(['uuid', 'currency']) : null;
|
|
115
|
+
|
|
116
|
+
return strtoupper((string) ($company?->currency ?: 'USD'));
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
protected function dateWindow(string $prompt): ?array
|
|
87
120
|
{
|
|
88
121
|
return $this->relativeDateResolver()->resolveWindow($prompt);
|
|
@@ -90,7 +123,7 @@ class OrderInsightsCapability extends AbstractFleetOpsAICapability
|
|
|
90
123
|
|
|
91
124
|
protected function orderQuery(?string $companyUuid): mixed
|
|
92
125
|
{
|
|
93
|
-
return Order::where('company_uuid', $companyUuid);
|
|
126
|
+
return $this->scopeToPermission(Order::where('company_uuid', $companyUuid), 'fleet-ops list order');
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
protected function relativeDateResolver(): AiRelativeDateResolver
|