@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
@@ -23,12 +23,21 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
23
23
  use InteractsWithQueue;
24
24
  use Queueable;
25
25
 
26
- public int $tries = 5;
27
- public int $timeout = 120;
28
- public int $uniqueFor = 3600;
26
+ // Reservations released while earlier batches drain are not provider failures.
27
+ public int $tries = 0;
28
+ public int $maxExceptions = 5;
29
+ // Keep every attempt below Laravel's baseline Redis retry_after of 90 seconds.
30
+ public int $timeout = 80;
31
+ public int $uniqueFor = 3600;
32
+ public ?string $manualJobId = null;
33
+ public array $requestOptions = [];
34
+ public ?int $retryDeadline = null;
29
35
 
30
- public function __construct(public string $telematicUuid)
36
+ public function __construct(public string $telematicUuid, ?string $manualJobId = null, array $requestOptions = [])
31
37
  {
38
+ $this->manualJobId = $manualJobId;
39
+ $this->requestOptions = $requestOptions;
40
+ $this->retryDeadline = now()->addMinutes(15)->getTimestamp();
32
41
  }
33
42
 
34
43
  public function uniqueId(): string
@@ -38,11 +47,35 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
38
47
 
39
48
  public function backoff(): array
40
49
  {
41
- return [15, 60, 180, 300];
50
+ // A scheduled retry holds the uniqueness lease that suppresses minute ticks,
51
+ // so it must not wait longer than the polling interval after a transient failure.
52
+ return $this->manualJobId ? [15, 60, 180, 300] : [15, 30, 60, 60];
53
+ }
54
+
55
+ public function retryUntil(): \Illuminate\Support\Carbon
56
+ {
57
+ if ($this->retryDeadline === null) {
58
+ // Older serialized jobs have no deadline property. Anchor their first
59
+ // observed attempt once, rather than extending it on each redelivery.
60
+ $key = 'telemetry:poll:retry-deadline:' . ($this->job?->uuid() ?? $this->manualJobId ?? $this->telematicUuid);
61
+ $this->retryDeadline = Cache::remember($key, 86400, fn () => now()->addMinutes(15)->getTimestamp());
62
+ }
63
+
64
+ return \Illuminate\Support\Carbon::createFromTimestampUTC($this->retryDeadline);
42
65
  }
43
66
 
44
67
  public function handle(TelematicProviderRegistry $registry, Inbox $inbox): void
