@fleetbase/fleetops-engine 0.6.66 → 0.6.68

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 (55) hide show
  1. package/RELEASE.md +9 -8
  2. package/addon/components/fuel-integration/form.hbs +2 -2
  3. package/addon/components/fuel-integration/form.js +8 -0
  4. package/addon/components/fuel-integration/hub.js +1 -1
  5. package/addon/components/telematic/details.hbs +1 -1
  6. package/addon/components/telematic/telemetry-status.hbs +93 -34
  7. package/addon/components/widget/radar.hbs +1 -1
  8. package/addon/components/widget/radar.js +7 -0
  9. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  10. package/addon/utils/fuel-transaction.js +2 -0
  11. package/composer.json +2 -1
  12. package/docs/AFAQY.md +71 -17
  13. package/docs/SAFEE.md +130 -0
  14. package/docs/TELEMATICS_QUEUES.md +230 -0
  15. package/extension.json +1 -1
  16. package/package.json +1 -1
  17. package/server/config/fuel-providers.php +53 -0
  18. package/server/config/safee.php +10 -0
  19. package/server/config/telematics.php +8 -1
  20. package/server/config/telemetry.php +4 -1
  21. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  22. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  23. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  24. package/server/src/Events/TrailerLocationChanged.php +2 -0
  25. package/server/src/Events/VehicleLocationChanged.php +5 -0
  26. package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
  27. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  28. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  29. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  30. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  31. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  32. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  33. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  34. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  35. package/server/src/Support/Telematics/TelematicService.php +45 -1
  36. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  37. package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
  38. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  39. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  40. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  41. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
  42. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  43. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  44. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  45. package/server/tests/ProviderContractsTest.php +50 -1
  46. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  47. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  48. package/server/tests/TelematicsHardeningTest.php +18 -13
  49. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  50. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  51. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  52. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  53. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  54. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  55. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -0,0 +1,272 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\FuelProviders\Providers;
