@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.
- package/RELEASE.md +9 -8
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/telemetry-status.hbs +93 -34
- package/addon/components/widget/radar.hbs +1 -1
- package/addon/components/widget/radar.js +7 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +130 -0
- package/docs/TELEMATICS_QUEUES.md +230 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fuel-providers.php +53 -0
- package/server/config/safee.php +10 -0
- package/server/config/telematics.php +8 -1
- package/server/config/telemetry.php +4 -1
- package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
- package/server/src/Console/Commands/SyncTelematics.php +11 -5
- package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
- package/server/src/Events/TrailerLocationChanged.php +2 -0
- package/server/src/Events/VehicleLocationChanged.php +5 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
- package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
- package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
- package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
- package/server/src/Support/Telematics/Safee/Transport.php +210 -0
- package/server/src/Support/Telematics/TelematicService.php +45 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
- package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
- package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
- package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
- package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
- package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
- package/server/tests/ProviderContractsTest.php +50 -1
- package/server/tests/Support/AfaqyTestCrypto.php +6 -23
- package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
- package/server/tests/TelematicsHardeningTest.php +18 -13
- package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
- package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
- package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
- package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
- package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\Telematics\Safee;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
|
|
6
|
+
use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
|
|
7
|
+
use Illuminate\Contracts\Encryption\DecryptException;
|
|
8
|
+
use Illuminate\Http\Client\ConnectionException;
|
|
9
|
+
use Illuminate\Http\Client\Response;
|
|
10
|
+
use Illuminate\Support\Facades\Cache;
|
|
11
|
+
use Illuminate\Support\Facades\Crypt;
|
|
12
|
+
use Illuminate\Support\Facades\Http;
|
|
13
|
+
|
|
14
|
+
/** Safee's documented OAuth and per-user request budget, shared across workers. */
|
|
15
|
+
class Transport
|
|
16
|
+
{
|
|
17
|
+
protected ?string $token = null;
|
|
18
|
+
protected ?float $expiresAt = null;
|
|
19
|
+
|
|
20
|
+
public function __construct(protected string $baseUrl, protected array $credentials)
|
|
21
|
+
{
|
|
22
|
+
$this->token = $credentials['access_token'] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public function fingerprint(): string
|
|
26
|
+
{
|
|
27
|
+
$credentials = $this->credentials;
|
|
28
|
+
ksort($credentials);
|
|
29
|
+
|
|
30
|
+
return hash('sha256', $this->baseUrl . '|' . json_encode($credentials, JSON_THROW_ON_ERROR));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public function accountKey(): string
|
|
34
|
+
{
|
|
35
|
+
return hash('sha256', implode('|', [$this->baseUrl, $this->credentials['realm_id'] ?? '', $this->credentials['username'] ?? $this->credentials['access_token'] ?? '']));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function request(string $method, string $endpoint, array|\stdClass $payload, float $deadline, int $timeout, int $connectTimeout): array
|
|
39
|
+
{
|
|
40
|
+
$refreshed = false;
|
|
41
|
+
while (true) {
|
|
42
|
+
if (!$this->token || ($this->expiresAt !== null && $this->expiresAt <= $this->wallTime() + 5)) {
|
|
43
|
+
$this->token = $this->cachedToken($deadline, $timeout, $connectTimeout);
|
|
44
|
+
}
|
|
45
|
+
$this->reserveRequest($deadline);
|
|
46
|
+
try {
|
|
47
|
+
$request = Http::withHeaders([
|
|
48
|
+
'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
49
|
+
'Accept-Language' => $this->credentials['language'] ?? 'en',
|
|
50
|
+
'Authorization' => trim(($this->credentials['authorization_scheme'] ?? 'Bearer') . ' ' . $this->token),
|
|
51
|
+
])->timeout($this->remaining($deadline, $timeout))
|
|
52
|
+
->connectTimeout($this->remaining($deadline, $connectTimeout));
|
|
53
|
+
$response = $method === 'GET' ? $request->get($this->baseUrl . $endpoint) : $request->post($this->baseUrl . $endpoint, $payload);
|
|
54
|
+
} catch (ConnectionException $e) {
|
|
55
|
+
throw new TelematicProviderException('Safee request timed out or could not connect.', ['endpoint' => $endpoint], previous: $e);
|
|
56
|
+
}
|
|
57
|
+
$this->checkThrottle($response);
|
|
58
|
+
// A rejected token is refreshed once; a second rejection fails as an unsuccessful response.
|
|
59
|
+
if ($response->status() === 401 && !$refreshed && $this->canAuthenticate()) {
|
|
60
|
+
$refreshed = true;
|
|
61
|
+
$this->token = $this->cachedToken($deadline, $timeout, $connectTimeout, $this->token);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if ($response->failed()) {
|
|
65
|
+
throw new TelematicProviderException('Safee API request failed with status ' . $response->status(), ['endpoint' => $endpoint, 'status' => $response->status()]);
|
|
66
|
+
}
|
|
67
|
+
$json = $response->json();
|
|
68
|
+
if (!is_array($json) || !array_key_exists('code', $json) || !is_numeric($json['code']) || (float) $json['code'] !== 0.0) {
|
|
69
|
+
throw new TelematicProviderException('Safee returned an invalid or unsuccessful response.', ['endpoint' => $endpoint]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return $json;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public function authenticate(float $deadline, int $timeout = 30, int $connectTimeout = 5): string
|
|
77
|
+
{
|
|
78
|
+
return $this->cachedToken($deadline, $timeout, $connectTimeout);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected function canAuthenticate(): bool
|
|
82
|
+
{
|
|
83
|
+
foreach (['realm_id', 'client_id', 'client_secret', 'username', 'password'] as $field) {
|
|
84
|
+
if (empty($this->credentials[$field])) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
protected function cachedToken(float $deadline, int $timeout, int $connectTimeout, ?string $rejected = null): string
|
|
93
|
+
{
|
|
94
|
+
if (!$this->canAuthenticate()) {
|
|
95
|
+
throw new \InvalidArgumentException('Safee realm, client and user credentials are required to authenticate.');
|
|
96
|
+
}
|
|
97
|
+
$key = 'safee:token:' . $this->fingerprint();
|
|
98
|
+
|
|
99
|
+
return Cache::lock($key . ':lock', 60)->block($this->remaining($deadline, 2), function () use ($key, $deadline, $timeout, $connectTimeout, $rejected) {
|
|
100
|
+
$cached = null;
|
|
101
|
+
if ($encrypted = Cache::get($key)) {
|
|
102
|
+
try {
|
|
103
|
+
$cached = json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR);
|
|
104
|
+
} catch (DecryptException|\JsonException) {
|
|
105
|
+
// Only the disposable token cache is recoverable this way.
|
|
106
|
+
Cache::forget($key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (is_array($cached) && is_string($cached['access_token'] ?? null) && $cached['access_token'] !== $rejected && ($cached['expires_at'] ?? 0) > $this->wallTime() + 5) {
|
|
110
|
+
$this->expiresAt = (float) $cached['expires_at'];
|
|
111
|
+
|
|
112
|
+
return $cached['access_token'];
|
|
113
|
+
}
|
|
114
|
+
$refresh = is_array($cached) && is_string($cached['refresh_token'] ?? null) ? $cached['refresh_token'] : null;
|
|
115
|
+
$tokens = $this->tokenRequest($deadline, $timeout, $connectTimeout, $refresh);
|
|
116
|
+
$lifetime = is_numeric($tokens['expires_in'] ?? null) ? max(0, (int) $tokens['expires_in']) : 0;
|
|
117
|
+
$this->expiresAt = $lifetime > 0 ? $this->wallTime() + $lifetime : null;
|
|
118
|
+
if ($lifetime > 0) {
|
|
119
|
+
$tokens['expires_at'] = $this->expiresAt;
|
|
120
|
+
Cache::put($key, Crypt::encryptString(json_encode($tokens, JSON_THROW_ON_ERROR)), max($lifetime, min(86400, (int) ($tokens['refresh_expires_in'] ?? $lifetime))));
|
|
121
|
+
} else {
|
|
122
|
+
// An undocumented expiry must not create a long-lived cached token.
|
|
123
|
+
Cache::forget($key);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return $tokens['access_token'];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected function tokenRequest(float $deadline, int $timeout, int $connectTimeout, ?string $refresh = null): array
|
|
131
|
+
{
|
|
132
|
+
$body = ['grant_type' => $refresh ? 'refresh_token' : 'password', 'client_id' => $this->credentials['client_id'], 'client_secret' => $this->credentials['client_secret']];
|
|
133
|
+
$body += $refresh ? ['refresh_token' => $refresh] : ['username' => $this->credentials['username'], 'password' => $this->credentials['password']];
|
|
134
|
+
$this->reserveRequest($deadline);
|
|
135
|
+
try {
|
|
136
|
+
$response = Http::asForm()->acceptJson()->timeout($this->remaining($deadline, $timeout))
|
|
137
|
+
->connectTimeout($this->remaining($deadline, $connectTimeout))
|
|
138
|
+
->post($this->baseUrl . '/auth/realms/' . rawurlencode($this->credentials['realm_id']) . '/protocol/openid-connect/token', $body);
|
|
139
|
+
} catch (ConnectionException $e) {
|
|
140
|
+
throw new TelematicProviderException('Safee authentication timed out or could not connect.', previous: $e);
|
|
141
|
+
}
|
|
142
|
+
$this->checkThrottle($response);
|
|
143
|
+
// Expired/revoked refresh tokens may be exchanged for a new password grant once.
|
|
144
|
+
if ($refresh && in_array($response->status(), [400, 401], true)) {
|
|
145
|
+
return $this->tokenRequest($deadline, $timeout, $connectTimeout);
|
|
146
|
+
}
|
|
147
|
+
if ($response->failed()) {
|
|
148
|
+
throw new TelematicProviderException('Safee authentication failed with status ' . $response->status());
|
|
149
|
+
}
|
|
150
|
+
$tokens = $response->json();
|
|
151
|
+
if (!is_array($tokens) || !is_string($tokens['access_token'] ?? null) || $tokens['access_token'] === '') {
|
|
152
|
+
throw new TelematicProviderException('Safee authentication did not return an access token.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return array_intersect_key($tokens, array_flip(['access_token', 'refresh_token', 'expires_in', 'refresh_expires_in']));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
protected function reserveRequest(float $deadline): void
|
|
159
|
+
{
|
|
160
|
+
$key = 'safee:rate:' . $this->accountKey();
|
|
161
|
+
Cache::lock($key . ':lock', 5)->block($this->remaining($deadline, 2), function () use ($key, $deadline) {
|
|
162
|
+
$this->remaining($deadline, 1);
|
|
163
|
+
$now = $this->wallTime();
|
|
164
|
+
$until = (float) Cache::get($key . ':blocked', 0);
|
|
165
|
+
$requests = array_values(array_filter(Cache::get($key, []), fn ($at) => $at > $now - 1));
|
|
166
|
+
if ($until > $now || count($requests) >= 50) {
|
|
167
|
+
throw new TelematicRateLimitExceededException('Safee request budget exhausted.', ['retry_after' => max(1, (int) ceil(max($until, ($requests[0] ?? $now) + 1) - $now))]);
|
|
168
|
+
}
|
|
169
|
+
$requests[] = $now;
|
|
170
|
+
Cache::put($key, $requests, 2);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
protected function checkThrottle(Response $response): void
|
|
175
|
+
{
|
|
176
|
+
if ($response->status() !== 429) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
$header = $response->header('Retry-After');
|
|
180
|
+
$delay = is_numeric($header) ? (int) ceil((float) $header) : max(1, (strtotime($header ?: '') ?: time() + 1) - time());
|
|
181
|
+
$delay = max(1, min($delay, 3600));
|
|
182
|
+
$key = 'safee:rate:' . $this->accountKey();
|
|
183
|
+
Cache::lock($key . ':lock', 5)->block(1, function () use ($key, $delay) {
|
|
184
|
+
$until = max((float) Cache::get($key . ':blocked', 0), $this->wallTime() + $delay);
|
|
185
|
+
Cache::put($key . ':blocked', $until, (int) ceil($until - $this->wallTime()));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
throw new TelematicRateLimitExceededException('Safee rate limited the request.', ['retry_after' => $delay]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
protected function remaining(float $deadline, int $maximum): int
|
|
192
|
+
{
|
|
193
|
+
$remaining = (int) floor($deadline - $this->time());
|
|
194
|
+
if ($remaining < 1) {
|
|
195
|
+
throw new TelematicProviderException('Safee request time budget exhausted before another operation could start.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return min(max(1, $maximum), $remaining);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
protected function time(): float
|
|
202
|
+
{
|
|
203
|
+
return hrtime(true) / 1_000_000_000;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
protected function wallTime(): float
|
|
207
|
+
{
|
|
208
|
+
return microtime(true);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -152,6 +152,11 @@ class TelematicService
|
|
|
152
152
|
*/
|
|
153
153
|
public function discoverDevices(Telematic $telematic, array $options = []): string
|
|
154
154
|
{
|
|
155
|
+
$provider = $this->registry->resolve($telematic->provider);
|
|
156
|
+
if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
|
|
157
|
+
&& (Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
|
|
158
|
+
return $this->queueTelemetrySync($telematic, $options);
|
|
159
|
+
}
|
|
155
160
|
$jobId = (string) Str::uuid();
|
|
156
161
|
dispatch(new SyncTelematicDevicesJob($telematic, $options, $jobId));
|
|
157
162
|
|
|
@@ -167,6 +172,43 @@ class TelematicService
|
|
|
167
172
|
return $jobId;
|
|
168
173
|
}
|
|
169
174
|
|
|
175
|
+
/** Queue current telemetry through bounded, durable batches for opted-in providers. */
|
|
176
|
+
public function queueTelemetrySync(Telematic $telematic, array $options = [], ?string $jobId = null): string
|
|
177
|
+
{
|
|
178
|
+
$settings = Telemetry\Configuration::options($this->registry->resolve($telematic->provider));
|
|
179
|
+
if (!Telemetry\Inbox::enabled($telematic) || !($settings['polling_enabled'] ?? false)) {
|
|
180
|
+
throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is disabled for this connection.']]);
|
|
181
|
+
}
|
|
182
|
+
$jobId ??= (string) Str::uuid();
|
|
183
|
+
|
|
184
|
+
return \Illuminate\Support\Facades\DB::transaction(function () use ($telematic, $options, $settings, $jobId) {
|
|
185
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $telematic->uuid)->lockForUpdate()->firstOrFail();
|
|
186
|
+
if (!Telemetry\Inbox::enabled($connection)) {
|
|
187
|
+
throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is disabled for this connection.']]);
|
|
188
|
+
}
|
|
189
|
+
$job = (new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid, $jobId, $options))
|
|
190
|
+
->onQueue($settings['poll_queue'] ?? 'default');
|
|
191
|
+
$queued = Telemetry\Queue::dispatch($job);
|
|
192
|
+
if (!$queued) {
|
|
193
|
+
$existing = data_get($connection->meta, 'last_sync_job_id');
|
|
194
|
+
if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), Telemetry\Inbox::PENDING_REQUEST_RESULTS, true)) {
|
|
195
|
+
return $existing;
|
|
196
|
+
}
|
|
197
|
+
// A scheduled sweep holds this connection's poll lease. Record the request so
|
|
198
|
+
// the next sweep adopts and completes it, rather than rejecting the user.
|
|
199
|
+
}
|
|
200
|
+
$connection->status = 'synchronizing';
|
|
201
|
+
$connection->meta = array_merge($connection->meta ?? [], [
|
|
202
|
+
'last_sync_job_id' => $jobId, 'last_sync_run_uuid' => null, 'last_sync_run_job_id' => null,
|
|
203
|
+
'last_sync_started_at' => now()->toDateTimeString(), 'last_sync_result' => 'queued',
|
|
204
|
+
'last_sync_phase' => $queued ? 'queued' : 'waiting_for_sweep', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
|
|
205
|
+
]);
|
|
206
|
+
$connection->save();
|
|
207
|
+
|
|
208
|
+
return $jobId;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
170
212
|
/**
|
|
171
213
|
* Link a device to a telematic.
|
|
172
214
|
*/
|
|
@@ -511,7 +553,8 @@ class TelematicService
|
|
|
511
553
|
$this->setDeviceAttributeIfPresent($device, 'model', $payload['model'] ?? $payload['device_model'] ?? null);
|
|
512
554
|
$this->setDeviceAttributeIfPresent($device, 'provider', $payload['provider'] ?? $payload['device_provider'] ?? $telematic?->provider);
|
|
513
555
|
$this->setDeviceAttributeIfPresent($device, 'type', $payload['type'] ?? null);
|
|
514
|
-
|
|
556
|
+
// Partial messages must not replace an existing identity with the external ID fallback.
|
|
557
|
+
$this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? (filled($device->internal_id) ? null : $externalId));
|
|
515
558
|
$this->setDeviceAttributeIfPresent($device, 'imei', $payload['imei'] ?? null);
|
|
516
559
|
$this->setDeviceAttributeIfPresent($device, 'imsi', $payload['imsi'] ?? null);
|
|
517
560
|
$this->setDeviceAttributeIfPresent($device, 'serial_number', $payload['serial_number'] ?? null);
|
|
@@ -762,6 +805,7 @@ class TelematicService
|
|
|
762
805
|
|
|
763
806
|
protected function broadcastTelemetry(object $event, bool $afterCommit): void
|
|
764
807
|
{
|
|
808
|
+
$event = Telemetry\Configuration::withBroadcastQueue($event);
|
|
765
809
|
if ($afterCommit) {
|
|
766
810
|
\Illuminate\Support\Facades\DB::afterCommit(fn () => broadcast($event));
|
|
767
811
|
} else {
|
|
@@ -23,6 +23,22 @@ class Configuration
|
|
|
23
23
|
return array_replace(config('telematics.telemetry', []), $provider->telemetryOptions());
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Queue for connection-level jobs that predate durable polling (discovery and connection tests). */
|
|
27
|
+
public static function pollQueue(): string
|
|
28
|
+
{
|
|
29
|
+
return config('telematics.telemetry.poll_queue') ?: 'default';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Route a telemetry broadcast to the configured queue without changing other callers of the event. */
|
|
33
|
+
public static function withBroadcastQueue(object $event): object
|
|
34
|
+
{
|
|
35
|
+
if ($queue = config('telematics.telemetry.broadcast_queue')) {
|
|
36
|
+
$event->broadcastQueue = $queue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return $event;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
public static function forConnection(Telematic $connection): array
|
|
27
43
|
{
|
|
28
44
|
return self::options(self::provider($connection));
|
|
@@ -10,6 +10,9 @@ use Illuminate\Support\Str;
|
|
|
10
10
|
|
|
11
11
|
class Inbox
|
|
12
12
|
{
|
|
13
|
+
/** Manual request states that a sweep may still finalize. */
|
|
14
|
+
public const PENDING_REQUEST_RESULTS = ['queued', 'running', 'retrying'];
|
|
15
|
+
|
|
13
16
|
public function accept(Telematic $telematic, array $payload, string $source, ?string $run = null, ?string $receivedAt = null): string
|
|
14
17
|
{
|
|
15
18
|
$json = json_encode($payload, JSON_THROW_ON_ERROR);
|
|
@@ -51,10 +54,53 @@ class Inbox
|
|
|
51
54
|
if ((clone $items)->whereIn('status', ['pending', 'processing', 'retry'])->exists()) {
|
|
52
55
|
return;
|
|
53
56
|
}
|
|
54
|
-
$failed
|
|
57
|
+
$failed = (clone $items)->sum('failed');
|
|
58
|
+
$applied = (clone $items)->sum('applied');
|
|
59
|
+
$status = $run->status === 'incomplete' ? 'incomplete' : ($failed ? 'partial' : 'completed');
|
|
55
60
|
DB::table('telematic_sync_runs')->where('uuid', $id)->update([
|
|
56
|
-
'status' => $
|
|
57
|
-
'applied' =>
|
|
61
|
+
'status' => $status,
|
|
62
|
+
'applied' => $applied, 'failed' => $failed, 'updated_at' => now(),
|
|
63
|
+
]);
|
|
64
|
+
// An incomplete fetch can still have accepted batches, but the poll
|
|
65
|
+
// job owns its retry/failure lifecycle. Draining them must not end the
|
|
66
|
+
// manual request while its next fetch attempt is still queued.
|
|
67
|
+
if ($status === 'incomplete') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $run->telematic_uuid)->lockForUpdate()->first();
|
|
71
|
+
if (!$connection || !self::enabled($connection)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
$meta = $connection->meta ?? [];
|
|
75
|
+
$result = [
|
|
76
|
+
'last_sync_result' => $status === 'completed' ? 'success' : $status,
|
|
77
|
+
'last_sync_phase' => $status, 'last_sync_progress_at' => now()->toDateTimeString(),
|
|
78
|
+
'last_sync_fetched_total' => (int) $run->units, 'last_sync_page_count' => (int) $run->pages,
|
|
79
|
+
'last_sync_total' => (int) $applied, 'last_sync_linked_total' => (int) $applied,
|
|
80
|
+
'last_sync_failed_total' => (int) $failed,
|
|
81
|
+
'last_sync_error' => $status === 'completed' ? null : ($run->error ?? 'Some units could not be applied; inspect telemetry diagnostics.'),
|
|
82
|
+
$status === 'completed' ? 'last_sync_completed_at' : 'last_sync_failed_at' => now()->toDateTimeString(),
|
|
83
|
+
];
|
|
84
|
+
// A previous sweep may finish after another manual request was queued.
|
|
85
|
+
// Only the run explicitly associated with that request may finalize it.
|
|
86
|
+
if (data_get($meta, 'last_sync_run_uuid') === $id && data_get($meta, 'last_sync_run_job_id')
|
|
87
|
+
&& data_get($meta, 'last_sync_run_job_id') === data_get($meta, 'last_sync_job_id')) {
|
|
88
|
+
$connection->status = $status === 'completed' ? 'active' : 'error';
|
|
89
|
+
$connection->meta = array_merge($meta, $result);
|
|
90
|
+
$connection->save();
|
|
91
|
+
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// A completed scheduled sweep proves the connection is healthy, so it replaces a
|
|
95
|
+
// stale failure. It never finalizes or overrides a manual request still in flight,
|
|
96
|
+
// and a partial sweep leaves the last reported state alone.
|
|
97
|
+
if ($status !== 'completed' || in_array(data_get($meta, 'last_sync_result'), self::PENDING_REQUEST_RESULTS, true)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Written without model events: sweeps run every minute and the model logs every save.
|
|
101
|
+
DB::table('telematics')->where('uuid', $connection->uuid)->update([
|
|
102
|
+
'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status,
|
|
103
|
+
'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR),
|
|
58
104
|
]);
|
|
59
105
|
});
|
|
60
106
|
}
|
|
@@ -103,7 +103,7 @@ class Ingestor
|
|
|
103
103
|
}
|
|
104
104
|
if ($newer || $contactChanged) {
|
|
105
105
|
$snapshot = $device->fresh() ?? $device;
|
|
106
|
-
DB::afterCommit(fn () => broadcast(new DeviceTelemetryUpdated($snapshot)));
|
|
106
|
+
DB::afterCommit(fn () => broadcast(Configuration::withBroadcastQueue(new DeviceTelemetryUpdated($snapshot))));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
return ['device' => $device, 'event' => $stored, 'events' => $stored ? [$stored] : [], 'sensors' => $sensors, 'duplicate' => $duplicate, 'invalid_position' => !$valid];
|