@fleetbase/fleetops-engine 0.6.48 → 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 (151) hide show
  1. package/addon/components/driver/details.hbs +5 -1
  2. package/addon/components/driver/form.hbs +11 -1
  3. package/addon/components/fleet-listing-panel.hbs +2 -2
  4. package/addon/components/fleet-listing-panel.js +10 -0
  5. package/addon/components/map/google-live-map.js +12 -73
  6. package/addon/components/map/leaflet-live-map.hbs +149 -33
  7. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  8. package/addon/components/orchestrator/phase-builder.js +13 -0
  9. package/addon/components/orchestrator-workbench.js +1 -0
  10. package/addon/components/order/form.hbs +2 -2
  11. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  12. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  13. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  14. package/addon/components/widget/fuel-efficiency.js +43 -0
  15. package/addon/components/widget/geofence-violations.hbs +44 -0
  16. package/addon/components/widget/geofence-violations.js +55 -0
  17. package/addon/components/widget/issues-insights.hbs +43 -0
  18. package/addon/components/widget/issues-insights.js +62 -0
  19. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  20. package/addon/components/widget/kpi-active-orders.js +5 -0
  21. package/addon/components/widget/kpi-aov.hbs +10 -0
  22. package/addon/components/widget/kpi-aov.js +5 -0
  23. package/addon/components/widget/kpi-distance.hbs +10 -0
  24. package/addon/components/widget/kpi-distance.js +5 -0
  25. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  26. package/addon/components/widget/kpi-drivers-online.js +5 -0
  27. package/addon/components/widget/kpi-earnings.hbs +10 -0
  28. package/addon/components/widget/kpi-earnings.js +5 -0
  29. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  30. package/addon/components/widget/kpi-open-issues.js +5 -0
  31. package/addon/components/widget/kpi-tile.hbs +59 -0
  32. package/addon/components/widget/kpi-tile.js +168 -0
  33. package/addon/components/widget/live-fleet.hbs +122 -0
  34. package/addon/components/widget/live-fleet.js +365 -0
  35. package/addon/components/widget/maintenance-overview.hbs +45 -0
  36. package/addon/components/widget/maintenance-overview.js +40 -0
  37. package/addon/components/widget/on-time-delivery.hbs +27 -0
  38. package/addon/components/widget/on-time-delivery.js +73 -0
  39. package/addon/components/widget/operations-pulse.hbs +51 -0
  40. package/addon/components/widget/operations-pulse.js +173 -0
  41. package/addon/components/widget/orders-by-status.hbs +26 -0
  42. package/addon/components/widget/orders-by-status.js +54 -0
  43. package/addon/components/widget/revenue-trend.hbs +34 -0
  44. package/addon/components/widget/revenue-trend.js +67 -0
  45. package/addon/components/widget/top-drivers.hbs +44 -0
  46. package/addon/components/widget/top-drivers.js +54 -0
  47. package/addon/controllers/settings/routing.js +26 -0
  48. package/addon/extension.js +172 -3
  49. package/addon/services/map-adapter/google.js +13 -2
  50. package/addon/styles/fleetops-engine.css +114 -87
  51. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  52. package/addon/templates/maintenance/parts/index.hbs +1 -1
  53. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  54. package/addon/templates/management/vehicles/index.hbs +1 -1
  55. package/addon/templates/settings/avatars.hbs +1 -1
  56. package/addon/templates/settings/custom-fields.hbs +1 -1
  57. package/addon/templates/settings/map.hbs +1 -1
  58. package/addon/templates/settings/navigator-app.hbs +1 -1
  59. package/addon/templates/settings/notifications.hbs +1 -1
  60. package/addon/templates/settings/orchestrator.hbs +1 -1
  61. package/addon/templates/settings/payments/index.hbs +1 -1
  62. package/addon/templates/settings/payments/onboard.hbs +1 -1
  63. package/addon/templates/settings/routing.hbs +22 -2
  64. package/addon/templates/settings/scheduling.hbs +1 -1
  65. package/addon/utils/live-map-card-content.js +171 -0
  66. package/app/components/widget/fuel-efficiency.js +1 -0
  67. package/app/components/widget/geofence-violations.js +1 -0
  68. package/app/components/widget/issues-insights.js +1 -0
  69. package/app/components/widget/kpi-active-orders.js +1 -0
  70. package/app/components/widget/kpi-aov.js +1 -0
  71. package/app/components/widget/kpi-distance.js +1 -0
  72. package/app/components/widget/kpi-drivers-online.js +1 -0
  73. package/app/components/widget/kpi-earnings.js +1 -0
  74. package/app/components/widget/kpi-open-issues.js +1 -0
  75. package/app/components/widget/kpi-tile.js +1 -0
  76. package/app/components/widget/live-fleet.js +1 -0
  77. package/app/components/widget/maintenance-overview.js +1 -0
  78. package/app/components/widget/on-time-delivery.js +1 -0
  79. package/app/components/widget/operations-pulse.js +1 -0
  80. package/app/components/widget/orders-by-status.js +1 -0
  81. package/app/components/widget/revenue-trend.js +1 -0
  82. package/app/components/widget/top-drivers.js +1 -0
  83. package/app/utils/live-map-card-content.js +1 -0
  84. package/composer.json +1 -1
  85. package/extension.json +1 -1
  86. package/package.json +3 -3
  87. package/pnpm-workspace.yaml +1 -0
  88. package/server/config/telematics.php +111 -1
  89. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  90. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  91. package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
  92. package/server/seeders/Testing/FleetSeeder.php +341 -0
  93. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  94. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  95. package/server/seeders/Testing/OrdersSeeder.php +214 -0
  96. package/server/seeders/Testing/TestingSeeder.php +40 -0
  97. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +8 -1
  98. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  99. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  100. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  101. package/server/src/Http/Controllers/Internal/v1/OrderController.php +12 -6
  102. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  103. package/server/src/Http/Resources/v1/Driver.php +2 -0
  104. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  105. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  106. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  107. package/server/src/Models/Driver.php +2 -0
  108. package/server/src/Models/OrderConfig.php +14 -11
  109. package/server/src/Notifications/LateDeparture.php +48 -0
  110. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  111. package/server/src/Notifications/RouteDeviation.php +48 -0
  112. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +28 -1
  113. package/server/src/Providers/FleetOpsServiceProvider.php +5 -0
  114. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  115. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  116. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  117. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  118. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  119. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  120. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  121. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  122. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  123. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  124. package/server/src/Support/Analytics/RevenueTrend.php +79 -0
  125. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  126. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  127. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  128. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  129. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  130. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  131. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  132. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  133. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  134. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  135. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  136. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  137. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  138. package/server/src/Support/Metrics/Registry.php +46 -0
  139. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  140. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  141. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  142. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  143. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  144. package/server/src/Support/Metrics.php +46 -249
  145. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  146. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  147. package/server/src/routes.php +16 -0
  148. package/server/tests/AnalyticsRoutesTest.php +41 -0
  149. package/server/tests/MetricsRegistryTest.php +88 -0
  150. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  151. package/translations/en-us.yaml +37 -0
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
+
5
+ use Fleetbase\FleetOps\Support\Analytics\AbstractAnalytics;
6
+ use Fleetbase\FleetOps\Support\Analytics\FuelEfficiency;
7
+ use Fleetbase\FleetOps\Support\Analytics\GeofenceViolations;
8
+ use Fleetbase\FleetOps\Support\Analytics\IssuesInsights;
9
+ use Fleetbase\FleetOps\Support\Analytics\LiveFleet;
10
+ use Fleetbase\FleetOps\Support\Analytics\MaintenanceOverview;
11
+ use Fleetbase\FleetOps\Support\Analytics\OnTimeDelivery;
12
+ use Fleetbase\FleetOps\Support\Analytics\OperationsPulse;
13
+ use Fleetbase\FleetOps\Support\Analytics\OrdersByStatus;
14
+ use Fleetbase\FleetOps\Support\Analytics\RevenueTrend;
15
+ use Fleetbase\FleetOps\Support\Analytics\TopDrivers;
16
+ use Fleetbase\Http\Controllers\Controller;
17
+ use Illuminate\Http\Request;
18
+
19
+ /**
20
+ * One public method per dashboard widget. Each method:
21
+ * - Resolves a period from ?period=7d|30d|... or explicit start/end
22
+ * - Constructs the corresponding Support\Analytics\* helper
23
+ * - Returns the helper's JSON payload (Chart.js datasets, ranked rows,
24
+ * scalar tiles, etc. — shape is widget-specific)
25
+ *
26
+ * Cache headers are set per-widget by the route middleware in routes.php.
27
+ */
28
+ class AnalyticsController extends Controller
29
+ {
30
+ public function operationsPulse(Request $request)
31
+ {
32
+ return $this->run($request, OperationsPulse::class);
33
+ }
34
+
35
+ public function revenueTrend(Request $request)
36
+ {
37
+ return $this->run($request, RevenueTrend::class, function (RevenueTrend $a) use ($request) {
38
+ $a->groupBy($request->string('group_by', 'day')->toString());
39
+ });
40
+ }
41
+
42
+ public function ordersByStatus(Request $request)
43
+ {
44
+ return $this->run($request, OrdersByStatus::class, defaultDays: 14);
45
+ }
46
+
47
+ public function onTimeDelivery(Request $request)
48
+ {
49
+ return $this->run($request, OnTimeDelivery::class, function (OnTimeDelivery $a) use ($request) {
50
+ $a->slaMinutes((int) $request->input('sla_minutes', 30));
51
+ });
52
+ }
53
+
54
+ public function topDrivers(Request $request)
55
+ {
56
+ return $this->run($request, TopDrivers::class, function (TopDrivers $a) use ($request) {
57
+ $a->limit((int) $request->input('limit', 10));
58
+ $a->sortBy($request->string('sort_by', 'orders_completed')->toString());
59
+ });
60
+ }
61
+
62
+ public function fuelEfficiency(Request $request)
63
+ {
64
+ return $this->run($request, FuelEfficiency::class, defaultDays: 90);
65
+ }
66
+
67
+ public function issuesInsights(Request $request)
68
+ {
69
+ return $this->run($request, IssuesInsights::class);
70
+ }
71
+
72
+ public function maintenanceOverview(Request $request)
73
+ {
74
+ return $this->run($request, MaintenanceOverview::class);
75
+ }
76
+
77
+ public function geofenceViolations(Request $request)
78
+ {
79
+ return $this->run($request, GeofenceViolations::class, defaultDays: 7);
80
+ }
81
+
82
+ public function liveFleet(Request $request)
83
+ {
84
+ return $this->run($request, LiveFleet::class);
85
+ }
86
+
87
+ /**
88
+ * Shared boilerplate: period parsing + helper construction + JSON response.
89
+ *
90
+ * @param class-string<AbstractAnalytics> $class
91
+ */
92
+ private function run(Request $request, string $class, ?callable $configure = null, int $defaultDays = 30)
93
+ {
94
+ [$start, $end] = AbstractAnalytics::resolvePeriod(
95
+ $request->string('period')->toString() ?: null,
96
+ $request->date('start'),
97
+ $request->date('end'),
98
+ $defaultDays,
99
+ );
100
+
101
+ try {
102
+ /** @var AbstractAnalytics $analytics */
103
+ $analytics = $class::forCompany($request->user()->company)->between($start, $end);
104
+
105
+ if ($configure !== null) {
106
+ $configure($analytics);
107
+ }
108
+
109
+ return response()->json($analytics->get());
110
+ } catch (\Throwable $e) {
111
+ report($e);
112
+
113
+ return response()->json([
114
+ 'error' => $e->getMessage(),
115
+ 'widget' => $class,
116
+ ], 500);
117
+ }
118
+ }
119
+ }
@@ -3,15 +3,17 @@
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Support\Metrics;
6
+ use Fleetbase\FleetOps\Support\Metrics\Registry;
6
7
  use Fleetbase\Http\Controllers\Controller;
