@fleetbase/fleetops-engine 0.6.55 → 0.6.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/addon/components/admin/map-settings.hbs +1 -1
  2. package/addon/components/ai/create-order-preview.hbs +297 -0
  3. package/addon/components/ai/create-order-preview.js +362 -0
  4. package/addon/components/ai/route-preview-map.hbs +79 -0
  5. package/addon/components/ai/route-preview-map.js +527 -0
  6. package/addon/components/device/details.js +1 -1
  7. package/addon/components/device-event/details.hbs +192 -0
  8. package/addon/components/device-event/details.js +151 -0
  9. package/addon/components/driver/schedule.js +1 -1
  10. package/addon/components/fleet/details.hbs +21 -8
  11. package/addon/components/fleet/details.js +55 -1
  12. package/addon/components/fleet/hierarchy-tree.hbs +137 -0
  13. package/addon/components/fleet/hierarchy-tree.js +528 -0
  14. package/addon/components/layout/fleet-ops-sidebar.js +15 -9
  15. package/addon/components/map/drawer/device-event-listing.js +1 -0
  16. package/addon/components/map/drawer/driver-listing.js +1 -0
  17. package/addon/components/map/drawer/place-listing.js +1 -0
  18. package/addon/components/map/drawer/vehicle-listing.js +1 -0
  19. package/addon/components/order/details.hbs +34 -1
  20. package/addon/components/order/form/details.hbs +4 -0
  21. package/addon/components/order/form.hbs +9 -1
  22. package/addon/components/telematic/details.hbs +1 -1
  23. package/addon/components/telematic/form.hbs +2 -2
  24. package/addon/components/telematic/settings.hbs +1 -1
  25. package/addon/components/vehicle/card.hbs +14 -2
  26. package/addon/components/vehicle/form.hbs +13 -1
  27. package/addon/controllers/connectivity/devices/index/details/events.js +1 -1
  28. package/addon/controllers/connectivity/devices/index/details/vehicle.js +2 -2
  29. package/addon/controllers/connectivity/events/details.js +68 -1
  30. package/addon/controllers/connectivity/sensors/index.js +4 -0
  31. package/addon/controllers/connectivity/telematics/details/events.js +1 -1
  32. package/addon/controllers/connectivity/telematics/details/sensors.js +4 -0
  33. package/addon/controllers/settings/map.js +19 -0
  34. package/addon/extension.js +14 -0
  35. package/addon/instance-initializers/register-vroom-allocation.js +1 -1
  36. package/addon/routes/connectivity/events/details.js +26 -1
  37. package/addon/routes/management/fleets/index/details.js +1 -1
  38. package/addon/routes/management/vehicles/index/details.js +3 -1
  39. package/addon/services/map-adapter/google.js +66 -1
  40. package/addon/services/map-adapter/leaflet.js +1 -1
  41. package/addon/services/map-adapter-interface.js +1 -1
  42. package/addon/services/map-manager.js +16 -1
  43. package/addon/services/map-settings.js +15 -0
  44. package/addon/styles/fleetops-engine.css +487 -1
  45. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +2 -1
  46. package/addon/templates/connectivity/events/details.hbs +40 -1
  47. package/addon/templates/settings/map.hbs +38 -9
  48. package/addon/utils/leaflet-plugin-loader.js +5 -5
  49. package/addon/utils/map-drawer-dropdown-position.js +11 -13
  50. package/addon/utils/route-colors.js +1 -1
  51. package/app/components/ai/create-order-preview.js +1 -0
  52. package/app/components/ai/route-preview-map.js +1 -0
  53. package/app/components/device-event/details.hbs +192 -0
  54. package/app/components/device-event/details.js +1 -0
  55. package/app/components/fleet/hierarchy-tree.js +1 -0
  56. package/app/services/route-engine.js +1 -0
  57. package/composer.json +1 -1
  58. package/extension.json +1 -1
  59. package/package.json +6 -4
  60. package/scripts/normalize-rgba-alpha.js +90 -0
  61. package/server/migrations/2026_06_23_000001_delete_bad_afaqy_telematics_sensors.php +34 -0
  62. package/server/src/Exports/ContactExport.php +12 -5
  63. package/server/src/Exports/DeviceExport.php +92 -0
  64. package/server/src/Exports/DriverExport.php +28 -3
  65. package/server/src/Exports/EquipmentExport.php +94 -0
  66. package/server/src/Exports/FleetExport.php +22 -7
  67. package/server/src/Exports/FuelReportExport.php +16 -1
  68. package/server/src/Exports/IssueExport.php +25 -3
  69. package/server/src/Exports/MaintenanceExport.php +106 -0
  70. package/server/src/Exports/MaintenanceScheduleExport.php +99 -0
  71. package/server/src/Exports/OrderExport.php +24 -5
  72. package/server/src/Exports/PartExport.php +99 -0
  73. package/server/src/Exports/PlaceExport.php +33 -7
  74. package/server/src/Exports/SensorExport.php +94 -0
  75. package/server/src/Exports/ServiceAreaExport.php +31 -7
  76. package/server/src/Exports/ServiceRateExport.php +41 -1
  77. package/server/src/Exports/TelematicExport.php +90 -0
  78. package/server/src/Exports/VehicleExport.php +274 -8
  79. package/server/src/Exports/VendorExport.php +15 -2
  80. package/server/src/Exports/WorkOrderExport.php +92 -0
  81. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +176 -0
  82. package/server/src/Http/Controllers/Api/v1/DeviceController.php +225 -0
  83. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +114 -0
  84. package/server/src/Http/Controllers/Api/v1/FuelTransactionController.php +185 -0
  85. package/server/src/Http/Controllers/Api/v1/PartController.php +120 -0
  86. package/server/src/Http/Controllers/Api/v1/SensorController.php +132 -0
  87. package/server/src/Http/Controllers/Api/v1/WorkOrderController.php +161 -0
  88. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +18 -0
  89. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +17 -0
  90. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +17 -0
  91. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +17 -0
  92. package/server/src/Http/Controllers/Internal/v1/PartController.php +17 -0
  93. package/server/src/Http/Controllers/Internal/v1/SensorController.php +18 -0
  94. package/server/src/Http/Controllers/Internal/v1/SettingController.php +29 -5
  95. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +18 -0
  96. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +17 -0
  97. package/server/src/Http/Filter/DeviceFilter.php +33 -10
  98. package/server/src/Http/Filter/EquipmentFilter.php +23 -0
  99. package/server/src/Http/Filter/FuelProviderTransactionFilter.php +57 -2
  100. package/server/src/Http/Filter/PartFilter.php +47 -0
  101. package/server/src/Http/Filter/SensorFilter.php +34 -6
  102. package/server/src/Http/Filter/WorkOrderFilter.php +23 -0
  103. package/server/src/Http/Requests/CreateDeviceRequest.php +51 -0
  104. package/server/src/Http/Requests/CreateEquipmentRequest.php +35 -0
  105. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +50 -0
  106. package/server/src/Http/Requests/CreatePartRequest.php +40 -0
  107. package/server/src/Http/Requests/CreateSensorRequest.php +47 -0
  108. package/server/src/Http/Requests/CreateWorkOrderRequest.php +42 -0
  109. package/server/src/Http/Requests/UpdateDeviceRequest.php +7 -0
  110. package/server/src/Http/Requests/UpdateEquipmentRequest.php +7 -0
  111. package/server/src/Http/Requests/UpdateFuelTransactionRequest.php +7 -0
  112. package/server/src/Http/Requests/UpdatePartRequest.php +7 -0
  113. package/server/src/Http/Requests/UpdateSensorRequest.php +7 -0
  114. package/server/src/Http/Requests/UpdateWorkOrderRequest.php +7 -0
  115. package/server/src/Http/Resources/v1/Device.php +72 -0
  116. package/server/src/Http/Resources/v1/Equipment.php +57 -0
  117. package/server/src/Http/Resources/v1/Fleet.php +8 -2
  118. package/server/src/Http/Resources/v1/FuelTransaction.php +69 -0
  119. package/server/src/Http/Resources/v1/Part.php +64 -0
  120. package/server/src/Http/Resources/v1/Sensor.php +68 -0
  121. package/server/src/Jobs/SyncTelematicDevicesJob.php +194 -38
  122. package/server/src/Models/Driver.php +6 -0
  123. package/server/src/Models/Order.php +20 -7
  124. package/server/src/Models/Place.php +11 -3
  125. package/server/src/Observers/PurchaseRateObserver.php +2 -1
  126. package/server/src/Providers/FleetOpsServiceProvider.php +26 -0
  127. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +88 -0
  128. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +108 -0
  129. package/server/src/Support/Ai/Capabilities/ConsoleNavigationCapability.php +55 -0
  130. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +508 -0
  131. package/server/src/Support/Ai/Capabilities/DocsHelpCapability.php +49 -0
  132. package/server/src/Support/Ai/Capabilities/ImportOrdersPreviewCapability.php +66 -0
  133. package/server/src/Support/Ai/Capabilities/OperationalQueryCapability.php +319 -0
  134. package/server/src/Support/Ai/Capabilities/OptimizeOrderRouteCapability.php +240 -0
  135. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +95 -0
  136. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +195 -0
  137. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +150 -0
  138. package/server/src/Support/Analytics/RevenueTrend.php +3 -8
  139. package/server/src/Support/Metrics/ActiveRevenueQuery.php +121 -0
  140. package/server/src/Support/Metrics/AvgOrderValueMetric.php +1 -7
  141. package/server/src/Support/Metrics/EarningsMetric.php +1 -10
  142. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +104 -5
  143. package/server/src/Support/Telematics/Providers/SafeeProvider.php +550 -54
  144. package/server/src/Support/Telematics/TelematicService.php +28 -9
  145. package/server/src/Support/Utils.php +52 -0
  146. package/server/src/routes.php +67 -14
  147. package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
  148. package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
  149. package/server/tests/ExportCoverageTest.php +79 -0
  150. package/server/tests/FleetHierarchyResourceTest.php +12 -0
  151. package/server/tests/MetricsRegistryTest.php +45 -5
  152. package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
  153. package/server/tests/PointResolutionTest.php +63 -0
  154. package/server/tests/PublicFleetOpsRestEndpointsTest.php +191 -0
  155. package/server/tests/TelematicsHardeningTest.php +636 -1
  156. package/addon/components/table/cell/dropdown/action-item.hbs +0 -31
  157. package/addon/components/table/cell/dropdown/action-item.js +0 -62
  158. package/addon/components/table/cell/dropdown.hbs +0 -28
  159. package/addon/components/table/cell/dropdown.js +0 -91
  160. package/app/components/table/cell/dropdown/action-item.js +0 -1
  161. package/app/components/table/cell/dropdown.js +0 -1
