@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.
- package/addon/components/driver/details.hbs +5 -1
- package/addon/components/driver/form.hbs +11 -1
- package/addon/components/fleet-listing-panel.hbs +2 -2
- package/addon/components/fleet-listing-panel.js +10 -0
- package/addon/components/map/google-live-map.js +12 -73
- package/addon/components/map/leaflet-live-map.hbs +149 -33
- package/addon/components/orchestrator/phase-builder.hbs +21 -0
- package/addon/components/orchestrator/phase-builder.js +13 -0
- package/addon/components/orchestrator-workbench.js +1 -0
- package/addon/components/order/form.hbs +2 -2
- package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
- package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
- package/addon/components/widget/fuel-efficiency.hbs +26 -0
- package/addon/components/widget/fuel-efficiency.js +43 -0
- package/addon/components/widget/geofence-violations.hbs +44 -0
- package/addon/components/widget/geofence-violations.js +55 -0
- package/addon/components/widget/issues-insights.hbs +43 -0
- package/addon/components/widget/issues-insights.js +62 -0
- package/addon/components/widget/kpi-active-orders.hbs +10 -0
- package/addon/components/widget/kpi-active-orders.js +5 -0
- package/addon/components/widget/kpi-aov.hbs +10 -0
- package/addon/components/widget/kpi-aov.js +5 -0
- package/addon/components/widget/kpi-distance.hbs +10 -0
- package/addon/components/widget/kpi-distance.js +5 -0
- package/addon/components/widget/kpi-drivers-online.hbs +10 -0
- package/addon/components/widget/kpi-drivers-online.js +5 -0
- package/addon/components/widget/kpi-earnings.hbs +10 -0
- package/addon/components/widget/kpi-earnings.js +5 -0
- package/addon/components/widget/kpi-open-issues.hbs +11 -0
- package/addon/components/widget/kpi-open-issues.js +5 -0
- package/addon/components/widget/kpi-tile.hbs +59 -0
- package/addon/components/widget/kpi-tile.js +168 -0
- package/addon/components/widget/live-fleet.hbs +122 -0
- package/addon/components/widget/live-fleet.js +365 -0
- package/addon/components/widget/maintenance-overview.hbs +45 -0
- package/addon/components/widget/maintenance-overview.js +40 -0
- package/addon/components/widget/on-time-delivery.hbs +27 -0
- package/addon/components/widget/on-time-delivery.js +73 -0
- package/addon/components/widget/operations-pulse.hbs +51 -0
- package/addon/components/widget/operations-pulse.js +173 -0
- package/addon/components/widget/orders-by-status.hbs +26 -0
- package/addon/components/widget/orders-by-status.js +54 -0
- package/addon/components/widget/revenue-trend.hbs +34 -0
- package/addon/components/widget/revenue-trend.js +67 -0
- package/addon/components/widget/top-drivers.hbs +44 -0
- package/addon/components/widget/top-drivers.js +54 -0
- package/addon/controllers/settings/routing.js +26 -0
- package/addon/extension.js +172 -3
- package/addon/services/map-adapter/google.js +13 -2
- package/addon/styles/fleetops-engine.css +114 -87
- package/addon/templates/maintenance/equipment/index.hbs +1 -1
- package/addon/templates/maintenance/parts/index.hbs +1 -1
- package/addon/templates/maintenance/schedules/index.hbs +1 -1
- package/addon/templates/management/vehicles/index.hbs +1 -1
- package/addon/templates/settings/avatars.hbs +1 -1
- package/addon/templates/settings/custom-fields.hbs +1 -1
- package/addon/templates/settings/map.hbs +1 -1
- package/addon/templates/settings/navigator-app.hbs +1 -1
- package/addon/templates/settings/notifications.hbs +1 -1
- package/addon/templates/settings/orchestrator.hbs +1 -1
- package/addon/templates/settings/payments/index.hbs +1 -1
- package/addon/templates/settings/payments/onboard.hbs +1 -1
- package/addon/templates/settings/routing.hbs +22 -2
- package/addon/templates/settings/scheduling.hbs +1 -1
- package/addon/utils/live-map-card-content.js +171 -0
- package/app/components/widget/fuel-efficiency.js +1 -0
- package/app/components/widget/geofence-violations.js +1 -0
- package/app/components/widget/issues-insights.js +1 -0
- package/app/components/widget/kpi-active-orders.js +1 -0
- package/app/components/widget/kpi-aov.js +1 -0
- package/app/components/widget/kpi-distance.js +1 -0
- package/app/components/widget/kpi-drivers-online.js +1 -0
- package/app/components/widget/kpi-earnings.js +1 -0
- package/app/components/widget/kpi-open-issues.js +1 -0
- package/app/components/widget/kpi-tile.js +1 -0
- package/app/components/widget/live-fleet.js +1 -0
- package/app/components/widget/maintenance-overview.js +1 -0
- package/app/components/widget/on-time-delivery.js +1 -0
- package/app/components/widget/operations-pulse.js +1 -0
- package/app/components/widget/orders-by-status.js +1 -0
- package/app/components/widget/revenue-trend.js +1 -0
- package/app/components/widget/top-drivers.js +1 -0
- package/app/utils/live-map-card-content.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/pnpm-workspace.yaml +1 -0
- package/server/config/telematics.php +111 -1
- package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
- package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
- package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
- package/server/seeders/Testing/FleetSeeder.php +341 -0
- package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
- package/server/seeders/Testing/NetworkSeeder.php +250 -0
- package/server/seeders/Testing/OrdersSeeder.php +214 -0
- package/server/seeders/Testing/TestingSeeder.php +40 -0
- package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +8 -1
- package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
- package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
- package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +12 -6
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
- package/server/src/Http/Resources/v1/Driver.php +2 -0
- package/server/src/Http/Resources/v1/Fleet.php +4 -0
- package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
- package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
- package/server/src/Models/Driver.php +2 -0
- package/server/src/Models/OrderConfig.php +14 -11
- package/server/src/Notifications/LateDeparture.php +48 -0
- package/server/src/Notifications/ProlongedStoppage.php +48 -0
- package/server/src/Notifications/RouteDeviation.php +48 -0
- package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +28 -1
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -0
- package/server/src/Providers/NotificationServiceProvider.php +3 -0
- package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
- package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
- package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
- package/server/src/Support/Analytics/IssuesInsights.php +67 -0
- package/server/src/Support/Analytics/LiveFleet.php +127 -0
- package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
- package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
- package/server/src/Support/Analytics/OperationsPulse.php +86 -0
- package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
- package/server/src/Support/Analytics/RevenueTrend.php +79 -0
- package/server/src/Support/Analytics/TopDrivers.php +92 -0
- package/server/src/Support/Metrics/AbstractMetric.php +170 -0
- package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
- package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
- package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
- package/server/src/Support/Metrics/EarningsMetric.php +30 -0
- package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
- package/server/src/Support/Metrics/MoneyMetric.php +30 -0
- package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
- package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
- package/server/src/Support/Metrics/Registry.php +46 -0
- package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
- package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
- package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
- package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
- package/server/src/Support/Metrics.php +46 -249
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
- package/server/src/routes.php +16 -0
- package/server/tests/AnalyticsRoutesTest.php +41 -0
- package/server/tests/MetricsRegistryTest.php +88 -0
- package/server/tests/VroomOrchestrationEngineTest.php +91 -0
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
116
|
-
//
|
|
117
|
-
$
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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([
|