4
+
5
+ use Fleetbase\FleetOps\Models\FuelProviderConnection;
6
+ use Illuminate\Http\Client\Response;
7
+ use Illuminate\Support\Carbon;
8
+ use Illuminate\Support\Collection;
9
+ use Illuminate\Support\Facades\Cache;
10
+ use Illuminate\Support\Facades\Http;
11
+
12
+ class SascoFuelProvider extends AbstractFuelProvider
13
+ {
14
+ public const BASE_URLS = [
15
+ 'sandbox' => 'https://loyalty-api-qc.sasco.sa',
16
+ 'production' => 'https://loyalty-api.sasco.com.sa',
17
+ ];
18
+
19
+ public const LOGIN_ENDPOINT = 'apigateway/api/identity/user/b2badmin-login';
20
+ public const VEHICLES_ENDPOINT = 'apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles';
21
+ public const TRANSACTIONS_ENDPOINT = 'apigateway/api/analytics/v1/b2bexternal/drivers-transactions';
22
+
23
+ /** Fallback token lifetime when the JWT carries no readable exp claim. */
24
+ protected const DEFAULT_TOKEN_TTL = 3600;
25
+
26
+ public function key(): string
27
+ {
28
+ return 'sasco';
29
+ }
30
+
31
+ public function name(): string
32
+ {
33
+ return 'SASCO';
34
+ }
35
+
36
+ public function authenticate(FuelProviderConnection $connection): array
37
+ {
38
+ $credentials = (array) $connection->credentials;
39
+ $response = Http::baseUrl($this->baseUrl($connection))
40
+ ->timeout((int) data_get($credentials, 'timeout', 30))
41
+ ->acceptJson()
42
+ ->asJson()
43
+ ->post(self::LOGIN_ENDPOINT, [
44
+ 'username' => trim((string) data_get($credentials, 'username')),
45
+ 'password' => (string) data_get($credentials, 'password'),
46
+ ]);
47
+
48
+ $token = $response->json('accessToken');
49
+ if (!$response->successful() || !is_string($token) || $token === '') {
50
+ return [
51
+ 'success' => false,
52
+ 'message' => $this->errorMessage($response, 'SASCO login failed. Check the B2B admin username and password.'),
53
+ 'status' => $response->status(),
54
+ ];
55
+ }
56
+
57
+ Cache::put($this->tokenCacheKey($connection), $token, $this->tokenTtl($token));
58
+
59
+ return ['success' => true, 'message' => 'SASCO login successful.', 'token' => $token];
60
+ }
61
+
62
+ public function testConnection(FuelProviderConnection $connection): array
63
+ {
64
+ $metadata = [
65
+ 'environment' => $connection->environment ?? 'production',
66
+ 'host' => parse_url($this->baseUrl($connection), PHP_URL_HOST),
67
+ ];
68
+
69
+ try {
70
+ $response = $this->get($connection, self::VEHICLES_ENDPOINT, ['Page_size' => 1, 'Current_page' => 1]);
71
+ $metadata['status'] = $response->status();
72
+
73
+ if (!$response->successful() || !is_array($response->json('vehicles'))) {
74
+ return [
75
+ 'success' => false,
76
+ 'message' => $this->errorMessage($response, 'Unable to connect to SASCO.'),
77
+ 'metadata' => $metadata,
78
+ ];
79
+ }
80
+
81
+ return [
82
+ 'success' => true,
83
+ 'message' => 'SASCO connection successful.',
84
+ 'metadata' => array_merge($metadata, ['total_vehicles' => $response->json('totalItems')]),
85
+ ];
86
+ } catch (\Throwable $e) {
87
+ return ['success' => false, 'message' => $e->getMessage(), 'metadata' => $metadata];
88
+ }
89
+ }
90
+
91
+ public function listVehicles(FuelProviderConnection $connection, array $options = []): Collection
92
+ {
93
+ return $this->paginatedGet($connection, self::VEHICLES_ENDPOINT, 'vehicles', $options);
94
+ }
95
+
96
+ public function listTransactions(FuelProviderConnection $connection, Carbon $from, Carbon $to, array $options = []): Collection
97
+ {
98
+ $params = [
99
+ 'FromDate' => $from->toDateString(),
100
+ 'ToDate' => $to->toDateString(),
101
+ ];
102
+
103
+ if (array_key_exists('is_trip', $options)) {
104
+ $params['IsTrip'] = $options['is_trip'] ? 'true' : 'false';
105
+ }
106
+
107
+ return $this->paginatedGet($connection, self::TRANSACTIONS_ENDPOINT, 'driverTransactions', array_merge($options, $params))
108
+ ->map(fn ($payload) => $this->normalizeTransaction($payload));
109
+ }
110
+
111
+ protected function baseUrl(FuelProviderConnection $connection): string
112
+ {
113
+ $credentials = (array) $connection->credentials;
114
+ $override = trim((string) data_get($credentials, 'base_url', ''));
115
+
116
+ return rtrim($override ?: self::BASE_URLS[$connection->environment ?? 'production'], '/');
117
+ }
118
+
119
+ protected function headers(FuelProviderConnection $connection): array
120
+ {
121
+ return ['Authorization' => 'Bearer ' . $this->token($connection)];
122
+ }
123
+
124
+ /**
125
+ * Returns a cached access token, logging in when none is cached.
126
+ */
127
+ protected function token(FuelProviderConnection $connection): string
128
+ {
129
+ $token = Cache::get($this->tokenCacheKey($connection));
130
+ if (is_string($token) && $token !== '') {
131
+ return $token;
132
+ }
133
+
134
+ $result = $this->authenticate($connection);
135
+ if (!$result['success']) {
136
+ throw new \RuntimeException($result['message']);
137
+ }
138
+
139
+ return $result['token'];
140
+ }
141
+
142
+ /**
143
+ * Keyed by host and username rather than connection uuid so unsaved
144
+ * connections built for credential tests share the same cache entry.
145
+ */
146
+ protected function tokenCacheKey(FuelProviderConnection $connection): string
147
+ {
148
+ $credentials = (array) $connection->credentials;
149
+
150
+ return 'fuel-provider:sasco:token:' . hash('sha256', $this->baseUrl($connection) . '|' . trim((string) data_get($credentials, 'username')));
151
+ }
152
+
153
+ protected function tokenTtl(string $token): int
154
+ {
155
+ $segments = explode('.', $token);
156
+ $payload = isset($segments[1]) ? json_decode((string) base64_decode(strtr($segments[1], '-_', '+/')), true) : null;
157
+ $expires = (int) data_get($payload, 'exp', 0);
158
+
159
+ if ($expires <= 0) {
160
+ return self::DEFAULT_TOKEN_TTL;
161
+ }
162
+
163
+ // Refresh a minute early so a request never goes out with a token about to lapse.
164
+ return max(1, $expires - now()->timestamp - 60);
165
+ }
166
+
167
+ /**
168
+ * Sends an authenticated GET, re-authenticating once when SASCO rejects a cached token.
169
+ */
170
+ protected function get(FuelProviderConnection $connection, string $endpoint, array $params = []): Response
171
+ {
172
+ $response = $this->client($connection)->get($endpoint, $params);
173
+
174
+ if ($response->status() === 401) {
175
+ Cache::forget($this->tokenCacheKey($connection));
176
+ $response = $this->client($connection)->get($endpoint, $params);
177
+ }
178
+
179
+ return $response;
180
+ }
181
+
182
+ protected function paginatedGet(FuelProviderConnection $connection, string $endpoint, string $dataKey, array $params = []): Collection
183
+ {
184
+ $page = max(1, (int) data_get($params, 'page', 1));
185
+ $pageSize = max(1, (int) data_get($params, 'page_size', 100));
186
+ $maxPages = (int) data_get($params, 'max_pages', 100);
187
+ $query = collect($params)->except(['page', 'page_size', 'max_pages', 'is_trip'])->all();
188
+
189
+ $items = collect();
190
+
191
+ do {
192
+ $response = $this->get($connection, $endpoint, array_merge($query, ['Page_size' => $pageSize, 'Current_page' => $page]));
193
+
194
+ if (!$response->successful()) {
195
+ throw new \RuntimeException($this->errorMessage($response, "SASCO {$endpoint} request failed."));
196
+ }
197
+
198
+ $data = $response->json($dataKey);
199
+ if (!is_array($data)) {
200
+ throw new \RuntimeException("SASCO {$endpoint} returned an invalid response.");
201
+ }
202
+ $items = $items->merge($data);
203
+
204
+ $total = (int) $response->json('totalItems', 0);
205
+ $hasMore = count($data) > 0 && $items->count() < $total;
206
+ $page++;
207
+ } while ($hasMore && $page <= $maxPages);
208
+
209
+ if ($hasMore) {
210
+ throw new \RuntimeException('SASCO returned more pages than the sync limit. Choose a shorter date range and retry; the import was not marked complete.');
211
+ }
212
+
213
+ return $items;
214
+ }
215
+
216
+ protected function errorMessage(Response $response, string $fallback): string
217
+ {
218
+ $message = $response->json('message') ?? $response->json('title') ?? $response->json('error');
219
+
220
+ return is_string($message) && $message !== '' ? $message : $fallback;
221
+ }
222
+
223
+ /**
224
+ * Saudi plates are printed as three letters and up to four digits, e.g. "NXD 1240".
225
+ */
226
+ protected function plateNumber(array $plate): ?string
227
+ {
228
+ $number = $this->compactIdentifier($plate['plateNumber'] ?? null);
229
+ $letters = collect([$plate['firstPlateLetter'] ?? null, $plate['secondPlateLetter'] ?? null, $plate['thirdPlateLetter'] ?? null])
230
+ ->map(fn ($letter) => $this->compactIdentifier($letter))
231
+ ->filter()
232
+ ->implode('');
233
+
234
+ return $this->compactIdentifier(trim($letters . ' ' . $number));
235
+ }
236
+
237
+ protected function normalizeTransaction(array $transaction): array
238
+ {
239
+ $plate = (array) ($transaction['vehiclePlate'] ?? []);
240
+ $providerId = $this->compactIdentifier($transaction['transactionNumber'] ?? null);
241
+ $fallbackId = $this->transactionHash([
242
+ $transaction['createdAt'] ?? null,
243
+ $plate,
244
+ $transaction['stationName'] ?? null,
245
+ $transaction['noOfLitters'] ?? null,
246
+ $transaction['amount'] ?? null,
247
+ $transaction['driverPhone'] ?? null,
248
+ ]);
249
+
250
+ return [
251
+ 'provider' => $this->key(),
252
+ 'provider_transaction_id' => (string) ($providerId ?: $fallbackId),
253
+ 'plate_number' => $this->plateNumber($plate),
254
+ 'trip_number' => $this->compactIdentifier($transaction['tripId'] ?? null),
255
+ 'station_name' => $this->compactIdentifier($transaction['stationName'] ?? null),
256
+ 'transaction_at' => $this->dateFrom($transaction['createdAt'] ?? null),
257
+ 'volume' => $transaction['noOfLitters'] ?? null,
258
+ 'metric_unit' => 'l',
259
+ 'amount' => $this->minorCurrencyUnit($transaction['amount'] ?? null),
260
+ 'currency' => 'SAR',
261
+ 'normalized_payload' => [
262
+ 'fuel_type' => $transaction['fuelType'] ?? null,
263
+ 'driver_name' => $transaction['driverName'] ?? null,
264
+ 'driver_phone' => $transaction['driverPhone'] ?? null,
265
+ 'type' => $transaction['type'] ?? null,
266
+ 'period' => $transaction['period'] ?? null,
267
+ 'fuel_image_url' => $transaction['fuelImageUrl'] ?? null,
268
+ ],
269
+ 'raw_payload' => $transaction,
270
+ ];
271
+ }
272
+ }
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
5
5
  use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