45
68
  {
69
+ if (now()->gte($this->retryUntil())) {
70
+ $error = new \Illuminate\Queue\MaxAttemptsExceededException('Telemetry polling retry deadline exceeded.');
71
+ if ($this->job) {
72
+ $this->fail($error);
73
+ } else {
74
+ $this->failed($error);
75
+ }
76
+
77
+ return;
78
+ }
46
79
  $telematic = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->first();
47
80
  if (!$telematic || !Inbox::enabled($telematic)) {
48
81
  return;
@@ -57,25 +90,48 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
57
90
  }
58
91
  // Coalesce ticks while a previous sweep is still being ingested.
59
92
  if (DB::table('telematic_deliveries')->where('telematic_uuid', $this->telematicUuid)->where('source', 'poll')->whereIn('status', ['pending', 'retry', 'processing'])->exists()) {
93
+ if ($this->manualJobId && $this->job) {
94
+ $this->manualProgress(['last_sync_result' => 'retrying', 'last_sync_phase' => 'waiting_for_ingestion']);
95
+ $this->release(15);
96
+ }
97
+
60
98
  return;
61
99
  }
62
- $lock = Cache::lock('telemetry:poll:' . $this->telematicUuid, 150);
100
+ $lock = Cache::lock('telemetry:poll:' . $this->telematicUuid, 85);
63
101
  if (!$lock->get()) {
102
+ if ($this->manualJobId && $this->job) {
103
+ $this->release(15);
104
+ }
105
+
64
106
  return;
65
107
  }
66
108
  $run = (string) Str::uuid();
67
- DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
68
109
  try {
110
+ $started = microtime(true);
111
+ DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
112
+ $this->adoptPendingRequest($run);
113
+ $this->manualProgress([
114
+ 'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $this->manualJobId,
115
+ 'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
116
+ 'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
117
+ 'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
118
+ ]);
69
119
  $provider->connect($telematic);
70
120
  $cursor = null;
71
121
  $cursors = [];
72
122
  $seen = [];
73
- $started = microtime(true);
74
123
  for ($page = 0; $page < ($options['max_pages'] ?? 100); $page++) {
75
- if (microtime(true) - $started > 90) {
76
- throw new \RuntimeException('Polling time budget exceeded; sweep incomplete.');
77
- }
78
- $response = $provider->fetchDevices(['limit' => $options['page_size'] ?? 1000, 'cursor' => $cursor, 'timeout' => 20, 'connect_timeout' => 5]);
124
+ $remaining = $this->remainingBudget($started);
125
+ // Large fleet responses can exceed 20 seconds. Allow a configured
126
+ // request timeout without extending the bounded sweep deadline.
127
+ $timeout = min(max(1, (int) ($options['request_timeout_seconds'] ?? 45)), $remaining);
128
+ $response = $provider->fetchDevices([
129
+ 'limit' => min(max(1, (int) ($this->requestOptions['limit'] ?? $options['page_size'] ?? 1000)), 1000),
130
+ 'cursor' => $cursor, 'filters' => $this->requestOptions['filters'] ?? [],
131
+ 'refresh_inventory' => $this->manualJobId !== null && $cursor === null,
132
+ 'timeout' => $timeout, 'connect_timeout' => min(5, $timeout),
133
+ ]);
134
+ $this->remainingBudget($started);
79
135
  $received = now()->utc();
80
136
  $units = $response['devices'];
81
137
  $ids = array_map(fn ($unit) => $provider->normalizeDevice($unit)['device_id'] ?? null, $units);
@@ -86,11 +142,15 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
86
142
  $seen[(string) $id] = true;
87
143
  }
88
144
  foreach (array_chunk($units, max(1, (int) ($options['batch_size'] ?? 100))) as $batch) {
145
+ $this->remainingBudget($started);
89
146
  $inbox->accept($telematic, $batch, 'poll', $run, $received->toDateTimeString());
90
147
  }
148
+ $this->remainingBudget($started);
91
149
  DB::table('telematic_sync_runs')->where('uuid', $run)->update(['pages' => $page + 1, 'units' => count($seen), 'updated_at' => now()]);
150
+ $this->manualProgress(['last_sync_fetched_total' => count($seen), 'last_sync_page_count' => $page + 1, 'last_sync_phase' => 'fetching_inventory']);
92
151
  if (!$response['has_more']) {
93
152
  DB::table('telematic_sync_runs')->where('uuid', $run)->update(['status' => 'ingesting', 'updated_at' => now()]);
153
+ $this->manualProgress(['last_sync_phase' => 'ingesting', 'last_sync_inventory_total' => count($seen)]);
94
154
  Inbox::finishRun($run);
95
155
 
96
156
  return;
@@ -105,6 +165,7 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
105
165
  throw new \RuntimeException('Maximum page count reached; sweep incomplete.');
106
166
  } catch (\Throwable $e) {
107
167
  DB::table('telematic_sync_runs')->where('uuid', $run)->update(['status' => 'incomplete', 'error' => $e instanceof TelematicRateLimitExceededException ? 'Rate limited; retry scheduled.' : 'Polling failed; retry scheduled. ' . class_basename($e), 'updated_at' => now()]);
168
+ $this->manualProgress(['last_sync_result' => 'retrying', 'last_sync_phase' => 'retrying', 'last_sync_error' => 'Polling failed; retry scheduled. ' . class_basename($e)]);
108
169
  if ($e instanceof TelematicRateLimitExceededException && $this->job) {
109
170
  $this->release($e->context()['retry_after'] ?? 60);
110
171
 
@@ -115,4 +176,94 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
115
176
  $lock->release();
116
177
  }
117
178
  }
179
+
180
+ private function remainingBudget(float $started): int
181
+ {
182
+ $remaining = (int) floor(60 - (microtime(true) - $started));
183
+ if ($remaining < 1) {
184
+ throw new \RuntimeException('Polling time budget exceeded; sweep incomplete.');
185
+ }
186
+
187
+ return $remaining;
188
+ }
189
+
190
+ /**
191
+ * Bind a manual request that has no active run to this scheduled sweep.
192
+ *
193
+ * Every poll job for a connection shares one unique lease, so while a scheduled sweep
194
+ * runs, the request's own job is either not queued (the request was recorded while
195
+ * the lease was held) or no longer exists (lost before or after an interrupted run).
196
+ */
197
+ private function adoptPendingRequest(string $run): void
198
+ {
199
+ if ($this->manualJobId) {
200
+ return;
201
+ }
202
+ DB::transaction(function () use ($run) {
203
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
204
+ $meta = $connection?->meta ?? [];
205
+ $request = data_get($meta, 'last_sync_job_id');
206
+ if (!$request || !in_array(data_get($meta, 'last_sync_result'), Inbox::PENDING_REQUEST_RESULTS, true)) {
207
+ return;
208
+ }
209
+ $bound = data_get($meta, 'last_sync_run_uuid');
210
+ if ($bound && DB::table('telematic_sync_runs')->where('uuid', $bound)->whereIn('status', ['fetching', 'ingesting'])->exists()) {
211
+ return;
212
+ }
213
+ $connection->status = 'synchronizing';
214
+ $connection->meta = array_merge($meta, [
215
+ 'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $request,
216
+ 'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
217
+ 'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
218
+ 'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
219
+ 'last_sync_progress_at' => now()->toDateTimeString(),
220
+ ]);
221
+ $connection->save();
222
+ });
223
+ }
224
+
225
+ private function manualProgress(array $attributes): void
226
+ {
227
+ if (!$this->manualJobId) {
228
+ return;
229
+ }
230
+ DB::transaction(function () use ($attributes) {
231
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
232
+ if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
233
+ return;
234
+ }
235
+ $connection->status = 'synchronizing';
236
+ $connection->meta = array_merge($connection->meta ?? [], $attributes, ['last_sync_progress_at' => now()->toDateTimeString()]);
237
+ $connection->save();
238
+ });
239
+ }
240
+
241
+ public function failed(\Throwable $error): void
242
+ {
243
+ if (!$this->manualJobId) {
244
+ return;
245
+ }
246
+ $run = null;
247
+ DB::transaction(function () use ($error, &$run) {
248
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
249
+ if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
250
+ return;
251
+ }
252
+ if (data_get($connection->meta, 'last_sync_run_job_id') === $this->manualJobId) {
253
+ $run = data_get($connection->meta, 'last_sync_run_uuid');
254
+ }
255
+ $connection->status = 'error';
256
+ $connection->meta = array_merge($connection->meta ?? [], [
257
+ 'last_sync_result' => 'failed', 'last_sync_phase' => 'failed',
258
+ 'last_sync_error' => 'Polling retries exhausted. ' . class_basename($error),
259
+ 'last_sync_error_type' => class_basename($error), 'last_sync_failed_at' => now()->toDateTimeString(),
260
+ ]);
261
+ $connection->save();
262
+ });
263
+ // Do not lock run rows while holding the connection lock: finishRun takes
264
+ // those locks in the opposite order when the final delivery completes.
265
+ if ($run) {
266
+ DB::table('telematic_sync_runs')->where('uuid', $run)->where('status', 'fetching')->update(['status' => 'incomplete', 'error' => 'Polling attempt interrupted.', 'updated_at' => now()]);
267
+ }
268
+ }
118
269
  }