7
8
  use Illuminate\Http\Request;
9
+ use Illuminate\Support\Carbon;
8
10
 
9
11
  class MetricsController extends Controller
10
12
  {
11
13
  /**
12
- * Gets all key metrics for the current session.
13
- *
14
- * @return \Illuminate\Http\Response
14
+ * Legacy bulk endpoint. Returns a flat map of slug → scalar value for the
15
+ * requested period. Preserved for backward compat; the dashboard widgets
16
+ * now prefer per-slug GET /metrics/{slug}.
15
17
  */
16
18
  public function all(Request $request)
17
19
  {
@@ -27,4 +29,81 @@ class MetricsController extends Controller
27
29
 
28
30
  return response()->json($data);
29
31
  }
32
+
33
+ /**
34
+ * Per-metric endpoint backing <Widget::KpiTile>.
35
+ *
36
+ * Accepts:
37
+ * ?period=7d|30d|90d OR ?start=ISO&end=ISO
38
+ * ?sparkline=true to include a per-day mini-chart series
39
+ * ?compare=true to include period-over-period delta (default: true)
40
+ *
41
+ * Returns: { slug, value, format, currency?, delta_pct?, sparkline? }
42
+ */
43
+ public function show(Request $request, string $slug)
44
+ {
45
+ $class = Registry::resolve($slug);
46
+ if ($class === null) {
47
+ return response()->json(['error' => "Unknown metric: {$slug}"], 404);
48
+ }
49
+
50
+ [$start, $end] = $this->resolvePeriod($request);
51
+
52
+ $metric = $class::forCompany($request->user()->company)->between($start, $end);
53
+
54
+ if ($request->boolean('compare', true)) {
55
+ $duration = $end->getTimestamp() - $start->getTimestamp();
56
+ $compareEnd = (clone $start);
57
+ $compareStart = (clone $start)->modify("-{$duration} seconds");
58
+ $metric->compareTo($compareStart, $compareEnd);
59
+ }
60
+
61
+ if ($request->boolean('sparkline')) {
62
+ $buckets = (int) $request->input('sparkline_buckets', 14);
63
+ $metric->withSparkline($buckets, 'day');
64
+ }
65
+
66
+ return response()->json($metric->get());
67
+ }
68
+
69
+ /**
70
+ * Parse ?period=7d/30d/90d into a [start, end] pair, falling back to
71
+ * explicit ?start=&end= ISO dates, then to a 30-day default.
72
+ */
73
+ private function resolvePeriod(Request $request): array
74
+ {
75
+ $period = $request->string('period')->toString();
76
+
77
+ if ($period !== '') {
78
+ $days = match ($period) {
79
+ '7d' => 7,
80
+ '14d' => 14,
81
+ '30d' => 30,
82
+ '90d' => 90,
83
+ '180d' => 180,
84
+ '365d' => 365,
85
+ default => null,
86
+ };
87
+
88
+ if ($days !== null) {
89
+ $end = Carbon::now()->toDateTime();
90
+ $start = Carbon::now()->subDays($days)->toDateTime();
91
+
92
+ return [$start, $end];
93
+ }
94
+ }
95
+
96
+ $start = $request->date('start') ?? Carbon::now()->subDays(30)->toDateTime();
97
+ $end = $request->date('end') ?? Carbon::now()->toDateTime();
98
+
99
+ if (!$start instanceof \DateTime) {
100
+ $start = Carbon::parse($start)->toDateTime();
101
+ }
102
+
103
+ if (!$end instanceof \DateTime) {
104
+ $end = Carbon::parse($end)->toDateTime();
105
+ }
106
+
107
+ return [$start, $end];
108
+ }
30
109
  }