6
6
  use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
7
7
  use Fleetbase\FleetOps\Support\Telematics\Afaqy\Payload;
8
+ use Illuminate\Contracts\Encryption\DecryptException;
8
9
  use Illuminate\Http\Client\ConnectionException;
9
10
  use Illuminate\Http\Client\Response;
10
11
  use Illuminate\Support\Facades\Cache;
@@ -20,12 +21,13 @@ use Illuminate\Support\Facades\Log;
20
21
  */
21
22
  class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
22
23
  {
23
- protected string $baseUrl = 'https://api.afaqy.sa';
24
- protected int $requestsPerMinute = 60;
25
- protected int $dataTimeout = 120;
26
- protected int $connectTimeout = 15;
27
- protected int $connectionTestTimeout = 30;
28
- protected int $connectionTestConnectTimeout = 10;
24
+ protected string $baseUrl = 'https://api.afaqy.sa';
25
+ protected int $requestsPerMinute = 60;
26
+ protected int $dataTimeout = 120;
27
+ protected int $connectTimeout = 15;
28
+ protected int $connectionTestTimeout = 30;
29
+ protected int $connectionTestConnectTimeout = 10;
30
+ protected ?float $requestDeadline = null;
29
31
 
30
32
  protected function prepareAuthentication(): void
31
33
  {
@@ -343,9 +345,11 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
343
345
  }
344
346
 
345
347
  $this->reserveRequest();
348
+ $timeout = $this->remainingRequestTimeout(30);
346
349
  $response = Http::asJson()
347
350
  ->acceptJson()
348
- ->timeout(30)
351
+ ->timeout($timeout)
352
+ ->connectTimeout(min($this->connectTimeout, $timeout))
349
353
  ->post($this->baseUrl . '/auth/login', [
350
354
  'data' => [
351
355
  'username' => $this->credentials['username'],
@@ -373,6 +377,20 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
373
377
  }
374
378
 
375
379
  protected function authenticatedPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, bool $allowRetry = true, ?int $timeout = null, ?int $connectTimeout = null): array
380
+ {
381
+ $previousDeadline = $this->requestDeadline;
382
+ $this->requestDeadline ??= $this->requestTime() + max(1, $timeout ?? $this->dataTimeout);
383
+
384
+ try {
385
+ return $this->postWithinDeadline($endpoint, $payload, $tokenInQuery, $allowRetry, $timeout, $connectTimeout);
386
+ } finally {
387
+ // Providers may be reused. A completed or failed request must not
388
+ // leave its deadline on the next independent request.
389
+ $this->requestDeadline = $previousDeadline;
390
+ }
391
+ }
392
+
393
+ protected function postWithinDeadline(string $endpoint, array $payload, bool $tokenInQuery, bool $allowRetry, ?int $timeout, ?int $connectTimeout): array
376
394
  {
377
395
  [$url, $body] = $this->buildAuthenticatedRequest($endpoint, $payload, $tokenInQuery);
378
396
 
@@ -381,6 +399,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
381
399
  $connectTimeout ??= $this->connectTimeout;
382
400
 
383
401
  $this->reserveRequest();
402
+ $timeout = $this->remainingRequestTimeout($timeout);
403
+ $connectTimeout = min(max(1, $connectTimeout), $timeout);
384
404
  try {
385
405
  $response = Http::withHeaders($this->headers)
386
406
  ->timeout($timeout)
@@ -468,9 +488,22 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
468
488
  {
469
489
  $key = 'afaqy:token:' . $this->accountKey() . ':' . hash('sha256', $this->credentials['password'] ?? '');
470
490
 
471
- return Cache::lock($key . ':lock', 40)->block(5, function () use ($key, $rejected) {
491
+ return Cache::lock($key . ':lock', 40)->block($this->remainingRequestTimeout(5), function () use ($key, $rejected) {
472
492
  $stored = Cache::get($key);
473
- $token = $stored ? Crypt::decryptString($stored) : null;
493
+ $token = null;
494
+ if ($stored) {
495
+ try {
496
+ $token = Crypt::decryptString($stored);
497
+ } catch (DecryptException) {
498
+ // A cached token is disposable. Evict only this entry while holding
499
+ // the existing account lock, then authenticate with resolved credentials.
500
+ Cache::forget($key);
501
+ Log::warning('AFAQY cached token could not be decrypted; re-authenticating.', [
502
+ 'telematic_uuid' => $this->telematic?->uuid,
503
+ 'reason' => 'cached_token_decryption_failed',
504
+ ]);
505
+ }
506
+ }
474
507
  if (!$token || $token === $rejected) {
475
508
  $token = $this->authenticate();
476
509
  Cache::put($key, Crypt::encryptString($token), 29 * 86400);
@@ -483,7 +516,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
483
516
  protected function reserveRequest(): void
484
517
  {
485
518
  $key = 'afaqy:rate:' . $this->accountKey();
486
- Cache::lock($key . ':lock', 5)->block(2, function () use ($key) {
519
+ Cache::lock($key . ':lock', 5)->block($this->remainingRequestTimeout(2), function () use ($key) {
520
+ $this->remainingRequestTimeout(1);
487
521
  $now = microtime(true);
488
522
  $until = (float) Cache::get($key . ':blocked', 0);
489
523
  $requests = array_values(array_filter(Cache::get($key, []), fn ($at) => $at > $now - 60));
@@ -495,6 +529,27 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
495
529
  });
496
530
  }
497
531
 
532
+ /** Monotonic time keeps the request budget independent of wall-clock corrections. */
533
+ protected function requestTime(): float
534
+ {
535
+ return hrtime(true) / 1_000_000_000;
536
+ }
537
+
538
+ protected function remainingRequestTimeout(int $maximum): int
539
+ {
540
+ $maximum = max(1, $maximum);
541
+ if ($this->requestDeadline === null) {
542
+ return $maximum;
543
+ }
544
+
545
+ $remaining = (int) floor($this->requestDeadline - $this->requestTime());
546
+ if ($remaining < 1) {
547
+ throw new TelematicProviderException('AFAQY request time budget exhausted before another operation could start.');
548
+ }
549
+
550
+ return min($maximum, $remaining);
551
+ }
552
+
498
553
  protected function checkThrottle(Response $response): void
499
554
  {
500
555
  if ($response->status() !== 429) {