@fleetbase/fleetops-engine 0.6.68 → 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.
Files changed (100) hide show
  1. package/RELEASE.md +11 -9
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/modals/driver-form.hbs +2 -2
  10. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  11. package/addon/components/modals/reset-profile-credentials.js +70 -0
  12. package/addon/components/profile-identity-hint.hbs +13 -0
  13. package/addon/components/profile-identity-hint.js +49 -0
  14. package/addon/controllers/management/drivers/index.js +4 -0
  15. package/addon/extension.js +4 -2
  16. package/addon/services/contact-actions.js +50 -89
  17. package/addon/services/driver-actions.js +98 -0
  18. package/addon/utils/profile-login-actions.js +172 -0
  19. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  20. package/app/components/profile-identity-hint.js +1 -0
  21. package/composer.json +1 -1
  22. package/extension.json +1 -1
  23. package/package.json +3 -3
  24. package/scripts/pest-bootstrap.php +12 -0
  25. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  26. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  27. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  28. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  29. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  30. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  31. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  32. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  33. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  34. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  35. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  36. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  37. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  38. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  39. package/server/src/Http/Resources/v1/Contact.php +3 -0
  40. package/server/src/Http/Resources/v1/Driver.php +3 -0
  41. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  42. package/server/src/Models/Contact.php +55 -90
  43. package/server/src/Models/Sensor.php +3 -5
  44. package/server/src/Observers/ContactObserver.php +2 -22
  45. package/server/src/Observers/DriverObserver.php +5 -6
  46. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  47. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  48. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  50. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  51. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  52. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  53. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  54. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  55. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  56. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  57. package/server/src/Support/ProfileAccountManager.php +405 -0
  58. package/server/src/routes.php +5 -0
  59. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  60. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  61. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  62. package/server/tests/CommandContractsTest.php +4 -4
  63. package/server/tests/ControllerHelperContractsTest.php +3 -11
  64. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  65. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  66. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  67. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  68. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  69. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  70. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  71. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  72. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  73. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  74. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  75. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  76. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  77. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  78. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  79. package/server/tests/ModelAccessorContractsTest.php +82 -20
  80. package/server/tests/ObserverContractsTest.php +27 -2
  81. package/server/tests/RequestContractsTest.php +19 -11
  82. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  83. package/server/tests/TelematicControllerContractsTest.php +22 -4
  84. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  85. package/server/tests/Unit/Models/ContactTest.php +48 -5
  86. package/server/tests/Unit/Models/SensorTest.php +2 -2
  87. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  88. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  89. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  90. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  91. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  92. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  93. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  94. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  95. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  96. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  97. package/translations/en-us.yaml +37 -2
  98. package/translations/uk-ua.yaml +3475 -0
  99. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  100. 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')->where('type', $this->type);
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
- // Create the user record
366
- $user = User::create([
367
- 'company_uuid' => $contact->company_uuid,
368
- 'name' => $contact->name,
369
- 'email' => $contact->email,
370
- 'phone' => $contact->phone ? Utils::formatPhoneNumber($contact->phone) : null,
371
- 'username' => Str::slug($contact->name . '_' . Str::random(4), '_'),
372
- 'password' => Str::random(),
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
- if ($companyUser) {
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
- $updates = [];
597
-
598
- if ($this->isDirty('name')) {
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
- if ($this->isDirty('phone')) {
607
- $updates['phone'] = $this->phone;
608
- }
609
-
610
- if ($this->isDirty('timezone')) {
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
- $user = $this->getUser();
615
- if ($user) {
616
- return $user->update($updates);
578
+ if (empty($updates)) {
579
+ return false;
617
580
  }
618
581
 
619
- return false;
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->loadMissing('user');
642
- if ($this->user && $this->user->type === $this->type) {
643
- return $this->user->delete();
604
+ $user = $this->getUser();
605
+ if (!ProfileAccountManager::isManagedAccount($user)) {
606
+ return false;
644
607
  }
645
608
 
646
- return false;
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', 'sensor_type'])
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('sensor_type', $type);
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->sensor_type,
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
- // Validate email is available to user
46
- if (!empty($contact->email) && $contact->wasChanged('email') && $this->isEmailUnavailable($contact)) {
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
- // If the driver had a user account with the role driver and type user delete it
68
- $user = $this->findDriverUser($driver);
69
- if ($user && $user->hasRole('Driver')) {
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(['uuid' => $driver->user_uuid, 'type' => 'user'])->first();
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
- preg_match_all('/[A-Z]{2,}[-_][A-Z0-9-_]+|[A-Za-z0-9][A-Za-z0-9-_]{2,}/', (string) $prompt, $matches);
97
+ $prompt = trim($prompt);
98
+ $terms = [];
67
99
 
68
- $terms = collect($matches[0] ?? [])
69
- ->reject(fn ($term) => in_array(Str::lower($term), ['find', 'show', 'open', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'work', 'status', 'about', 'fleet', 'ops'], true))
70
- ->unique()
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 empty($terms) ? [trim((string) $prompt)] : $terms;
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::where('company_uuid', session('company'))->count();
95
+ return $this->scopedQuery($modelClass)->count();
96
96
  }
97
97
 
98
98
  protected function onlineCountForModel(string $modelClass): int
99
99
  {
100
- return $modelClass::where('company_uuid', session('company'))->where('online', true)->count();
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::where('company_uuid', session('company'))->where(function ($query) {
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::where('company_uuid', session('company'))
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
- $existing = (array) data_get($task->metadata, 'action_previews.0.draft', []);
226
- $draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input));
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 = $this->prompt($task);
38
- $window = $this->dateWindow($prompt);
39
- $amount = $this->amountThreshold($prompt);
40
- $query = $this->orderQuery(session('company'));
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 ($amount !== null) {
47
- $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $amount));
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