@@ -112,13 +112,19 @@ class OrderController extends FleetOpsController
112
112
  $input['integrated_vendor_order'] = $integratedVendorOrder;
113
113
  }
114
114
 
115
- // Normalize order config + type onto the company transport
116
- // config when an explicit config was not provided.
117
- $resolvedOrderConfig = null;
118
- if (!empty($input['order_config_uuid']) || !empty($input['type'])) {
119
- $resolvedOrderConfig = OrderConfig::resolveFromIdentifier([$input['order_config_uuid'] ?? null, $input['type'] ?? null]);
115
+ // Normalize order config + type. Invalid explicit configs must
116
+ // not silently fall back to the default transport config.
117
+ $hasExplicitOrderConfig = !empty($input['order_config_uuid']);
118
+ $hasExplicitOrderType = !empty($input['type']);
119
+ $resolvedOrderConfig = OrderConfig::resolveFromIdentifier([$input['order_config_uuid'] ?? null, $input['type'] ?? null]);
120
+
121
+ if (!$resolvedOrderConfig && $hasExplicitOrderConfig) {
122
+ throw new FleetbaseRequestValidationException(['order_config_uuid' => 'The selected order config is invalid.']);
123
+ }
124
+
125
+ if (!$resolvedOrderConfig && !$hasExplicitOrderType) {
126
+ $resolvedOrderConfig = OrderConfig::defaultOrCreate();
120
127
  }
121
- $resolvedOrderConfig ??= OrderConfig::defaultOrCreate();
122
128
 
123
129
  if ($resolvedOrderConfig) {
124
130
  $input['order_config_uuid'] = $resolvedOrderConfig->uuid;
@@ -251,6 +251,7 @@ class SettingController extends Controller
251
251
  'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
252
252
  'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
253
253
  'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
254
+ 'alerts' => $this->normalizeTrackingAlertSettings((array) $request->input('alerts', data_get($config, 'alerts', []))),
254
255
  ]);