@@ -0,0 +1,108 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
4
+
5
+ use Fleetbase\Ai\Models\AiTask;
6
+ use Fleetbase\FleetOps\Models\Device;
7
+ use Fleetbase\FleetOps\Models\Driver;
8
+ use Fleetbase\FleetOps\Models\Sensor;
9
+ use Fleetbase\FleetOps\Models\Telematic;
10
+ use Fleetbase\FleetOps\Models\Vehicle;
11
+
12
+ class AssetStatusCapability extends AbstractFleetOpsAICapability
13
+ {
14
+ public function key(): string
15
+ {
16
+ return 'fleet-ops.asset_status';
17
+ }
18
+
19
+ public function label(): string
20
+ {
21
+ return 'Fleet-Ops asset status';
22
+ }
23
+
24
+ public function description(): string
25
+ {
26
+ return 'Summarizes Fleet-Ops vehicle, device, sensor, and telematic status counts.';
27
+ }
28
+
29
+ public function permissions(): array
30
+ {
31
+ return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
32
+ }
33
+
34
+ public function resolve(AiTask $task): array
35
+ {
36
+ return [
37
+ 'drivers' => $this->driverStatus(),
38
+ 'vehicles' => $this->statusCounts(Vehicle::class, 'fleet-ops see vehicle'),
39
+ 'devices' => $this->deviceStatus(),
40
+ 'sensors' => $this->statusCounts(Sensor::class, 'fleet-ops see sensor'),
41
+ 'telematics' => $this->statusCounts(Telematic::class, 'fleet-ops see telematic'),
42
+ ];
43
+ }
44
+
45
+ protected function matchesPrompt(string $prompt): bool
46
+ {
47
+ return $this->containsAny($prompt, ['offline', 'online', 'asset status', 'driver status', 'vehicle status', 'device status', 'sensor status', 'telematic status', 'drivers down', 'devices down', 'vehicles down']);
48
+ }
49
+
50
+ protected function statusCounts(string $modelClass, string $permission): array
51
+ {
52
+ if (!$this->can($permission)) {
53
+ return ['authorized' => false];
54
+ }
55
+
56
+ return [
57
+ 'authorized' => true,
58
+ 'total' => $modelClass::where('company_uuid', session('company'))->count(),
59
+ 'counts_by_status' => $modelClass::where('company_uuid', session('company'))
60
+ ->selectRaw('status, count(*) as aggregate')
61
+ ->groupBy('status')
62
+ ->pluck('aggregate', 'status')
63
+ ->all(),
64
+ ];
65
+ }
66
+
67
+ protected function deviceStatus(): array
68
+ {
69
+ if (!$this->can('fleet-ops see device')) {
70
+ return ['authorized' => false];
71
+ }
72
+
73
+ return [
74
+ 'authorized' => true,
75
+ 'total' => Device::where('company_uuid', session('company'))->count(),
76
+ 'online' => Device::where('company_uuid', session('company'))->where('online', true)->count(),
77
+ 'offline' => Device::where('company_uuid', session('company'))->where(function ($query) {
78
+ $query->where('online', false)->orWhereNull('online');
79
+ })->count(),
80
+ 'counts_by_status' => Device::where('company_uuid', session('company'))
81
+ ->selectRaw('status, count(*) as aggregate')
82
+ ->groupBy('status')
83
+ ->pluck('aggregate', 'status')
84
+ ->all(),
85
+ ];
86
+ }
87
+
88
+ protected function driverStatus(): array
89
+ {
90
+ if (!$this->can('fleet-ops see driver')) {
91
+ return ['authorized' => false];
92
+ }
93
+
94
+ return [
95
+ 'authorized' => true,
96
+ 'total' => Driver::where('company_uuid', session('company'))->count(),
97
+ 'online' => Driver::where('company_uuid', session('company'))->where('online', true)->count(),
98
+ 'offline' => Driver::where('company_uuid', session('company'))->where(function ($query) {
99
+ $query->where('online', false)->orWhereNull('online');
100
+ })->count(),
101
+ 'counts_by_status' => Driver::where('company_uuid', session('company'))
102
+ ->selectRaw('status, count(*) as aggregate')
103
+ ->groupBy('status')
104
+ ->pluck('aggregate', 'status')
105
+ ->all(),
106
+ ];
107
+ }
108
+ }
@@ -0,0 +1,55 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
4
+
5
+ use Fleetbase\Ai\Models\AiTask;
6
+
7
+ class ConsoleNavigationCapability extends SearchResourcesCapability
8
+ {
9
+ public function key(): string
10
+ {
11
+ return 'fleet-ops.console_navigation';
12
+ }
13
+
14
+ public function label(): string
15
+ {
16
+ return 'Fleet-Ops console navigation preview';
17
+ }
18
+
19
+ public function description(): string
20
+ {
21
+ return 'Returns preview-only console route suggestions for Fleet-Ops resources.';
22
+ }
23
+
24
+ public function type(): string
25
+ {
26
+ return 'ui';
27
+ }
28
+
29
+ public function mode(): string
30
+ {
31
+ return 'navigation_preview';
32
+ }
33
+
34
+ public function resolve(AiTask $task): array
35
+ {
36
+ $search = parent::resolve($task);
37
+ $items = collect(data_get($search, 'results', []))
38
+ ->flatten(1)
39
+ ->filter(fn ($result) => is_array($result) && isset($result['route']))
40
+ ->take(5)
41
+ ->values()
42
+ ->all();
43
+
44
+ return [
45
+ 'preview_only' => true,
46
+ 'message' => 'Fleetbase AI can suggest where to navigate, but this pilot does not directly open panels yet.',
47
+ 'suggestions' => $items,
48
+ ];
49
+ }
50
+
51
+ protected function matchesPrompt(string $prompt): bool
52
+ {
53
+ return $this->containsAny($prompt, ['open', 'show me', 'go to', 'navigate', 'take me to']) && parent::matchesPrompt($prompt);
54
+ }
55
+ }
@@ -0,0 +1,508 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Ai\Capabilities;
4
+
5
+ use Fleetbase\Ai\Contracts\AIActionCapabilityInterface;
6
+ use Fleetbase\Ai\Models\AiTask;
7
+ use Fleetbase\Ai\Support\AiRelativeDateResolver;
8
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController;
9
+ use Fleetbase\FleetOps\Models\Driver;
10
+ use Fleetbase\FleetOps\Models\OrderConfig;
11
+ use Fleetbase\FleetOps\Models\Place;
12
+ use Fleetbase\FleetOps\Models\Vehicle;
13
+ use Fleetbase\FleetOps\Support\PlaceSearch;
14
+ use Fleetbase\FleetOps\Support\Utils;
15
+ use Illuminate\Http\JsonResponse;
16
+ use Illuminate\Http\Request;
17
+ use Illuminate\Support\Arr;
18
+ use Illuminate\Support\Str;
19
+
20
+ class CreateOrderPreviewCapability extends AbstractFleetOpsAICapability implements AIActionCapabilityInterface
21
+ {
22
+ public function key(): string
23
+ {
24
+ return 'fleet-ops.create_order';
25
+ }
26
+
27
+ public function label(): string
28
+ {
29
+ return 'Create Fleet-Ops order preview';
30
+ }
31
+
32
+ public function description(): string
33
+ {
34
+ return 'Builds and applies confirmed Fleet-Ops order creation drafts.';
35
+ }
36
+
37
+ public function type(): string
38
+ {
39
+ return 'action';
40
+ }
41
+
42
+ public function mode(): string
43
+ {
44
+ return 'confirmation_required';
45
+ }
46
+
47
+ public function permissions(): array
48
+ {
49
+ return ['fleet-ops create order'];
50
+ }
51
+
52
+ public function previewOnly(): bool
53
+ {
54
+ return false;
55
+ }
56
+
57
+ public function executable(): bool
58
+ {
59
+ return true;
60
+ }
61
+
62
+ public function inputSchema(): array
63
+ {
64
+ return [
65
+ 'order_config_uuid' => 'Fleet-Ops order config UUID. Defaults to the company transport config when available.',
66
+ 'payload.pickup_uuid' => 'Existing Fleet-Ops place UUID for pickup.',
67
+ 'payload.dropoff_uuid' => 'Existing Fleet-Ops place UUID for dropoff.',
68
+ 'payload.waypoints' => 'Optional ordered waypoint place UUIDs.',
69
+ 'customer' => 'Optional contact/vendor UUID.',
70
+ 'scheduled_at' => 'Optional scheduled date/time for the order.',
71
+ 'dispatched' => 'Optional boolean. Defaults to false for AI-created drafts.',
72
+ ];
73
+ }
74
+
75
+ public function shouldPreview(AiTask $task): bool
76
+ {
77
+ return $this->matchesPrompt($this->prompt($task));
78
+ }
79
+
80
+ public function resolve(AiTask $task): array
81
+ {
82
+ return $this->preview($task);
83
+ }
84
+
85
+ public function preview(AiTask $task, array $input = []): array
86
+ {
87
+ $authorized = $this->canAll($this->permissions());
88
+ $draft = $this->buildDraft($task, $input);
89
+ $orderConfig = $this->resolveOrderConfig($draft);
90
+ $pickup = data_get($draft, 'payload.pickup') ?: data_get($draft, 'payload.pickup_query');
91
+ $dropoff = data_get($draft, 'payload.dropoff') ?: data_get($draft, 'payload.dropoff_query');
92
+ $driver = $this->resolveDriver($draft);
93
+ $vehicle = $this->resolveVehicle($draft);
94
+
95
+ if ($orderConfig) {
96
+ $draft['order_config_uuid'] = $orderConfig->uuid;
97
+ $draft['type'] = $orderConfig->key;
98
+ }
99
+
100
+ if ($driver) {
101
+ $draft['driver'] = $driver->uuid;
102
+ $draft['driver_assigned_uuid'] = $driver->uuid;
103
+ }
104
+
105
+ if ($vehicle) {
106
+ $draft['vehicle_assigned_uuid'] = $vehicle->uuid;
107
+ }
108
+
109
+ $missing = array_values(array_filter([
110
+ $authorized ? null : 'permission to create Fleet-Ops orders',
111
+ $orderConfig ? null : 'order configuration',
112
+ $pickup ? null : 'pickup address or place',
113
+ $dropoff ? null : 'dropoff address or place',
114
+ ]));
115
+
116
+ return [
117
+ 'action' => $this->key(),
118
+ 'authorized' => $authorized,
119
+ 'ready' => empty($missing),
120
+ 'missing_fields' => $missing,
121
+ 'message' => empty($missing) ? 'Fleetbase AI prepared a Fleet-Ops order draft. Review it before applying.' : 'Fleetbase AI can create the order after these required details are resolved.',
122
+ 'apply_label' => 'Create order',
123
+ 'cancel_label' => 'Cancel',
124
+ 'draft' => $draft,
125
+ 'route_preview' => $this->routePreview($draft),
126
+ 'options' => [
127
+ 'pod_methods' => $this->podMethods(),
128
+ ],
129
+ 'fields' => [
130
+ ['label' => 'Order config', 'value' => $orderConfig?->name ?? $orderConfig?->key],
131
+ ['label' => 'Schedule', 'value' => data_get($draft, 'scheduled_at')],
132
+ ['label' => 'Pickup', 'value' => data_get($pickup, 'address') ?? data_get($pickup, 'name')],
133
+ ['label' => 'Dropoff', 'value' => data_get($dropoff, 'address') ?? data_get($dropoff, 'name')],
134
+ ['label' => 'Driver', 'value' => $driver?->name],
135
+ ['label' => 'Vehicle', 'value' => $vehicle?->display_name ?? $vehicle?->name],
136
+ ['label' => 'Dispatch immediately', 'value' => data_get($draft, 'dispatched') ? 'Yes' : 'No'],
137
+ ],
138
+ 'risks' => [
139
+ 'The order will be created only after confirmation.',
140
+ 'AI-created drafts do not dispatch by default.',
141
+ ],
142
+ ];
143
+ }
144
+
145
+ public function apply(AiTask $task, array $preview = [], array $input = []): array
146
+ {
147
+ if (!$this->canAll($this->permissions())) {
148
+ throw new \RuntimeException('You do not have permission to create Fleet-Ops orders.');
149
+ }
150
+
151
+ if (!data_get($preview, 'ready')) {
152
+ throw new \RuntimeException('This order draft is missing required fields and cannot be applied yet.');
153
+ }
154
+
155
+ $draft = array_replace_recursive((array) data_get($preview, 'draft', []), (array) data_get($input, 'draft', $input));
156
+ $draft = $this->sanitizeDraftForApply($draft);
157
+ Arr::forget($draft, [
158
+ 'payload.pickup_query',
159
+ 'payload.dropoff_query',
160
+ 'driver_query',
161
+ 'vehicle_query',
162
+ ]);
163
+
164
+ /** @var OrderController $controller */
165
+ $controller = app(OrderController::class);
166
+ $response = $controller->createRecord(new Request(['order' => $draft]));
167
+
168
+ if ($response instanceof JsonResponse && $response->getStatusCode() >= 400) {
169
+ throw new \RuntimeException((string) $response->getContent());
170
+ }
171
+
172
+ $order = data_get($response, 'order');
173
+
174
+ return [
175
+ 'action' => $this->key(),
176
+ 'status' => 'completed',
177
+ 'message' => 'Fleet-Ops order was created.',
178
+ 'resource' => [
179
+ 'type' => 'order',
180
+ 'id' => data_get($order, 'public_id') ?? data_get($order, 'id'),
181
+ 'uuid' => data_get($order, 'uuid'),
182
+ 'route' => 'console.fleet-ops.operations.orders.index.details',
183
+ 'models' => array_filter([data_get($order, 'public_id') ?? data_get($order, 'uuid')]),
184
+ ],
185
+ ];
186
+ }
187
+
188
+ protected function sanitizeDraftForApply(array $draft): array
189
+ {
190
+ foreach (['pickup', 'dropoff', 'return'] as $role) {
191
+ $uuid = data_get($draft, "payload.{$role}_uuid");
192
+ if ($this->hasExistingPlaceUuid($uuid)) {
193
+ Arr::forget($draft, "payload.{$role}");
194
+ }
195
+ }
196
+
197
+ return $draft;
198
+ }
199
+
200
+ protected function hasExistingPlaceUuid($uuid): bool
201
+ {
202
+ return is_string($uuid) && Str::isUuid($uuid) && Place::where('uuid', $uuid)->exists();
203
+ }
204
+
205
+ protected function matchesPrompt(string $prompt): bool
206
+ {
207
+ return str_contains($prompt, 'order') && $this->containsAny($prompt, ['create', 'new order', 'make an order', 'book an order']);
208
+ }
209
+
210
+ protected function buildDraft(AiTask $task, array $input = []): array
211
+ {
212
+ $existing = (array) data_get($task->metadata, 'action_previews.0.draft', []);
213
+ $draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input));
214
+
215
+ $draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN);
216
+ $draft['payload'] = (array) data_get($draft, 'payload', []);
217
+ if (blank(data_get($draft, 'scheduled_at'))) {
218
+ unset($draft['scheduled_at']);
219
+ }
220
+
221
+ foreach (['pickup', 'dropoff'] as $role) {
222
+ $query = data_get($draft, "payload.{$role}_query");
223
+ $place = data_get($draft, "payload.{$role}");
224
+
225
+ if (!$place && $query) {
226
+ $place = $this->resolvePlace($query);
227
+ }
228
+
229
+ if ($place) {
230
+ $draft['payload'][$role] = $place;
231
+ if (!empty($place['uuid'])) {
232
+ $draft['payload']["{$role}_uuid"] = $place['uuid'];
233
+ }
234
+ } elseif ($query) {
235
+ $draft['payload'][$role] = $this->provisionalPlace($query);
236
+ unset($draft['payload']["{$role}_uuid"]);
237
+ }
238
+ }
239
+
240
+ return $draft;
241
+ }
242
+
243
+ protected function draftFromPrompt(string $prompt): array
244
+ {
245
+ $orderConfig = OrderConfig::defaultOrCreate();
246
+ $draft = [
247
+ 'order_config_uuid' => $orderConfig?->uuid,
248
+ 'type' => $orderConfig?->key,
249
+ 'payload' => [],
250
+ 'dispatched' => false,
251
+ ];
252
+
253
+ [$pickup, $dropoff] = $this->addressPairFromPrompt($prompt);
254
+ if ($pickup) {
255
+ $draft['payload']['pickup_query'] = $pickup;
256
+ if ($place = $this->resolvePlace($pickup)) {
257
+ $draft['payload']['pickup'] = $place;
258
+ if (!empty($place['uuid'])) {
259
+ $draft['payload']['pickup_uuid'] = $place['uuid'];
260
+ }
261
+ }
262
+ }
263
+
264
+ if ($dropoff) {
265
+ $draft['payload']['dropoff_query'] = $dropoff;
266
+ if ($place = $this->resolvePlace($dropoff)) {
267
+ $draft['payload']['dropoff'] = $place;
268
+ if (!empty($place['uuid'])) {
269
+ $draft['payload']['dropoff_uuid'] = $place['uuid'];
270
+ }
271
+ }
272
+ }
273
+
274
+ if (str_contains(Str::lower($prompt), 'dispatch')) {
275
+ $draft['dispatched'] = true;
276
+ }
277
+
278
+ if ($scheduledAt = $this->scheduledAtFromPrompt($prompt)) {
279
+ $draft['scheduled_at'] = $scheduledAt;
280
+ }
281
+
282
+ if (preg_match('/(?:note|notes)[:\s]+(.+)$/i', $prompt, $matches)) {
283
+ $draft['notes'] = trim($matches[1]);
284
+ }
285
+
286
+ if ($this->containsAny(Str::lower($prompt), ['proof of delivery', 'pod', 'signature', 'photo proof', 'scan proof'])) {
287
+ $draft['pod_required'] = true;
288
+ $draft['pod_method'] = str_contains(Str::lower($prompt), 'signature') ? 'signature' : (str_contains(Str::lower($prompt), 'photo') ? 'photo' : 'scan');
289
+ }
290
+
291
+ return $draft;
292
+ }
293
+
294
+ protected function scheduledAtFromPrompt(string $prompt): ?string
295
+ {
296
+ return $this->relativeDateResolver()->resolveDateTime($prompt)?->toIso8601String();
297
+ }
298
+
299
+ protected function relativeDateResolver(): AiRelativeDateResolver
300
+ {
301
+ return function_exists('app') ? app(AiRelativeDateResolver::class) : new AiRelativeDateResolver(null);
302
+ }
303
+
304
+ protected function addressPairFromPrompt(string $prompt): array
305
+ {
306
+ preg_match_all('/"([^"]+)"|\'([^\']+)\'/', $prompt, $quoted);
307
+ $quotedValues = collect($quoted[1] ?? [])
308
+ ->merge($quoted[2] ?? [])
309
+ ->filter()
310
+ ->values();
311
+
312
+ if ($quotedValues->count() >= 2) {
313
+ return [$quotedValues->get(0), $quotedValues->get(1)];
314
+ }
315
+
316
+ if (preg_match('/\bfrom\s+(.+?)(?=\s*,?\s+(?:to|towards)\s+)\s*,?\s+(?:to|towards)\s+(.+?)(?=\s+\b(?:with|for|on|using|require|requiring)\b|$)/i', $prompt, $matches)) {
317
+ return [$this->cleanAddress($matches[1]), $this->cleanAddress($matches[2])];
318
+ }
319
+
320
+ $pickup = $this->extractLabeledAddress($prompt, ['pickup', 'pick up'], ['dropoff', 'drop off', 'delivery']);
321
+ $dropoff = $this->extractLabeledAddress($prompt, ['dropoff', 'drop off', 'delivery'], ['return', 'with', 'for', 'on', 'using', 'require', 'requiring']);
322
+
323
+ if ($pickup || $dropoff) {
324
+ return [$pickup, $dropoff];
325
+ }
326
+
327
+ return [null, null];
328
+ }
329
+
330
+ protected function extractLabeledAddress(string $prompt, array $labels, array $nextLabels = []): ?string
331
+ {
332
+ $labelPattern = collect($labels)
333
+ ->map(fn ($label) => preg_quote($label, '/'))
334
+ ->implode('|');
335
+ $nextPattern = collect($nextLabels)
336
+ ->map(fn ($label) => preg_quote($label, '/'))
337
+ ->implode('|');
338
+ $lookahead = $nextPattern ? '(?=\s*,?\s*(?:and\s+)?(?:the\s+)?(?:' . $nextPattern . ')(?:\s+(?:at|to|from))?\s+|$)' : '(?=$)';
339
+
340
+ if (!preg_match('/\b(?:the\s+)?(?:' . $labelPattern . ')(?:\s+(?:at|to|from))?\s+(.+?)' . $lookahead . '/i', $prompt, $matches)) {
341
+ return null;
342
+ }
343
+
344
+ return $this->cleanAddress($matches[1]);
345
+ }
346
+
347
+ protected function cleanAddress(?string $address): ?string
348
+ {
349
+ $address = trim((string) $address);
350
+ $address = preg_replace('/^(?:,|\band\b|\s)+/i', '', $address);
351
+ $address = preg_replace('/(?:,|\band\b|\s)+$/i', '', $address);
352
+ $address = preg_replace('/\s+/', ' ', $address);
353
+
354
+ return $address !== '' ? $address : null;
355
+ }
356
+
357
+ protected function resolvePlace(?string $query): ?array
358
+ {
359
+ $query = $this->cleanAddress($query);
360
+ if (!$query) {
361
+ return null;
362
+ }
363
+
364
+ $places = PlaceSearch::search(Place::where('company_uuid', session('company')), $query, ['geo' => true, 'limit' => 8]);
365
+ $place = $places->first();
366
+
367
+ return $place instanceof Place ? $this->serializePlace($place, $query) : null;
368
+ }
369
+
370
+ protected function provisionalPlace(string $query): array
371
+ {
372
+ return [
373
+ 'id' => null,
374
+ 'uuid' => null,
375
+ 'public_id' => null,
376
+ 'name' => $query,
377
+ 'address' => $query,
378
+ 'latitude' => null,
379
+ 'longitude' => null,
380
+ 'query' => $query,
381
+ 'source' => 'unresolved',
382
+ ];
383
+ }
384
+
385
+ protected function serializePlace(Place $place, ?string $query = null): array
386
+ {
387
+ [$latitude, $longitude] = $this->placeCoordinates($place);
388
+
389
+ return [
390
+ 'id' => $place->public_id,
391
+ 'uuid' => $place->uuid,
392
+ 'public_id' => $place->public_id,
393
+ 'name' => $place->name,
394
+ 'street1' => $place->street1,
395
+ 'street2' => $place->street2,
396
+ 'city' => $place->city,
397
+ 'province' => $place->province,
398
+ 'postal_code' => $place->postal_code,
399
+ 'country' => $place->country,
400
+ 'address' => $place->address ?: $place->toAddressString(['name']),
401
+ 'latitude' => $latitude,
402
+ 'longitude' => $longitude,
403
+ 'query' => $query,
404
+ 'source' => $place->exists ? 'saved' : 'geocoded',
405
+ ];
406
+ }
407
+
408
+ protected function placeCoordinates(Place $place): array
409
+ {
410
+ $latitude = data_get($place, 'latitude');
411
+ $longitude = data_get($place, 'longitude');
412
+
413
+ if ((!is_numeric($latitude) || !is_numeric($longitude)) && $place->location) {
414
+ $point = Utils::getPointFromCoordinates($place->location);
415
+
416
+ if ($point) {
417
+ $latitude = method_exists($point, 'getLat') ? $point->getLat() : data_get($point, 'coordinates.1');
418
+ $longitude = method_exists($point, 'getLng') ? $point->getLng() : data_get($point, 'coordinates.0');
419
+ }
420
+ }
421
+
422
+ return [
423
+ is_numeric($latitude) ? (float) $latitude : null,
424
+ is_numeric($longitude) ? (float) $longitude : null,
425
+ ];
426
+ }
427
+
428
+ protected function routePreview(array $draft): array
429
+ {
430
+ $rawStops = [
431
+ ['role' => 'pickup', 'place' => data_get($draft, 'payload.pickup')],
432
+ ];
433
+ foreach ((array) data_get($draft, 'payload.waypoints', []) as $index => $waypoint) {
434
+ $rawStops[] = ['role' => 'waypoint', 'label' => 'Stop ' . ($index + 1), 'place' => data_get($waypoint, 'place') ?: $waypoint];
435
+ }
436
+ $rawStops[] = ['role' => 'dropoff', 'place' => data_get($draft, 'payload.dropoff')];
437
+
438
+ $stops = collect($rawStops)
439
+ ->filter(fn ($stop) => is_array($stop['place'] ?? null))
440
+ ->map(fn ($stop) => [
441
+ 'role' => $stop['role'],
442
+ 'label' => $stop['label'] ?? Str::title($stop['role']),
443
+ 'address' => data_get($stop, 'place.address') ?? data_get($stop, 'place.name'),
444
+ 'latitude' => data_get($stop, 'place.latitude'),
445
+ 'longitude' => data_get($stop, 'place.longitude'),
446
+ 'coordinates' => is_numeric(data_get($stop, 'place.latitude')) && is_numeric(data_get($stop, 'place.longitude')) ? [(float) data_get($stop, 'place.latitude'), (float) data_get($stop, 'place.longitude')] : null,
447
+ ])
448
+ ->values()
449
+ ->all();
450
+
451
+ return [
452
+ 'stops' => $stops,
453
+ 'coordinates' => collect($stops)
454
+ ->filter(fn ($stop) => is_numeric($stop['latitude']) && is_numeric($stop['longitude']))
455
+ ->map(fn ($stop) => [$stop['latitude'], $stop['longitude']])
456
+ ->values()
457
+ ->all(),
458
+ ];
459
+ }
460
+
461
+ protected function resolveOrderConfig(array $draft): ?OrderConfig
462
+ {
463
+ return OrderConfig::resolveFromIdentifier([data_get($draft, 'order_config_uuid'), data_get($draft, 'type')]) ?? OrderConfig::defaultOrCreate();
464
+ }
465
+
466
+ protected function resolveDriver(array $draft): ?Driver
467
+ {
468
+ $identifier = data_get($draft, 'driver') ?? data_get($draft, 'driver_assigned_uuid') ?? data_get($draft, 'driver_query');
469
+ if (!$identifier) {
470
+ return null;
471
+ }
472
+
473
+ return Driver::where('company_uuid', session('company'))
474
+ ->where(function ($query) use ($identifier) {
475
+ $query->where('uuid', $identifier)->orWhere('public_id', $identifier)->orWhereHas('user', fn ($user) => $user->where('name', 'like', '%' . $identifier . '%'));
476
+ })
477
+ ->first();
478
+ }
479
+
480
+ protected function resolveVehicle(array $draft): ?Vehicle
481
+ {
482
+ $identifier = data_get($draft, 'vehicle') ?? data_get($draft, 'vehicle_assigned_uuid') ?? data_get($draft, 'vehicle_query');
483
+ if (!$identifier) {
484
+ return null;
485
+ }
486
+
487
+ return Vehicle::where('company_uuid', session('company'))
488
+ ->where(function ($query) use ($identifier) {
489
+ $query->where('uuid', $identifier)->orWhere('public_id', $identifier)->orWhere('plate_number', 'like', '%' . $identifier . '%')->orWhere('name', 'like', '%' . $identifier . '%');
490
+ })
491
+ ->first();
492
+ }
493
+
494
+ protected function podMethods(): array
495
+ {
496
+ $methods = config('fleetops.pod_methods', ['scan', 'signature', 'photo']);
497
+ if (is_string($methods)) {
498
+ $methods = explode(',', $methods);
499
+ }
500
+
501
+ return collect((array) $methods)
502
+ ->map(fn ($method) => trim((string) $method))
503
+ ->filter()
504
+ ->unique()
505
+ ->values()
506
+ ->all();
507
+ }
508
+ }