@@ -38,7 +38,8 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
38
38
 
39
39
  public function handle(Ingestor $ingestor, TelematicService $service): void
40
40
  {
41
- $lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
41
+ $started = microtime(true);
42
+ $lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
42
43
  if (!$lock->get()) {
43
44
  return;
44
45
  }
@@ -66,21 +67,48 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
66
67
  return;
67
68
  }
68
69
  $this->update(['status' => 'processing', 'attempts' => $row->attempts + 1, 'available_at' => now()->addSeconds(120)]);
70
+ $failed = [];
71
+ $failureTypes = [];
69
72
  try {
70
- $units = $provider->telemetryUnits(json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR));
73
+ $payload = json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR);
74
+ if ($row->retry_payload && ($payload['checkpoint_version'] ?? null) === 1) {
75
+ // This envelope is internal and encrypted; the original webhook
76
+ // contract was already validated before creating the checkpoint.
77
+ $units = $payload['remaining'] ?? null;
78
+ $failed = $payload['failed'] ?? null;
79
+ $failureTypes = $payload['failure_types'] ?? null;
80
+ foreach ([$units, $failed, $failureTypes] as $items) {
81
+ if (!is_array($items) || !array_is_list($items)) {
82
+ throw new \InvalidArgumentException('Invalid delivery checkpoint.');
83
+ }
84
+ }
85
+ } elseif ($row->source === 'poll') {
86
+ // Polling persists the provider's inventory records, including units
87
+ // that have never reported a position. Validate this internal envelope
88
+ // here and let ingestion report invalid positions per unit below.
89
+ if (!is_array($payload) || !array_is_list($payload) || $payload === []) {
90
+ throw new \InvalidArgumentException('Expected a non-empty polling unit list.');
91
+ }
92
+ $units = $payload;
93
+ } else {
94
+ // External deliveries must still pass the provider's position contract.
95
+ $units = $provider->telemetryUnits($payload);
96
+ }
71
97
  } catch (\Throwable) {
72
98
  $this->update(['status' => 'quarantined', 'failed' => 1, 'error' => 'Unsupported or unreadable position payload; inspect and replay after adapter correction.']);
73
99
  Inbox::finishRun($row->run_uuid);
74
100
 
75
101
  return;
76
102
  }
