@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,290 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
+
5
+ use Illuminate\Support\Carbon;
6
+ use Illuminate\Support\Facades\Http;
7
+
8
+ /**
9
+ * AFAQY telematics provider implementation.
10
+ *
11
+ * AFAQY uses token-based REST endpoints for authentication, unit discovery,
12
+ * latest unit telemetry, historical signals, and track generation.
13
+ */
14
+ class AfaqyProvider extends AbstractProvider
15
+ {
16
+ protected string $baseUrl = 'https://api.afaqy.sa';
17
+ protected int $requestsPerMinute = 60;
18
+
19
+ protected function prepareAuthentication(): void
20
+ {
21
+ $this->baseUrl = rtrim($this->credentials['base_url'] ?? $this->baseUrl, '/');
22
+ $token = $this->credentials['token'] ?? $this->authenticate();
23
+
24
+ $this->credentials['token'] = $token;
25
+ $this->headers = [
26
+ 'Accept' => 'application/json',
27
+ 'Content-Type' => 'application/json',
28
+ ];
29
+ }
30
+
31
+ public function testConnection(array $credentials): array
32
+ {
33
+ try {
34
+ $this->credentials = $credentials;
35
+ $this->prepareAuthentication();
36
+
37
+ $response = $this->afaqyPost('/units/lists', [
38
+ 'data' => [
39
+ 'projection' => ['_id', 'name', 'imei', 'last_update'],
40
+ 'filters' => new \stdClass(),
41
+ ],
42
+ ]);
43
+
44
+ return [
45
+ 'success' => true,
46
+ 'message' => 'Connection successful',
47
+ 'metadata' => [
48
+ 'units_count' => count($response['data'] ?? []),
49
+ 'status_code' => $response['status_code'] ?? null,
50
+ ],
51
+ ];
52
+ } catch (\Throwable $e) {
53
+ return [
54
+ 'success' => false,
55
+ 'message' => $e->getMessage(),
56
+ 'metadata' => [],
57
+ ];
58
+ }
59
+ }
60
+
61
+ public function fetchDevices(array $options = []): array
62
+ {
63
+ $limit = (int) ($options['limit'] ?? 100);
64
+ $offset = (int) ($options['cursor'] ?? 0);
65
+
66
+ $response = $this->afaqyPost('/units/lists', [
67
+ 'data' => [
68
+ 'address' => $options['address'] ?? false,
69
+ 'filters' => $options['filters'] ?? new \stdClass(),
70
+ 'projection' => $options['projection'] ?? [
71
+ '_id',
72
+ 'name',
73
+ 'imei',
74
+ 'sim_number',
75
+ 'device_serial',
76
+ 'active',
77
+ 'driver_id',
78
+ 'last_update',
79
+ 'counters',
80
+ 'profile',
81
+ 'sensors',
82
+ 'sensors_last_val',
83
+ ],
84
+ ],
85
+ 'limit' => $limit,
86
+ 'offset' => $offset,
87
+ ]);
88
+
89
+ $devices = $response['data'] ?? [];
90
+ $pagination = $response['pagination'] ?? [];
91
+ $total = (int) ($pagination['filtersCount'] ?? $pagination['allCount'] ?? count($devices));
92
+ $nextCursor = ($offset + $limit) < $total ? $offset + $limit : null;
93
+
94
+ return [
95
+ 'devices' => $devices,
96
+ 'next_cursor' => $nextCursor,
97
+ 'has_more' => $nextCursor !== null,
98
+ ];
99
+ }
100
+
101
+ public function fetchDeviceDetails(string $externalId): array
102
+ {
103
+ $response = $this->afaqyPost('/units/view', [
104
+ 'data' => ['id' => $externalId],
105
+ ]);
106
+
107
+ return $response['data'] ?? [];
108
+ }
109
+
110
+ public function normalizeDevice(array $payload): array
111
+ {
112
+ $lastUpdate = $payload['last_update'] ?? [];
113
+
114
+ return [
115
+ 'external_id' => $payload['_id'] ?? $payload['id'] ?? null,
116
+ 'device_name' => $payload['name'] ?? data_get($payload, 'profile.plate_number') ?? 'Unknown Unit',
117
+ 'device_provider' => 'afaqy',
118
+ 'device_model' => data_get($payload, 'profile.model') ?? $payload['device'] ?? null,
119
+ 'imei' => $payload['imei'] ?? null,
120
+ 'phone' => $payload['sim_number'] ?? null,
121
+ 'vin' => data_get($payload, 'profile.vin'),
122
+ 'status' => ($payload['active'] ?? false) ? 'active' : 'inactive',
123
+ 'location' => [
124
+ 'lat' => $lastUpdate['lat'] ?? null,
125
+ 'lng' => $lastUpdate['lng'] ?? null,
126
+ ],
127
+ 'meta' => [
128
+ 'raw' => $payload,
129
+ 'plate_number' => data_get($payload, 'profile.plate_number'),
130
+ 'driver_id' => $payload['driver_id'] ?? null,
131
+ 'last_update' => $this->normalizeEvent($payload),
132
+ 'capabilities' => [
133
+ 'tracking' => isset($lastUpdate['lat'], $lastUpdate['lng']),
134
+ 'odometer' => data_get($payload, 'counters.odometer') !== null,
135
+ 'fuel_level' => $this->extractFuelLevel($payload) !== null,
136
+ 'ignition_state' => $this->extractIgnition($payload) !== null,
137
+ ],
138
+ ],
139
+ ];
140
+ }
141
+
142
+ public function normalizeEvent(array $payload): array
143
+ {
144
+ $lastUpdate = $payload['last_update'] ?? $payload;
145
+
146
+ return [
147
+ 'external_id' => $payload['_id'] ?? $payload['id'] ?? null,
148
+ 'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'telemetry_update',
149
+ 'occurred_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null),
150
+ 'location' => [
151
+ 'lat' => $lastUpdate['lat'] ?? data_get($lastUpdate, 'loc.coordinates.1'),
152
+ 'lng' => $lastUpdate['lng'] ?? data_get($lastUpdate, 'loc.coordinates.0'),
153
+ ],
154
+ 'speed' => $lastUpdate['speed'] ?? $lastUpdate['spd'] ?? null,
155
+ 'heading' => $lastUpdate['angle'] ?? $lastUpdate['ang'] ?? null,
156
+ 'odometer' => data_get($payload, 'counters.odometer'),
157
+ 'ignition' => $this->extractIgnition($payload),
158
+ 'fuel_level' => $this->extractFuelLevel($payload),
159
+ 'meta' => $payload,
160
+ ];
161
+ }
162
+
163
+ public function normalizeSensor(array $payload): array
164
+ {
165
+ return [
166
+ 'sensor_type' => $payload['type'] ?? $payload['name'] ?? $payload['param'] ?? 'generic',
167
+ 'value' => $payload['value'] ?? $payload['last_val']['value'] ?? $payload['last_update'] ?? null,
168
+ 'unit' => $payload['units'] ?? null,
169
+ 'recorded_at' => $this->parseTimestamp($payload['updated_at'] ?? $payload['created_at'] ?? null),
170
+ 'meta' => $payload,
171
+ ];
172
+ }
173
+
174
+ public function getCredentialSchema(): array
175
+ {
176
+ return [
177
+ [
178
+ 'name' => 'base_url',
179
+ 'label' => 'Base URL',
180
+ 'type' => 'text',
181
+ 'placeholder' => 'https://api.afaqy.sa',
182
+ 'required' => false,
183
+ 'validation' => 'nullable|url',
184
+ ],
185
+ [
186
+ 'name' => 'username',
187
+ 'label' => 'Username',
188
+ 'type' => 'text',
189
+ 'placeholder' => 'Enter your AFAQY username',
190
+ 'required' => false,
191
+ 'validation' => 'required_without:token|string',
192
+ ],
193
+ [
194
+ 'name' => 'password',
195
+ 'label' => 'Password',
196
+ 'type' => 'password',
197
+ 'placeholder' => 'Enter your AFAQY password',
198
+ 'required' => false,
199
+ 'validation' => 'required_without:token|string',
200
+ ],
201
+ [
202
+ 'name' => 'token',
203
+ 'label' => 'Token',
204
+ 'type' => 'password',
205
+ 'placeholder' => 'Use an existing AFAQY token',
206
+ 'required' => false,
207
+ 'validation' => 'required_without:username|string',
208
+ ],
209
+ ];
210
+ }
211
+
212
+ protected function authenticate(): string
213
+ {
214
+ if (empty($this->credentials['username']) || empty($this->credentials['password'])) {
215
+ throw new \InvalidArgumentException('AFAQY username/password or token is required.');
216
+ }
217
+
218
+ $response = Http::asJson()
219
+ ->acceptJson()
220
+ ->timeout(30)
221
+ ->post($this->baseUrl . '/auth/login', [
222
+ 'data' => [
223
+ 'username' => $this->credentials['username'],
224
+ 'password' => $this->credentials['password'],
225
+ 'expire' => $this->credentials['expire'] ?? 1,
226
+ ],
227
+ ]);
228
+
229
+ if ($response->failed()) {
230
+ throw new \RuntimeException('AFAQY authentication failed: ' . $response->body());
231
+ }
232
+
233
+ $token = data_get($response->json(), 'data.token');
234
+
235
+ if (!$token) {
236
+ throw new \RuntimeException('AFAQY authentication did not return a token.');
237
+ }
238
+
239
+ return $token;
240
+ }
241
+
242
+ protected function afaqyPost(string $endpoint, array $payload = []): array
243
+ {
244
+ $response = Http::withHeaders($this->headers)
245
+ ->timeout(30)
246
+ ->post($this->baseUrl . $endpoint . '?token=' . urlencode($this->credentials['token']), $payload);
247
+
248
+ if ($response->failed()) {
249
+ throw new \RuntimeException('AFAQY API request failed: ' . $response->body());
250
+ }
251
+
252
+ return $response->json() ?? [];
253
+ }
254
+
255
+ protected function parseTimestamp($value): ?string
256
+ {
257
+ if (!$value) {
258
+ return null;
259
+ }
260
+
261
+ if (is_numeric($value)) {
262
+ $timestamp = (float) $value;
263
+ $seconds = $timestamp > 9999999999 ? $timestamp / 1000 : $timestamp;
264
+
265
+ return Carbon::createFromTimestamp($seconds)->toDateTimeString();
266
+ }
267
+
268
+ return Carbon::parse($value)->toDateTimeString();
269
+ }
270
+
271
+ protected function extractIgnition(array $payload): ?bool
272
+ {
273
+ $params = data_get($payload, 'last_update.params', []);
274
+ $value = data_get($params, 'acc') ?? data_get($params, 'di1') ?? data_get($payload, 'counters.last_acc');
275
+
276
+ if ($value === null) {
277
+ return null;
278
+ }
279
+
280
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? ((int) $value === 1);
281
+ }
282
+
283
+ protected function extractFuelLevel(array $payload): mixed
284
+ {
285
+ return data_get($payload, 'last_update.params.fuel')
286
+ ?? data_get($payload, 'last_update.params.fuel_level')
287
+ ?? data_get($payload, 'fc.level')
288
+ ?? null;
289
+ }
290
+ }
@@ -0,0 +1,333 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
+
5
+ use Illuminate\Support\Carbon;
6
+ use Illuminate\Support\Facades\Http;
7
+
8
+ /**
9
+ * Safee Tracking REST provider implementation.
10
+ *
11
+ * Safee authenticates through an OpenID Connect token endpoint and exposes
12
+ * vehicle discovery plus latest state/position endpoints under api/v2.
13
+ */
14
+ class SafeeProvider extends AbstractProvider
15
+ {
16
+ protected string $baseUrl = '';
17
+ protected int $requestsPerMinute = 3000;
18
+ protected ?string $accessToken = null;
19
+
20
+ protected function prepareAuthentication(): void
21
+ {
22
+ $this->baseUrl = rtrim($this->credentials['api_base_url'] ?? $this->credentials['server_uri'] ?? '', '/');
23
+ $this->accessToken = $this->credentials['access_token'] ?? $this->authenticate();
24
+ $scheme = $this->credentials['authorization_scheme'] ?? 'Bearer';
25
+
26
+ $this->headers = [
27
+ 'Accept' => 'application/json',
28
+ 'Content-Type' => 'application/json',
29
+ 'Accept-Language' => $this->credentials['language'] ?? 'en',
30
+ 'Authorization' => trim($scheme . ' ' . $this->accessToken),
31
+ ];
32
+ }
33
+
34
+ public function testConnection(array $credentials): array
35
+ {
36
+ try {
37
+ $this->credentials = $credentials;
38
+ $this->prepareAuthentication();
39
+
40
+ $response = $this->safeeGet('/api/v2/status');
41
+
42
+ return [
43
+ 'success' => (int) ($response['code'] ?? 0) === 0,
44
+ 'message' => $response['message'] ?? 'Connection successful',
45
+ 'metadata' => [
46
+ 'status' => $response['status'] ?? null,
47
+ 'time' => $response['time'] ?? null,
48
+ ],
49
+ ];
50
+ } catch (\Throwable $e) {
51
+ return [
52
+ 'success' => false,
53
+ 'message' => $e->getMessage(),
54
+ 'metadata' => [],
55
+ ];
56
+ }
57
+ }
58
+
59
+ public function fetchDevices(array $options = []): array
60
+ {
61
+ if (!empty($options['vehicles'])) {
62
+ $response = $this->safeePost('/api/v2/vehicle/last-state', [
63
+ 'live' => $options['live'] ?? true,
64
+ 'startDate' => $options['start_date'] ?? null,
65
+ 'endDate' => $options['end_date'] ?? null,
66
+ 'vehicles' => $options['vehicles'],
67
+ ]);
68
+
69
+ $devices = $response['result'] ?? [];
70
+ } else {
71
+ $response = $this->safeePost('/api/v2/vehicle/list-info', $options['filter'] ?? new \stdClass());
72
+ $devices = $response['result'] ?? [];
73
+ }
74
+
75
+ return [
76
+ 'devices' => $devices,
77
+ 'next_cursor' => null,
78
+ 'has_more' => false,
79
+ ];
80
+ }
81
+
82
+ public function fetchDeviceDetails(string $externalId): array
83
+ {
84
+ $response = $this->safeePost('/api/v2/vehicle/info', [
85
+ 'id' => is_numeric($externalId) ? (int) $externalId : $externalId,
86
+ ]);
87
+
88
+ return $response['result'] ?? [];
89
+ }
90
+
91
+ public function normalizeDevice(array $payload): array
92
+ {
93
+ $position = $this->extractPosition($payload);
94
+
95
+ return [
96
+ 'external_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null,
97
+ 'device_name' => $payload['name'] ?? $payload['plateNumber'] ?? data_get($payload, 'vehicle.name') ?? 'Unknown Vehicle',
98
+ 'device_provider' => 'safee',
99
+ 'device_model' => $payload['model'] ?? data_get($payload, 'device.model') ?? null,
100
+ 'imei' => data_get($payload, 'device.imei') ?? data_get($payload, 'device.serial') ?? null,
101
+ 'vin' => $payload['vin'] ?? null,
102
+ 'status' => $this->normalizeVehicleStatus($payload['status'] ?? $payload['vehicleStatus'] ?? null),
103
+ 'location' => [
104
+ 'lat' => $position['lat'] ?? null,
105
+ 'lng' => $position['lng'] ?? null,
106
+ ],
107
+ 'meta' => [
108
+ 'raw' => $payload,
109
+ 'plate_number' => $payload['plateNumber'] ?? $payload['plate_number'] ?? null,
110
+ 'door_number' => $payload['doorNumber'] ?? null,
111
+ 'driver' => $payload['driver'] ?? null,
112
+ 'last_update' => $this->normalizeEvent($payload),
113
+ 'capabilities' => [
114
+ 'tracking' => isset($position['lat'], $position['lng']),
115
+ 'odometer' => $this->extractOdometer($payload) !== null,
116
+ 'fuel_level' => $this->extractFuelLevel($payload) !== null,
117
+ 'ignition_state' => $this->extractIgnition($payload) !== null,
118
+ ],
119
+ ],
120
+ ];
121
+ }
122
+
123
+ public function normalizeEvent(array $payload): array
124
+ {
125
+ $position = $this->extractPosition($payload);
126
+
127
+ return [
128
+ 'external_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null,
129
+ 'event_type' => data_get($payload, 'event.code') ?? data_get($payload, 'event.name') ?? 'telemetry_update',
130
+ 'occurred_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null),
131
+ 'location' => [
132
+ 'lat' => $position['lat'] ?? null,
133
+ 'lng' => $position['lng'] ?? null,
134
+ ],
135
+ 'speed' => $payload['speed'] ?? $payload['lastSpeed'] ?? null,
136
+ 'heading' => $payload['heading'] ?? $payload['angle'] ?? null,
137
+ 'odometer' => $this->extractOdometer($payload),
138
+ 'ignition' => $this->extractIgnition($payload),
139
+ 'fuel_level' => $this->extractFuelLevel($payload),
140
+ 'meta' => $payload,
141
+ ];
142
+ }
143
+
144
+ public function normalizeSensor(array $payload): array
145
+ {
146
+ return [
147
+ 'sensor_type' => $payload['type'] ?? $payload['name'] ?? 'generic',
148
+ 'value' => $payload['value'] ?? $payload['lastValue'] ?? null,
149
+ 'unit' => $payload['unit'] ?? null,
150
+ 'recorded_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? null),
151
+ 'meta' => $payload,
152
+ ];
153
+ }
154
+
155
+ public function getCredentialSchema(): array
156
+ {
157
+ return [
158
+ [
159
+ 'name' => 'server_uri',
160
+ 'label' => 'Server URI',
161
+ 'type' => 'text',
162
+ 'placeholder' => 'https://tracking.example.com',
163
+ 'required' => true,
164
+ 'validation' => 'required|url',
165
+ ],
166
+ [
167
+ 'name' => 'realm_id',
168
+ 'label' => 'Realm ID',
169
+ 'type' => 'text',
170
+ 'placeholder' => 'Enter Safee realm ID',
171
+ 'required' => true,
172
+ ],
173
+ [
174
+ 'name' => 'client_id',
175
+ 'label' => 'Client ID',
176
+ 'type' => 'text',
177
+ 'placeholder' => 'Enter Safee client ID',
178
+ 'required' => true,
179
+ ],
180
+ [
181
+ 'name' => 'client_secret',
182
+ 'label' => 'Client Secret',
183
+ 'type' => 'password',
184
+ 'placeholder' => 'Enter Safee client secret',
185
+ 'required' => true,
186
+ ],
187
+ [
188
+ 'name' => 'username',
189
+ 'label' => 'Username',
190
+ 'type' => 'text',
191
+ 'placeholder' => 'Enter Safee username',
192
+ 'required' => true,
193
+ ],
194
+ [
195
+ 'name' => 'password',
196
+ 'label' => 'Password',
197
+ 'type' => 'password',
198
+ 'placeholder' => 'Enter Safee password',
199
+ 'required' => true,
200
+ ],
201
+ ];
202
+ }
203
+
204
+ protected function authenticate(): string
205
+ {
206
+ foreach (['server_uri', 'realm_id', 'client_id', 'client_secret', 'username', 'password'] as $field) {
207
+ if (empty($this->credentials[$field])) {
208
+ throw new \InvalidArgumentException("Safee credential '{$field}' is required.");
209
+ }
210
+ }
211
+
212
+ $tokenUrl = rtrim($this->credentials['server_uri'], '/') . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token';
213
+
214
+ $response = Http::asForm()
215
+ ->acceptJson()
216
+ ->timeout(30)
217
+ ->post($tokenUrl, [
218
+ 'grant_type' => 'password',
219
+ 'client_secret' => $this->credentials['client_secret'],
220
+ 'client_id' => $this->credentials['client_id'],
221
+ 'username' => $this->credentials['username'],
222
+ 'password' => $this->credentials['password'],
223
+ ]);
224
+
225
+ if ($response->failed()) {
226
+ throw new \RuntimeException('Safee authentication failed: ' . $response->body());
227
+ }
228
+
229
+ $token = $response->json('access_token');
230
+
231
+ if (!$token) {
232
+ throw new \RuntimeException('Safee authentication did not return an access token.');
233
+ }
234
+
235
+ return $token;
236
+ }
237
+
238
+ protected function safeeGet(string $endpoint): array
239
+ {
240
+ $response = Http::withHeaders($this->headers)
241
+ ->timeout(30)
242
+ ->get($this->baseUrl . $endpoint);
243
+
244
+ if ($response->failed()) {
245
+ throw new \RuntimeException('Safee API request failed: ' . $response->body());
246
+ }
247
+
248
+ return $response->json() ?? [];
249
+ }
250
+
251
+ protected function safeePost(string $endpoint, array|\stdClass $payload = []): array
252
+ {
253
+ $response = Http::withHeaders($this->headers)
254
+ ->timeout(30)
255
+ ->post($this->baseUrl . $endpoint, $payload);
256
+
257
+ if ($response->failed()) {
258
+ throw new \RuntimeException('Safee API request failed: ' . $response->body());
259
+ }
260
+
261
+ return $response->json() ?? [];
262
+ }
263
+
264
+ protected function extractPosition(array $payload): array
265
+ {
266
+ $position = $payload['position'] ?? $payload['lastPosition'] ?? $payload['location'] ?? $payload;
267
+
268
+ return [
269
+ 'lat' => $position['lat'] ?? $position['latitude'] ?? data_get($position, 'loc.coordinates.1'),
270
+ 'lng' => $position['lon'] ?? $position['lng'] ?? $position['longitude'] ?? data_get($position, 'loc.coordinates.0'),
271
+ ];
272
+ }
273
+
274
+ protected function parseTimestamp($value): ?string
275
+ {
276
+ if (!$value) {
277
+ return null;
278
+ }
279
+
280
+ if (is_numeric($value)) {
281
+ $timestamp = (float) $value;
282
+ $seconds = $timestamp > 9999999999 ? $timestamp / 1000 : $timestamp;
283
+
284
+ return Carbon::createFromTimestamp($seconds)->toDateTimeString();
285
+ }
286
+
287
+ return Carbon::parse($value)->toDateTimeString();
288
+ }
289
+
290
+ protected function normalizeVehicleStatus(?string $status): string
291
+ {
292
+ if (!$status) {
293
+ return 'active';
294
+ }
295
+
296
+ return in_array(strtolower($status), ['offline', 'inactive', 'deleted', 'expired'], true) ? 'inactive' : 'active';
297
+ }
298
+
299
+ protected function extractOdometer(array $payload): mixed
300
+ {
301
+ return data_get($payload, 'canbus.odometer')
302
+ ?? data_get($payload, 'vehicleCanbus.odometer')
303
+ ?? data_get($payload, 'odometer')
304
+ ?? null;
305
+ }
306
+
307
+ protected function extractIgnition(array $payload): ?bool
308
+ {
309
+ $value = $payload['ignition'] ?? data_get($payload, 'event.code');
310
+
311
+ if ($value === null) {
312
+ return null;
313
+ }
314
+
315
+ if (is_string($value) && str_contains(strtolower($value), 'ignition_on')) {
316
+ return true;
317
+ }
318
+
319
+ if (is_string($value) && str_contains(strtolower($value), 'ignition_off')) {
320
+ return false;
321
+ }
322
+
323
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
324
+ }
325
+
326
+ protected function extractFuelLevel(array $payload): mixed
327
+ {
328
+ return data_get($payload, 'fuel.level')
329
+ ?? data_get($payload, 'fuelLevel')
330
+ ?? data_get($payload, 'vehicleFuel.level')
331
+ ?? null;
332
+ }
333
+ }
@@ -573,6 +573,22 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
573
573
  ['prefix' => 'metrics'],
574
574
  function ($router) {
575
575
  $router->get('/', 'MetricsController@all');
576
+ $router->get('{slug}', 'MetricsController@show');
577
+ }
578
+ );
579
+ $router->group(
580
+ ['prefix' => 'analytics'],
581
+ function ($router) {
582
+ $router->get('operations-pulse', 'AnalyticsController@operationsPulse');
583
+ $router->get('revenue-trend', 'AnalyticsController@revenueTrend');
584
+ $router->get('orders-by-status', 'AnalyticsController@ordersByStatus');
585
+ $router->get('on-time-delivery', 'AnalyticsController@onTimeDelivery');
586
+ $router->get('top-drivers', 'AnalyticsController@topDrivers');
587
+ $router->get('fuel-efficiency', 'AnalyticsController@fuelEfficiency');
588
+ $router->get('issues-insights', 'AnalyticsController@issuesInsights');
589
+ $router->get('maintenance-overview', 'AnalyticsController@maintenanceOverview');
590
+ $router->get('geofence-violations', 'AnalyticsController@geofenceViolations');
591
+ $router->get('live-fleet', 'AnalyticsController@liveFleet');
576
592
  }
577
593
  );
578
594
  $router->group(