255
256
 
256
257
  return response()->json([
@@ -275,7 +276,9 @@ class SettingController extends Controller
275
276
  'route_cache_ttl_seconds' => data_get($config, 'route_cache_ttl_seconds', 600),
276
277
  'stale_location_threshold_seconds' => data_get($config, 'stale_location_threshold_seconds', 300),
277
278
  'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35),
279
+ 'alerts' => data_get($config, 'alerts', $this->trackingAlertDefaults()),
278
280
  ]);
281
+ $trackingSettings['alerts'] = $this->normalizeTrackingAlertSettings(data_get($trackingSettings, 'alerts', []));
279
282
  $trackingSettings['providers'] = $this->trackingProviderOptions();
280
283
 
281
284
  return response()->json($trackingSettings);
@@ -304,6 +307,7 @@ class SettingController extends Controller
304
307
  'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
305
308
  'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
306
309
  'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
310
+ 'alerts' => $this->normalizeTrackingAlertSettings((array) $request->input('alerts', data_get($config, 'alerts', []))),
307
311
  ]);
308
312
 
309
313
  return response()->json($this->getAdminTrackingSettings()->getData(true));
@@ -519,7 +523,48 @@ class SettingController extends Controller
519
523
  $config = config('fleetops.tracking', []);
520
524
  $systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