77
- $failed = [];
78
- $failureTypes = [];
79
103
  $applied = 0;
80
104
  $invalid = (int) $row->invalid_count;
81
- $sourceDelay = null;
105
+ // Polled inventory legitimately includes units without a valid fix (for example,
106
+ // never reported). They stay counted, but only pushed samples are failures to replay.
107
+ $pushed = $row->source !== 'poll';
108
+ $sourceDelay = $row->source_delay_seconds;
82
109
  $queueDelay = max(0, now()->timestamp - \Illuminate\Support\Carbon::parse($row->received_at, 'UTC')->timestamp);
83
- foreach ($units as $unit) {
110
+ $checkpointAt = $started;
111
+ foreach ($units as $index => $unit) {
84
112
  try {
85
113
  $result = $ingestor->ingest($telematic, $provider, $unit, $service, $row->received_at, $row->source);
86
114
  $sourceDelay = max($sourceDelay ?? 0, data_get($result['device']->meta, 'telemetry.source_delay_seconds', 0));
@@ -93,19 +121,45 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
93
121
  $failed[] = $unit;
94
122
  $failureTypes[] = class_basename($e);
95
123
  }
124
+ $elapsed = microtime(true);
125
+ $yield = $elapsed - $started >= 40 && $index + 1 < count($units);
126
+ if ($yield || ($index + 1) % 10 === 0 || $elapsed - $checkpointAt >= 2) {
127
+ // Persist progress independently of the worker reservation. A
128
+ // restart resumes the remaining tail rather than replaying a
129
+ // growing prefix until the delivery exhausts its retry budget.
130
+ $this->update([
131
+ 'retry_payload' => Crypt::encryptString(json_encode([
132
+ 'checkpoint_version' => 1, 'remaining' => array_slice($units, $index + 1),
133
+ 'failed' => $failed, 'failure_types' => array_values(array_unique($failureTypes)),
134
+ ], JSON_THROW_ON_ERROR)),
135
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
136
+ 'queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay,
137
+ 'status' => $yield ? 'retry' : 'processing',
138
+ // Cooperative continuation is not a failed attempt. Actual
139
+ // crashes keep the increment persisted at the start above.
140
+ 'attempts' => $yield ? $row->attempts : $row->attempts + 1,
141
+ 'available_at' => $yield ? now() : now()->addSeconds(120),
142
+ ]);
143
+ $checkpointAt = $elapsed;
144
+ }
145
+ if ($yield) {
146
+ // The normal inbox drain queues the continuation after this
147
+ // job releases its uniqueness lease; it uses the same queue.
148
+ return;
149
+ }
96
150
  }
97
151
  $this->update(['queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay]);
98
152
  if ($failed && $row->attempts < 4) {
99
153
  $this->update([
100
154
  'status' => 'retry', 'retry_payload' => Crypt::encryptString(json_encode($failed, JSON_THROW_ON_ERROR)),
101
- 'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
155
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
102
156
  'error' => 'Some units failed ingestion; retry scheduled. ' . implode(', ', array_unique($failureTypes)), 'available_at' => now()->addSeconds(15 * (2 ** $row->attempts)),
103
157
  ]);
104
158
  } else {
105
159
  $this->update([
106
- 'status' => ($failed || $invalid) ? 'quarantined' : 'processed',
107
- 'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
108
- 'error' => ($failed || $invalid) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
160
+ 'status' => ($failed || ($pushed ? $invalid : 0)) ? 'quarantined' : 'processed',
161
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
162
+ 'error' => ($failed || ($pushed ? $invalid : 0)) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
109
163
  'processed_at' => now(),
110
164
  ]);
111
165
  }
@@ -40,6 +40,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
40
40
  $this->telematic = $telematic;
41
41
  $this->options = $options;
42
42
  $this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString();
43
+ $this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
43
44
  }
44
45
 
45
46
  /**
@@ -47,6 +48,24 @@ class SyncTelematicDevicesJob implements ShouldQueue
47
48
  */
48
49
  public function handle(TelematicProviderRegistry $registry, TelematicService $service): void
49
50
  {
51
+ // Jobs queued before a provider opts into batching must use the same bounded
52
+ // path as new manual requests, without acquiring the legacy hour-long lock.
53
+ $provider = $registry->resolve($this->telematic->provider);
54
+ if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
55
+ && (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
56
+ $this->telematic->refresh();
57
+ if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($this->telematic)) {
58
+ try {
59
+ $service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
60
+ } catch (\Illuminate\Validation\ValidationException) {
61
+ // Polling was paused after this job was queued. Failing here would mark the
62
+ // connection as errored and detach any active request's progress tracking.
63
+ Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
64
+ }
65
+ }
66
+
67
+ return;
68
+ }
50
69
  $correlationId = \Illuminate\Support\Str::uuid()->toString();
51
70
  $lockKey = 'fleetops:sync-telematic-devices:' . $this->telematic->uuid;
52
71
  $lock = Cache::lock($lockKey, $this->timeout + 60);
@@ -96,12 +115,54 @@ class SyncTelematicDevicesJob implements ShouldQueue
96
115
  $totalEnrichment = 0;
97
116
  $totalEnrichmentCompleted = 0;
98
117
  $totalEnrichmentFailures = 0;
118
+ $itemsSinceCheckpoint = 0;
119
+ $lastCheckpointAt = now();
120
+
121
+ $checkpoint = function (string $phase, bool $force = false) use (
122
+ &$itemsSinceCheckpoint, &$lastCheckpointAt, &$providerSyncMeta, &$totalFetched,
123
+ &$totalLinked, &$totalLinkAttempts, &$totalEvents, &$totalSensors, &$totalSkipped,
124
+ &$pageCount, &$lastProviderAllCount, &$lastProviderFiltersCount,
125
+ &$inventoryFetched, &$inventoryLinked, &$inventorySkipped,
126
+ &$totalEnrichment, &$totalEnrichmentCompleted, &$totalEnrichmentFailures
127
+ ): void {
128
+ if (!$force && $itemsSinceCheckpoint < 25 && $lastCheckpointAt->diffInSeconds(now(), true) < 5) {
129
+ return;
130
+ }
131
+ $this->telematic->status = 'synchronizing';
132
+ $this->telematic->meta = array_merge($this->telematic->meta ?? [], $providerSyncMeta, [
133
+ 'last_sync_job_id' => $this->jobId,
134
+ 'last_sync_result' => 'running',
135
+ 'last_sync_phase' => $phase,
136
+ 'last_sync_progress_at' => now()->toDateTimeString(),
137
+ 'last_sync_fetched_total' => $totalFetched,
138
+ 'last_sync_linked_total' => $totalLinked,
139
+ 'last_sync_link_attempts_total' => $totalLinkAttempts,
140
+ 'last_sync_events_total' => $totalEvents,
141
+ 'last_sync_sensors_total' => $totalSensors,
142
+ 'last_sync_skipped_total' => $totalSkipped,
143
+ 'last_sync_page_count' => $pageCount,
144
+ 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
145
+ 'last_sync_provider_all_count' => $lastProviderAllCount,
146
+ 'last_sync_provider_filters_count' => $lastProviderFiltersCount,
147
+ 'last_sync_inventory_total' => $inventoryFetched,
148
+ 'last_sync_inventory_linked_total' => $inventoryLinked,
149
+ 'last_sync_inventory_skipped_total' => $inventorySkipped,
150
+ 'last_sync_enrichment_total' => $totalEnrichment,
151
+ 'last_sync_enrichment_completed' => $totalEnrichmentCompleted,
152
+ 'last_sync_enrichment_failures' => $totalEnrichmentFailures,
153
+ ]);
154
+ $this->telematic->save();
155
+ $itemsSinceCheckpoint = 0;
156
+ $lastCheckpointAt = now();
157
+ };
99
158
 
100
159
  try {
160
+ $checkpoint('connecting', true);
101
161
  $provider = $registry->resolve($this->telematic->provider);
102
162
  $provider->connect($this->telematic);
103
163
 
104
164
  do {
165
+ $checkpoint('fetching_inventory', true);
105
166
  $response = $provider->fetchDevices([
106
167
  'limit' => $this->options['limit'] ?? null,
107
168
  'cursor' => $cursor,
@@ -137,6 +198,9 @@ class SyncTelematicDevicesJob implements ShouldQueue
137
198
  'has_more' => $response['has_more'] ?? false,
138
199
  ]);
139
200
 
201
+ // Persist retrieval before any potentially slow per-device database work.
202
+ $inventoryFetched = $totalFetched;
203
+ $checkpoint('ingesting_inventory', true);
140
204
  foreach ($devices as $devicePayload) {
141
205
  $normalizedDevice = $provider->normalizeDevice($devicePayload);
142
206
  try {
@@ -162,7 +226,12 @@ class SyncTelematicDevicesJob implements ShouldQueue
162
226
  'imei' => $devicePayload['imei'] ?? null,
163
227
  ]);
164
228
  }
229
+ $inventoryLinked = $totalLinked;
230
+ $inventorySkipped = $totalSkipped;
231
+ $itemsSinceCheckpoint++;
232
+ $checkpoint('ingesting_inventory');
165
233
  }
234
+ $checkpoint('ingesting_inventory', true);
166
235
 
167
236
  $cursor = $response['next_cursor'] ?? null;
168
237
 
@@ -221,6 +290,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
221
290
  'device_count' => count($inventoryPayloads),
222
291
  ]);
