@fleetbase/fleetops-engine 0.6.66 → 0.6.67
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 +15 -8
- 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/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +125 -0
- package/docs/TELEMATICS_QUEUES.md +179 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- 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 +127 -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/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 +43 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -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 +461 -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/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,41 @@ 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
|
+
if (!Telemetry\Queue::dispatch($job)) {
|
|
192
|
+
$existing = data_get($connection->meta, 'last_sync_job_id');
|
|
193
|
+
if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), ['queued', 'running', 'retrying'], true)) {
|
|
194
|
+
return $existing;
|
|
195
|
+
}
|
|
196
|
+
throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is already queued or running.']]);
|
|
197
|
+
}
|
|
198
|
+
$connection->status = 'synchronizing';
|
|
199
|
+
$connection->meta = array_merge($connection->meta ?? [], [
|
|
200
|
+
'last_sync_job_id' => $jobId, 'last_sync_run_uuid' => null, 'last_sync_run_job_id' => null,
|
|
201
|
+
'last_sync_started_at' => now()->toDateTimeString(), 'last_sync_result' => 'queued',
|
|
202
|
+
'last_sync_phase' => 'queued', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
|
|
203
|
+
]);
|
|
204
|
+
$connection->save();
|
|
205
|
+
|
|
206
|
+
return $jobId;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
/**
|
|
171
211
|
* Link a device to a telematic.
|
|
172
212
|
*/
|
|
@@ -511,7 +551,8 @@ class TelematicService
|
|
|
511
551
|
$this->setDeviceAttributeIfPresent($device, 'model', $payload['model'] ?? $payload['device_model'] ?? null);
|
|
512
552
|
$this->setDeviceAttributeIfPresent($device, 'provider', $payload['provider'] ?? $payload['device_provider'] ?? $telematic?->provider);
|
|
513
553
|
$this->setDeviceAttributeIfPresent($device, 'type', $payload['type'] ?? null);
|
|
514
|
-
|
|
554
|
+
// Partial messages must not replace an existing identity with the external ID fallback.
|
|
555
|
+
$this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? (filled($device->internal_id) ? null : $externalId));
|
|
515
556
|
$this->setDeviceAttributeIfPresent($device, 'imei', $payload['imei'] ?? null);
|
|
516
557
|
$this->setDeviceAttributeIfPresent($device, 'imsi', $payload['imsi'] ?? null);
|
|
517
558
|
$this->setDeviceAttributeIfPresent($device, 'serial_number', $payload['serial_number'] ?? null);
|
|
@@ -762,6 +803,7 @@ class TelematicService
|
|
|
762
803
|
|
|
763
804
|
protected function broadcastTelemetry(object $event, bool $afterCommit): void
|
|
764
805
|
{
|
|
806
|
+
$event = Telemetry\Configuration::withBroadcastQueue($event);
|
|
765
807
|
if ($afterCommit) {
|
|
766
808
|
\Illuminate\Support\Facades\DB::afterCommit(fn () => broadcast($event));
|
|
767
809
|
} 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));
|
|
@@ -51,11 +51,39 @@ class Inbox
|
|
|
51
51
|
if ((clone $items)->whereIn('status', ['pending', 'processing', 'retry'])->exists()) {
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
|
-
$failed
|
|
54
|
+
$failed = (clone $items)->sum('failed');
|
|
55
|
+
$applied = (clone $items)->sum('applied');
|
|
56
|
+
$status = $run->status === 'incomplete' ? 'incomplete' : ($failed ? 'partial' : 'completed');
|
|
55
57
|
DB::table('telematic_sync_runs')->where('uuid', $id)->update([
|
|
56
|
-
'status' => $
|
|
57
|
-
'applied' =>
|
|
58
|
+
'status' => $status,
|
|
59
|
+
'applied' => $applied, 'failed' => $failed, 'updated_at' => now(),
|
|
58
60
|
]);
|
|
61
|
+
// An incomplete fetch can still have accepted batches, but the poll
|
|
62
|
+
// job owns its retry/failure lifecycle. Draining them must not end the
|
|
63
|
+
// manual request while its next fetch attempt is still queued.
|
|
64
|
+
if ($status === 'incomplete') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// A previous sweep may finish after another manual request was queued.
|
|
68
|
+
// Only the run explicitly associated with that request may finalize it.
|
|
69
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $run->telematic_uuid)->lockForUpdate()->first();
|
|
70
|
+
if (!$connection || !self::enabled($connection)
|
|
71
|
+
|| data_get($connection->meta, 'last_sync_run_uuid') !== $id
|
|
72
|
+
|| !data_get($connection->meta, 'last_sync_run_job_id')
|
|
73
|
+
|| data_get($connection->meta, 'last_sync_run_job_id') !== data_get($connection->meta, 'last_sync_job_id')) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
$connection->status = $status === 'completed' ? 'active' : 'error';
|
|
77
|
+
$connection->meta = array_merge($connection->meta ?? [], [
|
|
78
|
+
'last_sync_result' => $status === 'completed' ? 'success' : $status,
|
|
79
|
+
'last_sync_phase' => $status, 'last_sync_progress_at' => now()->toDateTimeString(),
|
|
80
|
+
'last_sync_fetched_total' => (int) $run->units, 'last_sync_page_count' => (int) $run->pages,
|
|
81
|
+
'last_sync_total' => (int) $applied, 'last_sync_linked_total' => (int) $applied,
|
|
82
|
+
'last_sync_failed_total' => (int) $failed,
|
|
83
|
+
'last_sync_error' => $status === 'completed' ? null : ($run->error ?? 'Some units could not be applied; inspect telemetry diagnostics.'),
|
|
84
|
+
$status === 'completed' ? 'last_sync_completed_at' : 'last_sync_failed_at' => now()->toDateTimeString(),
|
|
85
|
+
]);
|
|
86
|
+
$connection->save();
|
|
59
87
|
});
|
|
60
88
|
}
|
|
61
89
|
}
|
|
@@ -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];
|