@fleetbase/fleetops-engine 0.6.47 → 0.6.49

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 (223) hide show
  1. package/addon/components/admin/routing-settings.hbs +71 -0
  2. package/addon/components/admin/routing-settings.js +124 -0
  3. package/addon/components/customer/orders.js +1 -1
  4. package/addon/components/display-place.hbs +1 -1
  5. package/addon/components/driver/details.hbs +5 -1
  6. package/addon/components/driver/form.hbs +11 -1
  7. package/addon/components/fleet-listing-panel.hbs +2 -2
  8. package/addon/components/fleet-listing-panel.js +10 -0
  9. package/addon/components/map/google-live-map.js +12 -73
  10. package/addon/components/map/leaflet-live-map.hbs +149 -33
  11. package/addon/components/map/order-list-overlay/order.hbs +18 -3
  12. package/addon/components/map/toolbar.hbs +2 -2
  13. package/addon/components/map/toolbar.js +10 -2
  14. package/addon/components/orchestrator/phase-builder.hbs +49 -5
  15. package/addon/components/orchestrator/phase-builder.js +34 -1
  16. package/addon/components/orchestrator/resource-panel.hbs +19 -1
  17. package/addon/components/orchestrator/resource-panel.js +4 -0
  18. package/addon/components/orchestrator-workbench.js +4 -1
  19. package/addon/components/order/details/purchase-rate.hbs +19 -22
  20. package/addon/components/order/details/route.hbs +26 -1
  21. package/addon/components/order/details/route.js +50 -0
  22. package/addon/components/order/details/tracking.hbs +171 -27
  23. package/addon/components/order/details/tracking.js +457 -0
  24. package/addon/components/order/form.hbs +2 -2
  25. package/addon/components/order/kanban-card.hbs +18 -3
  26. package/addon/components/order-list-overlay/order.hbs +18 -3
  27. package/addon/components/order-progress-card.hbs +21 -6
  28. package/addon/components/order-tracking-lookup.hbs +7 -7
  29. package/addon/components/order-tracking-lookup.js +1 -1
  30. package/addon/components/route-list.hbs +78 -8
  31. package/addon/components/route-list.js +48 -6
  32. package/addon/components/tracking-stop-progress.hbs +45 -0
  33. package/addon/components/tracking-stop-progress.js +79 -0
  34. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  35. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  36. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  37. package/addon/components/widget/fuel-efficiency.js +43 -0
  38. package/addon/components/widget/geofence-violations.hbs +44 -0
  39. package/addon/components/widget/geofence-violations.js +55 -0
  40. package/addon/components/widget/issues-insights.hbs +43 -0
  41. package/addon/components/widget/issues-insights.js +62 -0
  42. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  43. package/addon/components/widget/kpi-active-orders.js +5 -0
  44. package/addon/components/widget/kpi-aov.hbs +10 -0
  45. package/addon/components/widget/kpi-aov.js +5 -0
  46. package/addon/components/widget/kpi-distance.hbs +10 -0
  47. package/addon/components/widget/kpi-distance.js +5 -0
  48. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  49. package/addon/components/widget/kpi-drivers-online.js +5 -0
  50. package/addon/components/widget/kpi-earnings.hbs +10 -0
  51. package/addon/components/widget/kpi-earnings.js +5 -0
  52. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  53. package/addon/components/widget/kpi-open-issues.js +5 -0
  54. package/addon/components/widget/kpi-tile.hbs +59 -0
  55. package/addon/components/widget/kpi-tile.js +168 -0
  56. package/addon/components/widget/live-fleet.hbs +122 -0
  57. package/addon/components/widget/live-fleet.js +365 -0
  58. package/addon/components/widget/maintenance-overview.hbs +45 -0
  59. package/addon/components/widget/maintenance-overview.js +40 -0
  60. package/addon/components/widget/on-time-delivery.hbs +27 -0
  61. package/addon/components/widget/on-time-delivery.js +73 -0
  62. package/addon/components/widget/operations-pulse.hbs +51 -0
  63. package/addon/components/widget/operations-pulse.js +173 -0
  64. package/addon/components/widget/orders-by-status.hbs +26 -0
  65. package/addon/components/widget/orders-by-status.js +54 -0
  66. package/addon/components/widget/revenue-trend.hbs +34 -0
  67. package/addon/components/widget/revenue-trend.js +67 -0
  68. package/addon/components/widget/top-drivers.hbs +44 -0
  69. package/addon/components/widget/top-drivers.js +54 -0
  70. package/addon/controllers/settings/routing.js +124 -1
  71. package/addon/extension.js +172 -3
  72. package/addon/helpers/format-duration.js +38 -0
  73. package/addon/routes/application.js +6 -0
  74. package/addon/services/map-adapter/google.js +13 -2
  75. package/addon/services/order-list-overlay.js +2 -2
  76. package/addon/styles/fleetops-engine.css +806 -46
  77. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  78. package/addon/templates/maintenance/parts/index.hbs +1 -1
  79. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  80. package/addon/templates/management/vehicles/index.hbs +1 -1
  81. package/addon/templates/settings/avatars.hbs +1 -1
  82. package/addon/templates/settings/custom-fields.hbs +1 -1
  83. package/addon/templates/settings/map.hbs +1 -1
  84. package/addon/templates/settings/navigator-app.hbs +1 -1
  85. package/addon/templates/settings/notifications.hbs +1 -1
  86. package/addon/templates/settings/orchestrator.hbs +1 -1
  87. package/addon/templates/settings/payments/index.hbs +1 -1
  88. package/addon/templates/settings/payments/onboard.hbs +1 -1
  89. package/addon/templates/settings/routing.hbs +82 -4
  90. package/addon/templates/settings/scheduling.hbs +1 -1
  91. package/addon/utils/live-map-card-content.js +171 -0
  92. package/app/components/tracking-stop-progress.js +1 -0
  93. package/app/components/widget/fuel-efficiency.js +1 -0
  94. package/app/components/widget/geofence-violations.js +1 -0
  95. package/app/components/widget/issues-insights.js +1 -0
  96. package/app/components/widget/kpi-active-orders.js +1 -0
  97. package/app/components/widget/kpi-aov.js +1 -0
  98. package/app/components/widget/kpi-distance.js +1 -0
  99. package/app/components/widget/kpi-drivers-online.js +1 -0
  100. package/app/components/widget/kpi-earnings.js +1 -0
  101. package/app/components/widget/kpi-open-issues.js +1 -0
  102. package/app/components/widget/kpi-tile.js +1 -0
  103. package/app/components/widget/live-fleet.js +1 -0
  104. package/app/components/widget/maintenance-overview.js +1 -0
  105. package/app/components/widget/on-time-delivery.js +1 -0
  106. package/app/components/widget/operations-pulse.js +1 -0
  107. package/app/components/widget/orders-by-status.js +1 -0
  108. package/app/components/widget/revenue-trend.js +1 -0
  109. package/app/components/widget/top-drivers.js +1 -0
  110. package/app/helpers/format-duration.js +1 -0
  111. package/app/utils/live-map-card-content.js +1 -0
  112. package/composer.json +1 -1
  113. package/extension.json +1 -1
  114. package/package.json +3 -3
  115. package/pnpm-workspace.yaml +1 -0
  116. package/server/config/fleetops.php +32 -11
  117. package/server/config/telematics.php +111 -1
  118. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  119. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  120. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  121. package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
  122. package/server/seeders/Testing/FleetSeeder.php +341 -0
  123. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  124. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  125. package/server/seeders/Testing/OrdersSeeder.php +214 -0
  126. package/server/seeders/Testing/TestingSeeder.php +40 -0
  127. package/server/seeders/Testing/VroomOrchestrationSeeder.php +409 -0
  128. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  129. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  130. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  131. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  132. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  133. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  134. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  135. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  136. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  137. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  138. package/server/src/Http/Controllers/Internal/v1/OrderController.php +52 -21
  139. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  140. package/server/src/Http/Controllers/Internal/v1/SettingController.php +164 -12
  141. package/server/src/Http/Resources/v1/Driver.php +2 -0
  142. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  143. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  144. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  145. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  146. package/server/src/Models/Contact.php +122 -13
  147. package/server/src/Models/Driver.php +2 -0
  148. package/server/src/Models/Order.php +1 -1
  149. package/server/src/Models/OrderConfig.php +14 -11
  150. package/server/src/Models/Payload.php +8 -8
  151. package/server/src/Models/Place.php +0 -3
  152. package/server/src/Models/ServiceRate.php +1 -1
  153. package/server/src/Notifications/LateDeparture.php +48 -0
  154. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  155. package/server/src/Notifications/RouteDeviation.php +48 -0
  156. package/server/src/Observers/ContactObserver.php +8 -0
  157. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  158. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +357 -38
  159. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  160. package/server/src/Providers/FleetOpsServiceProvider.php +32 -0
  161. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  162. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  163. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  164. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  165. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  166. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  167. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  168. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  169. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  170. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  171. package/server/src/Support/Analytics/RevenueTrend.php +79 -0
  172. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  173. package/server/src/Support/LiveOrderQuery.php +75 -0
  174. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  175. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  176. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  177. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  178. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  179. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  180. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  181. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  182. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  183. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  184. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  185. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  186. package/server/src/Support/Metrics/Registry.php +46 -0
  187. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  188. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  189. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  190. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  191. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  192. package/server/src/Support/Metrics.php +46 -234
  193. package/server/src/Support/OSRM.php +11 -6
  194. package/server/src/Support/OrderTracker.php +7 -582
  195. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  196. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  197. package/server/src/Support/Utils.php +1 -1
  198. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  199. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  200. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  201. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  202. package/server/src/Tracking/README.md +25 -0
  203. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  204. package/server/src/Tracking/TrackingContext.php +63 -0
  205. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  206. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  207. package/server/src/Tracking/TrackingOptions.php +49 -0
  208. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  209. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  210. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  211. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  212. package/server/src/Tracking/TrackingStop.php +59 -0
  213. package/server/src/routes.php +27 -0
  214. package/server/tests/AnalyticsRoutesTest.php +41 -0
  215. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  216. package/server/tests/LiveOrderQueryTest.php +34 -0
  217. package/server/tests/MetricsRegistryTest.php +88 -0
  218. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  219. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  220. package/server/tests/PingDriverEndpointTest.php +19 -0
  221. package/server/tests/TrackingIntelligenceTest.php +176 -0
  222. package/server/tests/VroomOrchestrationEngineTest.php +250 -0
  223. package/translations/en-us.yaml +41 -0