223
292
 
293
+ $checkpoint('fetching_enrichment', true);
224
294
  $enrichmentResponse = $provider->fetchDeviceTelemetrySnapshots($inventoryPayloads, [
225
295
  'limit' => $this->options['limit'] ?? null,
226
296
  'filters' => $this->options['filters'] ?? [],
@@ -229,6 +299,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
229
299
  $enrichedDevices = $enrichmentResponse['devices'] ?? [];
230
300
  $totalEnrichment += count($enrichedDevices);
231
301
 
302
+ $checkpoint('ingesting_enrichment', true);
232
303
  foreach ($enrichedDevices as $devicePayload) {
233
304
  $normalizedDevice = $provider->normalizeDevice($devicePayload);
234
305
  try {
@@ -256,7 +327,10 @@ class SyncTelematicDevicesJob implements ShouldQueue
256
327
  'imei' => $devicePayload['imei'] ?? null,
257
328
  ]);
258
329
  }
330
+ $itemsSinceCheckpoint++;
331
+ $checkpoint('ingesting_enrichment');
259
332
  }
333
+ $checkpoint('ingesting_enrichment', true);
260
334
 
261
335
  Log::info($this->telematic->provider === 'safee' ? 'Safee telemetry enrichment completed' : 'Telematics telemetry enrichment completed', [
262
336
  'correlation_id' => $correlationId,
@@ -289,6 +363,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
289
363
  'last_sync_job_id' => $this->jobId,
290
364
  'last_sync_completed_at' => now()->toDateTimeString(),
291
365
  'last_sync_result' => 'success',
366
+ 'last_sync_phase' => 'completed',
367
+ 'last_sync_progress_at' => now()->toDateTimeString(),
292
368
  'last_sync_total' => $totalLinked,
293
369
  'last_sync_fetched_total' => $totalFetched,
294
370
  'last_sync_linked_total' => $totalLinked,
@@ -310,7 +386,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
310
386
  'last_sync_error_context' => null,
311
387
  ]);