521
525
 
522
- return array_merge($config, is_array($systemSettings) ? $systemSettings : []);
526
+ $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []);
527
+ $defaults['alerts'] = $this->normalizeTrackingAlertSettings(data_get($defaults, 'alerts', []));
528
+
529
+ return $defaults;
530
+ }
531
+
532
+ protected function trackingAlertDefaults(): array
533
+ {
534
+ return [
535
+ 'late_departures' => [
536
+ 'enabled' => true,
537
+ 'grace_period_minutes' => 15,
538
+ ],
539
+ 'route_deviations' => [
540
+ 'enabled' => true,
541
+ 'distance_threshold_meters' => 500,
542
+ ],
543
+ 'prolonged_stoppages' => [
544
+ 'enabled' => true,
545
+ 'duration_threshold_minutes' => 30,
546
+ ],
547
+ ];
548
+ }
549
+
550
+ protected function normalizeTrackingAlertSettings(array $alerts): array
551
+ {
552
+ $defaults = $this->trackingAlertDefaults();
553
+
554
+ return [
555
+ 'late_departures' => [
556
+ 'enabled' => (bool) data_get($alerts, 'late_departures.enabled', data_get($defaults, 'late_departures.enabled')),
557
+ 'grace_period_minutes' => max(0, (int) data_get($alerts, 'late_departures.grace_period_minutes', data_get($defaults, 'late_departures.grace_period_minutes'))),
558
+ ],
559
+ 'route_deviations' => [
560
+ 'enabled' => (bool) data_get($alerts, 'route_deviations.enabled', data_get($defaults, 'route_deviations.enabled')),
561
+ 'distance_threshold_meters' => max(0, (int) data_get($alerts, 'route_deviations.distance_threshold_meters', data_get($defaults, 'route_deviations.distance_threshold_meters'))),
562
+ ],
563
+ 'prolonged_stoppages' => [
564
+ 'enabled' => (bool) data_get($alerts, 'prolonged_stoppages.enabled', data_get($defaults, 'prolonged_stoppages.enabled')),
565
+ 'duration_threshold_minutes' => max(0, (int) data_get($alerts, 'prolonged_stoppages.duration_threshold_minutes', data_get($defaults, 'prolonged_stoppages.duration_threshold_minutes'))),
566
+ ],
567
+ ];
523
568
  }
524
569
 
525
570
  protected function trackingProviderOptions(): array
@@ -38,6 +38,7 @@ class Driver extends FleetbaseResource
38
38
  'email' => $this->email,
39
39
  'phone' => $this->phone,
40
40
  'drivers_license_number' => $this->drivers_license_number,
41
+ 'license_expiry' => $this->license_expiry,
41
42
  'photo_url' => $this->photo_url,
42
43
  'avatar_url' => $this->avatar_url,
43
44
  'avatar_value' => $this->when(Http::isInternalRequest(), $this->getOriginal('avatar_url')),
@@ -94,6 +95,7 @@ class Driver extends FleetbaseResource
94
95
  'email' => $this->email,
95
96
  'phone' => $this->phone,
96
97
  'photo_url' => $this->photo_url,
98
+ 'license_expiry' => $this->license_expiry,
97
99
  'vehicle' => data_get($this, 'vehicle.public_id'),
98
100
  'current_job' => data_get($this, 'currentJob.public_id'),
99
101
  'vendor' => data_get($this, 'vendor.public_id'),
@@ -23,6 +23,10 @@ class Fleet extends FleetbaseResource
23
23
  }, $request->array('with'));
24
24
 
25
25
  $this->load($with);
26
+
27
+ if (in_array('subfleets', $with, true) && in_array('vehicles', $with, true)) {
28
+ $this->loadMissing('subFleets.vehicles');
29
+ }
26
30
  }
27
31
 
28
32
  return $this->withCustomFields([