@@ -0,0 +1,409 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Seeders\Testing;
4
+
5
+ use Fleetbase\FleetOps\Models\Entity;
6
+ use Fleetbase\FleetOps\Models\Order;
7
+ use Fleetbase\FleetOps\Models\Payload;
8
+ use Fleetbase\FleetOps\Models\Place;
9
+ use Fleetbase\FleetOps\Models\Vehicle;
10
+ use Fleetbase\FleetOps\Models\Waypoint;
11
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
12
+ use Fleetbase\Models\Company;
13
+ use Fleetbase\Seeders\Concerns\ResolvesSeedCompany;
14
+ use Illuminate\Database\Seeder;
15
+ use Illuminate\Support\Facades\DB;
16
+ use Illuminate\Support\Str;
17
+
18
+ /**
19
+ * Explicit test-data seeder. Kept below server/seeders/Testing so fleetbase:seed
20
+ * does not auto-discover it during deploys.
21
+ *
22
+ * Run with:
23
+ * php artisan db:seed --class="Fleetbase\\FleetOps\\Seeders\\Testing\\VroomOrchestrationSeeder"
24
+ */
25
+ class VroomOrchestrationSeeder extends Seeder
26
+ {
27
+ use ResolvesSeedCompany;
28
+
29
+ protected const SEED_NAME = 'vroom-orchestration';
30
+
31
+ public function run(): void
32
+ {
33
+ $company = $this->resolveCompany();
34
+ if (!$company) {
35
+ $this->command?->error('No company found. Create a Fleetbase company before running the VROOM orchestration seeder.');
36
+
37
+ return;
38
+ }
39
+
40
+ session(['company' => $company->uuid]);
41
+
42
+ DB::transaction(function () use ($company) {
43
+ $this->purgePreviousSeedData();
44
+
45
+ $places = $this->seedPlaces($company);
46
+ $vehicles = $this->seedVehicles($company);
47
+ $orders = $this->seedOrders($company, $places);
48
+
49
+ $this->command?->info('Seeded VROOM orchestration test data for company: ' . $company->public_id);
50
+ $this->command?->line('Order IDs: ' . implode(', ', array_map(fn (Order $order) => $order->public_id, $orders)));
51
+ $this->command?->line('Vehicle IDs: ' . implode(', ', array_map(fn (Vehicle $vehicle) => $vehicle->public_id, $vehicles)));
52
+ });
53
+ }
54
+
55
+ protected function resolveCompany(): ?Company
56
+ {
57
+ return $this->resolveSeedCompany(
58
+ 'FLEETOPS_VROOM_SEED_COMPANY_UUID',
59
+ 'FLEETOPS_VROOM_SEED_COMPANY_PUBLIC_ID'
60
+ );
61
+ }
62
+
63
+ protected function purgePreviousSeedData(): void
64
+ {
65
+ $payloadUuids = $this->seeded(Payload::query())->pluck('uuid');
66
+
67
+ $this->seeded(Order::query())->forceDelete();
68
+ Entity::whereIn('payload_uuid', $payloadUuids)->forceDelete();
69
+ Waypoint::whereIn('payload_uuid', $payloadUuids)->forceDelete();
70
+ $this->seeded(Payload::query())->forceDelete();
71
+ $this->seeded(Vehicle::query())->forceDelete();
72
+ $this->seeded(Place::query())->forceDelete();
73
+ }
74
+
75
+ protected function seeded($query)
76
+ {
77
+ return $query->where('meta->seed', static::SEED_NAME);
78
+ }
79
+
80
+ protected function meta(string $seedId, array $extra = []): array
81
+ {
82
+ return array_merge([
83
+ 'seed' => static::SEED_NAME,
84
+ 'seed_id' => $seedId,
85
+ ], $extra);
86
+ }
87
+
88
+ protected function seedPlaces(Company $company): array
89
+ {
90
+ $places = [
91
+ 'depot' => [
92
+ 'seed_id' => 'depot_tanjong_pagar',
93
+ 'name' => 'VROOM Test Depot - Tanjong Pagar',
94
+ 'street1' => '1 Raffles Quay',
95
+ 'city' => 'Singapore',
96
+ 'country' => 'SG',
97
+ 'lat' => 1.2816,
98
+ 'lng' => 103.8510,
99
+ ],
100
+ 'orchard' => [
101
+ 'seed_id' => 'dropoff_orchard',
102
+ 'name' => 'VROOM Test Dropoff - Orchard',
103
+ 'street1' => '2 Orchard Turn',
104
+ 'city' => 'Singapore',
105
+ 'country' => 'SG',
106
+ 'lat' => 1.3048,
107
+ 'lng' => 103.8318,
108
+ ],
109
+ 'changi' => [
110
+ 'seed_id' => 'dropoff_changi',
111
+ 'name' => 'VROOM Test Dropoff - Changi',
112
+ 'street1' => '80 Airport Boulevard',
113
+ 'city' => 'Singapore',
114
+ 'country' => 'SG',
115
+ 'lat' => 1.3644,
116
+ 'lng' => 103.9915,
117
+ ],
118
+ 'woodlands' => [
119
+ 'seed_id' => 'dropoff_woodlands',
120
+ 'name' => 'VROOM Test Dropoff - Woodlands',
121
+ 'street1' => '1 Woodlands Square',
122
+ 'city' => 'Singapore',
123
+ 'country' => 'SG',
124
+ 'lat' => 1.4360,
125
+ 'lng' => 103.7860,
126
+ ],
127
+ 'rochor' => [
128
+ 'seed_id' => 'waypoint_rochor',
129
+ 'name' => 'VROOM Test Waypoint - Rochor',
130
+ 'street1' => '1 Rochor Canal Road',
131
+ 'city' => 'Singapore',
132
+ 'country' => 'SG',
133
+ 'lat' => 1.3039,
134
+ 'lng' => 103.8520,
135
+ ],
136
+ 'novena' => [
137
+ 'seed_id' => 'waypoint_novena',
138
+ 'name' => 'VROOM Test Waypoint - Novena',
139
+ 'street1' => '10 Sinaran Drive',
140
+ 'city' => 'Singapore',
141
+ 'country' => 'SG',
142
+ 'lat' => 1.3206,
143
+ 'lng' => 103.8439,
144
+ ],
145
+ 'serangoon' => [
146
+ 'seed_id' => 'waypoint_serangoon',
147
+ 'name' => 'VROOM Test Waypoint - Serangoon',
148
+ 'street1' => '23 Serangoon Central',
149
+ 'city' => 'Singapore',
150
+ 'country' => 'SG',
151
+ 'lat' => 1.3508,
152
+ 'lng' => 103.8723,
153
+ ],
154
+ 'paya_lebar' => [
155
+ 'seed_id' => 'waypoint_paya_lebar',
156
+ 'name' => 'VROOM Test Waypoint - Paya Lebar',
157
+ 'street1' => '10 Paya Lebar Road',
158
+ 'city' => 'Singapore',
159
+ 'country' => 'SG',
160
+ 'lat' => 1.3174,
161
+ 'lng' => 103.8927,
162
+ ],
163
+ 'tampines' => [
164
+ 'seed_id' => 'waypoint_tampines',
165
+ 'name' => 'VROOM Test Waypoint - Tampines',
166
+ 'street1' => '4 Tampines Central 5',
167
+ 'city' => 'Singapore',
168
+ 'country' => 'SG',
169
+ 'lat' => 1.3525,
170
+ 'lng' => 103.9447,
171
+ ],
172
+ ];
173
+
174
+ return collect($places)->mapWithKeys(function (array $place, string $key) use ($company) {
175
+ $model = new Place();
176
+ $model->forceFill([
177
+ 'uuid' => (string) Str::uuid(),
178
+ 'company_uuid' => $company->uuid,
179
+ 'name' => $place['name'],
180
+ 'street1' => $place['street1'],
181
+ 'city' => $place['city'],
182
+ 'country' => $place['country'],
183
+ 'location' => new Point($place['lat'], $place['lng']),
184
+ 'meta' => $this->meta($place['seed_id']),
185
+ ])->save();
186
+
187
+ return [$key => $model];
188
+ })->all();
189
+ }
190
+
191
+ protected function seedVehicles(Company $company): array
192
+ {
193
+ $vehicles = [
194
+ [
195
+ 'seed_id' => 'central_1',
196
+ 'name' => 'VROOM Test Van - Central',
197
+ 'make' => 'Nissan',
198
+ 'model' => 'NV200',
199
+ 'lat' => 1.2798,
200
+ 'lng' => 103.8500,
201
+ 'capacity' => [120, 4.5, 1, 20],
202
+ ],
203
+ [
204
+ 'seed_id' => 'east_1',
205
+ 'name' => 'VROOM Test Van - East',
206
+ 'make' => 'Toyota',
207
+ 'model' => 'HiAce',
208
+ 'lat' => 1.3349,
209
+ 'lng' => 103.9617,
210
+ 'capacity' => [250, 8, 2, 40],
211
+ ],
212
+ [
213
+ 'seed_id' => 'north_1',
214
+ 'name' => 'VROOM Test Van - North',
215
+ 'make' => 'Mercedes-Benz',
216
+ 'model' => 'Sprinter',
217
+ 'lat' => 1.4326,
218
+ 'lng' => 103.7856,
219
+ 'capacity' => [500, 16, 4, 80],
220
+ ],
221
+ [
222
+ 'seed_id' => 'west_1',
223
+ 'name' => 'VROOM Test Van - West',
224
+ 'make' => 'Ford',
225
+ 'model' => 'Transit',
226
+ 'lat' => 1.3345,
227
+ 'lng' => 103.7420,
228
+ 'capacity' => [90, 3, 0, 12],
229
+ ],
230
+ [
231
+ 'seed_id' => 'capacity_only_heavy_1',
232
+ 'name' => 'VROOM Test Capacity Truck - No Position',
233
+ 'make' => 'Isuzu',
234
+ 'model' => 'NPR',
235
+ 'capacity' => [900, 24, 8, 160],
236
+ 'skills' => ['tail_lift'],
237
+ 'max_tasks' => 10,
238
+ ],
239
+ [
240
+ 'seed_id' => 'capacity_only_cold_1',
241
+ 'name' => 'VROOM Test Cold Chain Van - No Position',
242
+ 'make' => 'Hyundai',
243
+ 'model' => 'H350',
244
+ 'capacity' => [350, 10, 3, 60],
245
+ 'skills' => ['cold_chain'],
246
+ 'max_tasks' => 8,
247
+ ],
248
+ ];
249
+
250
+ return collect($vehicles)->mapWithKeys(function (array $vehicle) use ($company) {
251
+ $model = new Vehicle();
252
+ $model->forceFill([
253
+ 'uuid' => (string) Str::uuid(),
254
+ 'company_uuid' => $company->uuid,
255
+ 'name' => $vehicle['name'],
256
+ 'make' => $vehicle['make'],
257
+ 'model' => $vehicle['model'],
258
+ 'year' => '2024',
259
+ 'type' => 'van',
260
+ 'status' => 'active',
261
+ 'online' => true,
262
+ 'location' => isset($vehicle['lat'], $vehicle['lng']) ? new Point($vehicle['lat'], $vehicle['lng']) : null,
263
+ 'skills' => $vehicle['skills'] ?? [],
264
+ 'payload_capacity' => $vehicle['capacity'][0],
265
+ 'payload_capacity_volume' => $vehicle['capacity'][1],
266
+ 'payload_capacity_pallets' => $vehicle['capacity'][2],
267
+ 'payload_capacity_parcels' => $vehicle['capacity'][3],
268
+ 'max_tasks' => $vehicle['max_tasks'] ?? 6,
269
+ 'return_to_depot' => false,
270
+ 'meta' => $this->meta($vehicle['seed_id'], [
271
+ 'location_state' => isset($vehicle['lat'], $vehicle['lng']) ? 'set' : 'not_set',
272
+ 'capacity' => [
273
+ 'weight_kg' => $vehicle['capacity'][0],
274
+ 'volume_m3' => $vehicle['capacity'][1],
275
+ 'pallets' => $vehicle['capacity'][2],
276
+ 'parcels' => $vehicle['capacity'][3],
277
+ ],
278
+ ]),
279
+ ])->save();
280
+
281
+ return [$vehicle['seed_id'] => $model];
282
+ })->all();
283
+ }
284
+
285
+ protected function seedOrders(Company $company, array $places): array
286
+ {
287
+ $orders = [
288
+ 'endpoint_only' => [
289
+ 'payload_seed_id' => 'endpoint_only',
290
+ 'name' => 'VROOM Test Order - Pickup Dropoff',
291
+ 'pickup' => $places['depot'],
292
+ 'dropoff' => $places['orchard'],
293
+ 'waypoints' => [],
294
+ 'entities' => [
295
+ ['name' => 'Small carton', 'type' => 'parcel', 'weight' => 12, 'weight_unit' => 'kg', 'length' => 60, 'width' => 40, 'height' => 35, 'dimensions_unit' => 'cm'],
296
+ ['name' => 'Fragile tote', 'type' => 'parcel', 'weight' => 8, 'weight_unit' => 'kg', 'length' => 50, 'width' => 35, 'height' => 30, 'dimensions_unit' => 'cm'],
297
+ ],
298
+ ],
299
+ 'waypoint_only' => [
300
+ 'payload_seed_id' => 'waypoint_only',
301
+ 'name' => 'VROOM Test Order - Waypoints Only',
302
+ 'pickup' => null,
303
+ 'dropoff' => null,
304
+ 'waypoints' => [$places['rochor'], $places['novena'], $places['serangoon']],
305
+ 'entities' => [
306
+ ['name' => 'Multi-stop parcel 1', 'type' => 'parcel', 'weight' => 25, 'weight_unit' => 'kg', 'length' => 80, 'width' => 50, 'height' => 40, 'dimensions_unit' => 'cm'],
307
+ ['name' => 'Multi-stop parcel 2', 'type' => 'parcel', 'weight' => 18, 'weight_unit' => 'kg', 'length' => 70, 'width' => 45, 'height' => 35, 'dimensions_unit' => 'cm'],
308
+ ['name' => 'Multi-stop parcel 3', 'type' => 'parcel', 'weight' => 15, 'weight_unit' => 'kg', 'length' => 65, 'width' => 40, 'height' => 35, 'dimensions_unit' => 'cm'],
309
+ ],
310
+ ],
311
+ 'mixed' => [
312
+ 'payload_seed_id' => 'mixed',
313
+ 'name' => 'VROOM Test Order - Mixed Stops',
314
+ 'pickup' => $places['depot'],
315
+ 'dropoff' => $places['changi'],
316
+ 'waypoints' => [$places['paya_lebar'], $places['tampines']],
317
+ 'entities' => [
318
+ ['name' => 'Heavy equipment crate', 'type' => 'freight', 'weight' => 180, 'weight_unit' => 'kg', 'length' => 120, 'width' => 80, 'height' => 90, 'dimensions_unit' => 'cm'],
319
+ ['name' => 'Accessory box', 'type' => 'parcel', 'weight' => 22, 'weight_unit' => 'kg', 'length' => 75, 'width' => 55, 'height' => 45, 'dimensions_unit' => 'cm'],
320
+ ],
321
+ ],
322
+ 'single_waypoint' => [
323
+ 'payload_seed_id' => 'single_waypoint',
324
+ 'name' => 'VROOM Test Order - Single Waypoint',
325
+ 'pickup' => null,
326
+ 'dropoff' => null,
327
+ 'waypoints' => [$places['woodlands']],
328
+ 'entities' => [
329
+ ['name' => 'Oversized sample case', 'type' => 'parcel', 'weight' => 60, 'weight_unit' => 'kg', 'length' => 100, 'width' => 60, 'height' => 60, 'dimensions_unit' => 'cm'],
330
+ ],
331
+ ],
332
+ ];
333
+
334
+ return collect($orders)->mapWithKeys(function (array $definition, string $seedId) use ($company) {
335
+ $payload = $this->createPayload($company, $definition);
336
+ $order = new Order();
337
+ $order->forceFill([
338
+ 'uuid' => (string) Str::uuid(),
339
+ 'internal_id' => 'VROOM-SEED-' . Str::of($seedId)->upper(),
340
+ 'company_uuid' => $company->uuid,
341
+ 'payload_uuid' => $payload->uuid,
342
+ 'status' => 'created',
343
+ 'type' => 'transport',
344
+ 'notes' => $definition['name'],
345
+ 'orchestrator_priority' => 50,
346
+ 'required_skills' => [],
347
+ 'meta' => $this->meta($seedId),
348
+ ])->save();
349
+
350
+ $this->seedEntities($company, $payload, $definition['entities'] ?? []);
351
+
352
+ return [$seedId => $order];
353
+ })->all();
354
+ }
355
+
356
+ protected function createPayload(Company $company, array $definition): Payload
357
+ {
358
+ $payload = new Payload();
359
+ $payload->forceFill([
360
+ 'uuid' => (string) Str::uuid(),
361
+ 'company_uuid' => $company->uuid,
362
+ 'pickup_uuid' => $definition['pickup']?->uuid,
363
+ 'dropoff_uuid' => $definition['dropoff']?->uuid,
364
+ 'type' => 'transport',
365
+ 'meta' => $this->meta($definition['payload_seed_id']),
366
+ ])->save();
367
+
368
+ foreach ($definition['waypoints'] as $index => $place) {
369
+ $waypoint = new Waypoint();
370
+ $waypoint->forceFill([
371
+ 'uuid' => (string) Str::uuid(),
372
+ '_import_id' => static::SEED_NAME . ':waypoint:' . $definition['payload_seed_id'] . ':' . ($index + 1),
373
+ 'company_uuid' => $company->uuid,
374
+ 'payload_uuid' => $payload->uuid,
375
+ 'place_uuid' => $place->uuid,
376
+ 'type' => 'dropoff',
377
+ 'order' => $index + 1,
378
+ 'service_time' => 300,
379
+ ]);
380
+
381
+ Waypoint::withoutEvents(fn () => $waypoint->save());
382
+ }
383
+
384
+ return $payload;
385
+ }
386
+
387
+ protected function seedEntities(Company $company, Payload $payload, array $entities): void
388
+ {
389
+ foreach ($entities as $index => $definition) {
390
+ $entity = new Entity();
391
+ $entity->forceFill([
392
+ 'uuid' => (string) Str::uuid(),
393
+ 'company_uuid' => $company->uuid,
394
+ 'payload_uuid' => $payload->uuid,
395
+ 'name' => $definition['name'],
396
+ 'type' => $definition['type'],
397
+ 'weight' => $definition['weight'],
398
+ 'weight_unit' => $definition['weight_unit'],
399
+ 'length' => $definition['length'],
400
+ 'width' => $definition['width'],
401
+ 'height' => $definition['height'],
402
+ 'dimensions_unit' => $definition['dimensions_unit'],
403
+ 'meta' => $this->meta($payload->getMeta('seed_id') . '_' . ($index + 1)),
404
+ ]);
405
+
406
+ Entity::withoutEvents(fn () => $entity->save());
407
+ }
408
+ }
409
+ }
@@ -0,0 +1,279 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Console\Commands;
4
+
5
+ use Fleetbase\FleetOps\Models\Order;
6
+ use Fleetbase\FleetOps\Models\Position;
7
+ use Fleetbase\FleetOps\Notifications\LateDeparture;
8
+ use Fleetbase\FleetOps\Notifications\ProlongedStoppage;
9
+ use Fleetbase\FleetOps\Notifications\RouteDeviation;
10
+ use Fleetbase\FleetOps\Support\Utils;
11
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
12
+ use Fleetbase\Models\Setting;
13
+ use Fleetbase\Support\NotificationRegistry;
14
+ use Illuminate\Console\Command;
15
+ use Illuminate\Support\Carbon;
16
+ use Illuminate\Support\Facades\Cache;
17
+
18
+ class ProcessOperationalAlerts extends Command
19
+ {
20
+ protected $signature = 'fleetops:process-operational-alerts
21
+ {--days=2 : Only include orders created in the last N days}
22
+ {--chunk=250 : How many orders to process per chunk}
23
+ {--no-lock : Skip process locking}
24
+ {--dry : Dry run without updating notification markers}';
25
+
26
+ protected $description = 'Evaluate FleetOps operational alerts for late departures, route deviations, and prolonged stoppages.';
27
+
28
+ public function handle(): int
29
+ {
30
+ $days = max(1, (int) $this->option('days'));
31
+ $perChunk = max(50, (int) $this->option('chunk'));
32
+ $dryRun = (bool) $this->option('dry');
33
+ $useLock = !$this->option('no-lock');
34
+ $lock = null;
35
+
36
+ if ($useLock) {
37
+ $lock = Cache::lock('fleetops:process-operational-alerts', 600);
38
+ if (!$lock->get()) {
39
+ $this->warn('Another operational alert run appears to be in progress.');
40
+
41
+ return self::SUCCESS;
42
+ }
43
+ }
44
+
45
+ try {
46
+ $cutoff = Carbon::now()->subDays($days);
47
+ $query = $this->ordersQuery($cutoff);
48
+ $total = (clone $query)->count('id');
49
+
50
+ if ($total === 0) {
51
+ $this->info('No qualifying orders found.');
52
+
53
+ return self::SUCCESS;
54
+ }
55
+
56
+ $triggered = 0;
57
+
58
+ $query->orderBy('id')->chunkById($perChunk, function ($orders) use ($dryRun, &$triggered) {
59
+ $orders->loadMissing(['driverAssigned', 'vehicleAssigned', 'route']);
60
+
61
+ foreach ($orders as $order) {
62
+ session(['company' => $order->company_uuid]);
63
+ $settings = $this->alertSettings();
64
+
65
+ if ($this->processLateDeparture($order, $settings, $dryRun)) {
66
+ $triggered++;
67
+ }
68
+
69
+ if ($this->processRouteDeviation($order, $settings, $dryRun)) {
70
+ $triggered++;
71
+ }
72
+
73
+ if ($this->processProlongedStoppage($order, $settings, $dryRun)) {
74
+ $triggered++;
75
+ }
76
+ }
77
+ });
78
+
79
+ $this->info(($dryRun ? '[Dry Run] ' : '') . "Operational alerts triggered: {$triggered}");
80
+
81
+ return self::SUCCESS;
82
+ } finally {
83
+ if ($lock) {
84
+ $lock->release();
85
+ }
86
+ }
87
+ }
88
+
89
+ protected function ordersQuery(Carbon $cutoff)
90
+ {
91
+ return Order::query()
92
+ ->withoutGlobalScopes()
93
+ ->whereNotIn('status', ['completed', 'canceled'])
94
+ ->whereNull('deleted_at')
95
+ ->whereNotNull('company_uuid')
96
+ ->where('created_at', '>=', $cutoff);
97
+ }
98
+
99
+ protected function processLateDeparture(Order $order, array $settings, bool $dryRun): bool
100
+ {
101
+ if (!data_get($settings, 'late_departures.enabled')) {
102
+ return false;
103
+ }
104
+
105
+ if (!$order->scheduled_at || $order->started_at || $order->started) {
106
+ return false;
107
+ }
108
+
109
+ $graceMinutes = (int) data_get($settings, 'late_departures.grace_period_minutes', 15);
110
+ if (Carbon::parse($order->scheduled_at)->addMinutes($graceMinutes)->isFuture()) {
111
+ return false;
112
+ }
113
+
114
+ return $this->notifyOnce($order, 'late_departure', LateDeparture::class, [
115
+ 'scheduled_at' => $order->scheduled_at,
116
+ 'grace_period_minutes' => $graceMinutes,
117
+ ], $dryRun);
118
+ }
119
+
120
+ protected function processRouteDeviation(Order $order, array $settings, bool $dryRun): bool
121
+ {
122
+ if (!data_get($settings, 'route_deviations.enabled')) {
123
+ return false;
124
+ }
125
+
126
+ $latestPosition = $this->latestPositionForOrder($order);
127
+ if (!$latestPosition?->coordinates) {
128
+ return false;
129
+ }
130
+
131
+ $routePoints = $this->routePoints($order);
132
+ if (count($routePoints) < 2) {
133
+ return false;
134
+ }
135
+
136
+ $thresholdMeters = (int) data_get($settings, 'route_deviations.distance_threshold_meters', 500);
137
+ $distanceMeters = $this->minimumDistanceToRoute($latestPosition->coordinates, $routePoints);
138
+
139
+ if ($distanceMeters <= $thresholdMeters) {
140
+ return false;
141
+ }
142
+
143
+ return $this->notifyOnce($order, 'route_deviation', RouteDeviation::class, [
144
+ 'distance_meters' => round($distanceMeters, 2),
145
+ 'distance_threshold_meters' => $thresholdMeters,
146
+ 'position_id' => $latestPosition->uuid,
147
+ ], $dryRun);
148
+ }
149
+
150
+ protected function processProlongedStoppage(Order $order, array $settings, bool $dryRun): bool
151
+ {
152
+ if (!data_get($settings, 'prolonged_stoppages.enabled')) {
153
+ return false;
154
+ }
155
+
156
+ if (!$order->started_at && !$order->started) {
157
+ return false;
158
+ }
159
+
160
+ $latestPosition = $this->latestPositionForOrder($order);
161
+ if (!$latestPosition || ((float) $latestPosition->speed) > 1) {
162
+ return false;
163
+ }
164
+
165
+ $thresholdMinutes = (int) data_get($settings, 'prolonged_stoppages.duration_threshold_minutes', 30);
166
+ if (Carbon::parse($latestPosition->created_at)->addMinutes($thresholdMinutes)->isFuture()) {
167
+ return false;
168
+ }
169
+
170
+ return $this->notifyOnce($order, 'prolonged_stoppage', ProlongedStoppage::class, [
171
+ 'duration_threshold_minutes' => $thresholdMinutes,
172
+ 'position_id' => $latestPosition->uuid,
173
+ 'stopped_since' => $latestPosition->created_at,
174
+ ], $dryRun);
175
+ }
176
+
177
+ protected function notifyOnce(Order $order, string $key, string $notificationClass, array $context, bool $dryRun): bool
178
+ {
179
+ $metaKey = "operational_alerts.{$key}.notified_at";
180
+ if ($order->getMeta($metaKey)) {
181
+ return false;
182
+ }
183
+
184
+ NotificationRegistry::notify($notificationClass, $order, $context);
185
+
186
+ if (!$dryRun) {
187
+ $order->setMeta("operational_alerts.{$key}", [
188
+ 'notified_at' => Carbon::now()->toDateTimeString(),
189
+ 'context' => $context,
190
+ ]);
191
+ $order->saveQuietly();
192
+ }
193
+
194
+ return true;
195
+ }
196
+
197
+ protected function latestPositionForOrder(Order $order): ?Position
198
+ {
199
+ return Position::where('company_uuid', $order->company_uuid)
200
+ ->where('order_uuid', $order->uuid)
201
+ ->latest()
202
+ ->first();
203
+ }
204
+
205
+ protected function routePoints(Order $order): array
206
+ {
207
+ $details = data_get($order, 'route.details', []);
208
+
209
+ return $this->collectPoints($details);
210
+ }
211
+
212
+ protected function collectPoints(mixed $value): array
213
+ {
214
+ if (!is_array($value)) {
215
+ return [];
216
+ }
217
+
218
+ if ($point = $this->pointFromPair($value)) {
219
+ return [$point];
220
+ }
221
+
222
+ $points = [];
223
+ foreach ($value as $item) {
224
+ foreach ($this->collectPoints($item) as $point) {
225
+ $points[] = $point;
226
+ }
227
+ }
228
+
229
+ return $points;
230
+ }
231
+
232
+ protected function pointFromPair(array $value): ?Point
233
+ {
234
+ if (count($value) !== 2 || !is_numeric($value[0]) || !is_numeric($value[1])) {
235
+ return null;
236
+ }
237
+
238
+ $first = (float) $value[0];
239
+ $second = (float) $value[1];
240
+
241
+ if (abs($first) <= 90 && abs($second) <= 180) {
242
+ return new Point($first, $second);
243
+ }
244
+
245
+ if (abs($first) <= 180 && abs($second) <= 90) {
246
+ return new Point($second, $first);
247
+ }
248
+
249
+ return null;
250
+ }
251
+
252
+ protected function minimumDistanceToRoute(Point $position, array $routePoints): float
253
+ {
254
+ return collect($routePoints)
255
+ ->map(fn (Point $point) => Utils::vincentyGreatCircleDistance($position, $point))
256
+ ->min() ?? 0;
257
+ }
258
+
259
+ protected function alertSettings(): array
260
+ {
261
+ $settings = Setting::lookupCompany('tracking', []);
262
+ $alerts = data_get($settings, 'alerts', []);
263
+
264
+ return [
265
+ 'late_departures' => [
266
+ 'enabled' => (bool) data_get($alerts, 'late_departures.enabled', true),
267
+ 'grace_period_minutes' => (int) data_get($alerts, 'late_departures.grace_period_minutes', 15),
268
+ ],
269
+ 'route_deviations' => [
270
+ 'enabled' => (bool) data_get($alerts, 'route_deviations.enabled', true),
271
+ 'distance_threshold_meters' => (int) data_get($alerts, 'route_deviations.distance_threshold_meters', 500),
272
+ ],
273
+ 'prolonged_stoppages' => [
274
+ 'enabled' => (bool) data_get($alerts, 'prolonged_stoppages.enabled', true),
275
+ 'duration_threshold_minutes' => (int) data_get($alerts, 'prolonged_stoppages.duration_threshold_minutes', 30),
276
+ ],
277
+ ];
278
+ }
279
+ }