312
388
  $this->telematic->save();
313
- } catch (\Exception $e) {
389
+ } catch (\Throwable $e) {
314
390
  $failureContext = method_exists($e, 'context') ? $e->context() : [];
315
391
  $failureMessage = $this->safeSyncErrorMessage($e);
316
392
 
@@ -37,6 +37,7 @@ class TestTelematicConnectionJob implements ShouldQueue
37
37
  {
38
38
  $this->telematic = $telematic;
39
39
  $this->jobId = $jobId ?? Str::uuid()->toString();
40
+ $this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
40
41
  }
41
42
 
42
43
  /**
@@ -149,8 +149,10 @@ class FleetOpsServiceProvider extends CoreServiceProvider
149
149
  $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
150
150
  $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
151
151
  $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
152
- $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping()->storeOutputInDb();
153
- $schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping();
152
+ // These commands dispatch bounded jobs. A scheduler restart must not
153
+ // leave the default 24-hour overlap lease blocking telemetry recovery.
154
+ $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb();
155
+ $schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping(2);
154
156
  });
155
157
  $this->registerNotifications();
156
158
  $this->registerAiCapabilities();
@@ -196,6 +198,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
196
198
  $this->mergeConfigFrom(__DIR__ . '/../../config/fleetops.php', 'fleetops');
197
199
  $this->mergeConfigFrom(__DIR__ . '/../../config/telematics.php', 'telematics');
198
200
  $this->mergeConfigFrom(__DIR__ . '/../../config/afaqy.php', 'telematics.afaqy');
201
+ $this->mergeConfigFrom(__DIR__ . '/../../config/safee.php', 'telematics.safee');
199
202
  $this->mergeConfigFrom(__DIR__ . '/../../config/telemetry.php', 'telematics.telemetry');
200
203
  $this->mergeConfigFrom(__DIR__ . '/../../config/fuel-providers.php', 'fuel-providers');
201
204
  $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'api');