@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,230 @@
1
+ # Dedicated telematics queue workers
2
+
3
+ By default, all telematics work runs on the `default` queue alongside every other
4
+ background job. On an instance with a large fleet, that work can crowd out
5
+ notifications, order events, and other jobs. This guide moves telematics work to
6
+ its own queues so dedicated worker containers process it while the existing
7
+ worker keeps serving `default`.
8
+
9
+ This is an **opt-in, per-instance** setup. With no environment variables set,
10
+ nothing changes: every job and broadcast stays on the same queue as before.
11
+
12
+ ## What each setting routes
13
+
14
+ | Setting | Default | Work routed |
15
+ | --- | --- | --- |
16
+ | `TELEMATICS_POLL_QUEUE` | `default` | Scheduled and manual polls (`PollTelematicTelemetry`), legacy device discovery (`SyncTelematicDevicesJob`), and queued connection tests (`TestTelematicConnectionJob`). |
17
+ | `TELEMATICS_INGESTION_QUEUE` | `default` | Applying polled or pushed positions (`ProcessTelematicDelivery`), including jobs re-queued by `fleetops:drain-telematic-inbox` and manual replays. |
18
+ | `TELEMATICS_BROADCAST_QUEUE` | unset | Live-map broadcasts created by telematics ingestion: `DeviceTelemetryUpdated`, and `VehicleLocationChanged` / `TrailerLocationChanged` when a device is attached. When unset, broadcasts use the queue connection's default queue. |
19
+
20
+ `TELEMATICS_BROADCAST_QUEUE` affects only broadcasts that telematics ingestion
21
+ creates. Vehicle and trailer location broadcasts from the driver app and public API
22
+ remain on the default queue.
23
+
24
+ Broadcasts can outnumber sync jobs. Each ingested unit with a newer position or
25
+ contact time queues one device broadcast, plus one vehicle or trailer broadcast
26
+ when attached. A fleet that reports every minute can queue hundreds of broadcast
27
+ jobs per minute, so route broadcasts as well as polling and ingestion.
28
+
29
+ ## Before you start: check which sync path the provider uses
30
+
31
+ Separate workers keep telematics from blocking other jobs. They do not make a slow
32
+ sync faster, and they do not fix an unsafe job.
33
+
34
+ - **Bounded polling path** (Safee scheduled and manual syncs; AFAQY scheduled polls
35
+ with `AFAQY_POLLING_ENABLED=true`): poll attempts are limited to an 80-second
36
+ hard timeout and a 60-second sweep budget, and ingestion yields every 40 seconds. These jobs are safe for the stock Redis
37
+ `retry_after` of 90 seconds and for multiple concurrent workers.
38
+ - **Legacy discovery path** (other providers, AFAQY manual syncs, and AFAQY scheduled
39
+ syncs while polling is disabled):
40
+ `SyncTelematicDevicesJob` allows a 3,600-second run while Redis `retry_after`
41
+ is 90 seconds. A run lasting over 90 seconds can be handed to another worker and
42
+ end with `SyncTelematicDevicesJob has been attempted too many times`. Adding
43
+ workers increases the likelihood that another worker picks it up. Where possible,
44
+ move the provider to the bounded polling path, for example by setting
45
+ `AFAQY_POLLING_ENABLED=true` for AFAQY connections.
46
+
47
+ ## Setup
48
+
49
+ Start the workers **before** setting the queue variables. Jobs sent to a queue with
50
+ no worker wait indefinitely.
51
+
52
+ ### 1. Add telematics workers
53
+
54
+ Each telematics worker must be an exact copy of the existing `queue` worker: the
55
+ same image and tag, the same `env_file`, `environment`, volumes and `depends_on`,
56
+ including every Compose override file that changes `queue`. Change only the command.
57
+ Workers running a different build, or missing `APP_KEY`, fail every telematics job.
58
+
59
+ Docker Compose example, assuming `queue` is defined in `docker-compose.yml` and any
60
+ image or environment overrides for it live in `docker-compose.override.yml`:
61
+
62
+ ```yaml
63
+ # docker-compose.yml: copy the full `queue` service definition, then change the command.
64
+ services:
65
+ telematics-queue:
66
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
67
+ command: ["php", "artisan", "queue:work", "--queue=telematics", "--sleep=1", "--max-time=3600"]
68
+ restart: unless-stopped
69
+ deploy:
70
+ replicas: 2
71
+
72
+ telematics-broadcast-queue:
73
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
74
+ command: ["php", "artisan", "queue:work", "--queue=telematics-broadcasts", "--sleep=1", "--max-time=3600"]
75
+ restart: unless-stopped
76
+ ```
77
+
78
+ ```yaml
79
+ # docker-compose.override.yml: repeat every override that applies to `queue`.
80
+ services:
81
+ telematics-queue:
82
+ image: "fleetbase-api:latest" # the same image as `queue`
83
+ telematics-broadcast-queue:
84
+ image: "fleetbase-api:latest"
85
+ ```
86
+
87
+ Notes:
88
+
89
+ - **Use the same image as `queue`, not a separate `fleetbase/fleetbase-api:latest`.**
90
+ Docker does not download a newer `latest` when a copy already exists locally, so a
91
+ new service can start from an old cached image. If you do use a registry image,
92
+ pin a version tag and pull it for every API container together.
93
+ - **Every API container needs the same `APP_KEY`**: application, scheduler, the
94
+ default worker and the telematics workers. Provider credentials, token caches and
95
+ telematics deliveries are encrypted with it. If only the application service
96
+ receives it (for example through a mounted `.env` or an override), put it in a
97
+ shared `env_file` instead. Never generate a new key for a worker.
98
+ - **Check the merged configuration, including when using `extends`.** Run
99
+ `docker compose config` and compare the resolved `image`, `env_file` and
100
+ `environment` of `queue` and each telematics service before deploying.
101
+ - The `--queue` names must match the variables in step 2 exactly. A worker listening
102
+ on `telematics-broadcast` while broadcasts go to `telematics-broadcasts` receives
103
+ nothing.
104
+ - Leave the existing `queue` service unchanged. `queue:work` without `--queue`
105
+ processes the connection's default queue (`REDIS_QUEUE`, normally `default`).
106
+ - Leave `--timeout` at its default (60 seconds) or set any value below the Redis
107
+ connection's `retry_after` (90 seconds). Polls (80 seconds) and ingestion
108
+ (60 seconds) define their own timeouts, which override the worker setting.
109
+ Broadcast jobs use the worker value.
110
+ - `--max-time` recycles long-running workers so they pick up deployments and release
111
+ memory. `restart: unless-stopped` starts them again.
112
+ - On Kubernetes, ECS, or another platform, create equivalent deployments with the
113
+ same image, environment, and commands.
114
+
115
+ ### 2. Set the queue variables for this instance only
116
+
117
+ Add these to the environment of the application, scheduler, **and every queue
118
+ worker** container. The scheduler dispatches polls and the application dispatches
119
+ manual syncs, so every process must agree on the queue names:
120
+
121
+ ```dotenv
122
+ TELEMATICS_POLL_QUEUE=telematics
123
+ TELEMATICS_INGESTION_QUEUE=telematics
124
+ TELEMATICS_BROADCAST_QUEUE=telematics-broadcasts
125
+ ```
126
+
127
+ To use one worker type for all telematics work, set all three to `telematics` and
128
+ omit the broadcast worker.
129
+
130
+ AFAQY polls and ingestion also read `AFAQY_POLL_QUEUE` and `AFAQY_INGESTION_QUEUE`.
131
+ Those take precedence over the `TELEMATICS_*` values when set, so remove or align
132
+ them. Legacy discovery and connection-test jobs always use `TELEMATICS_POLL_QUEUE`.
133
+
134
+ ### 3. Deploy the change
135
+
136
+ 1. Deploy the new worker containers and confirm they are running.
137
+ 2. Apply the environment variables to the application, scheduler, and workers.
138
+ 3. If configuration is cached, rebuild it with `php artisan config:cache` in each
139
+ container, or through the normal image/deploy process.
140
+ 4. Restart long-running processes so they read the new configuration:
141
+ `php artisan queue:restart`, then reload Octane (`php artisan octane:reload`)
142
+ if the application runs under Octane.
143
+
144
+ ### 4. Existing backlog on `default`
145
+
146
+ Jobs already on `default` stay there, and the existing worker must process them.
147
+ The new setting affects only newly dispatched jobs.
148
+
149
+ - Do not run `php artisan queue:clear` on `default`. It deletes every pending job,
150
+ including non-telematics work.
151
+ - For opted-in batch providers such as Safee, a queued `SyncTelematicDevicesJob`
152
+ hands off to bounded polling and exits within seconds.
153
+ - Queued legacy discovery jobs for other providers can each occupy the default
154
+ worker for a long time. If the backlog does not drain, inspect those specific
155
+ jobs rather than clearing the entire queue.
156
+
157
+ ## Sizing
158
+
159
+ Start with **2 telematics workers and 1 broadcast worker**, then adjust based on
160
+ queue depth.
161
+
162
+ Current work per connection:
163
+
164
+ - One poll per connection per minute, coalesced while its previous sweep is still
165
+ ingesting. Safee requests at most 1,000 vehicle IDs per `last-state` call.
166
+ - Up to `ceil(units / TELEMATICS_BATCH_SIZE)` ingestion jobs per sweep (default
167
+ 100 units per job). A fleet of about 470 reporting devices is about 5 ingestion
168
+ jobs per minute.
169
+ - Workers can process different connections, deliveries, and devices concurrently.
170
+ Per-connection and per-device locks prevent duplicate polling or out-of-order
171
+ position updates.
172
+
173
+ For reference, a local single-worker run for 93 Safee units applied each delivery in
174
+ about 5-8 seconds (about 55-85 ms per unit), including database work. This was a
175
+ development machine, not production. Measure production queue depth rather than
176
+ extrapolating this value.
177
+
178
+ Additional workers do not increase provider limits. Safee connections share a limit
179
+ of 50 requests per second per account across all workers.
180
+
181
+ ## Verification
182
+
183
+ First confirm every API container runs the same build and application key. Each line
184
+ must show the same key fingerprint (a hash, never the key) and Fleet-Ops version:
185
+
186
+ ```bash
187
+ for c in $(docker ps --format '{{.Names}}' | grep -E 'application|scheduler|queue'); do echo "== $c $(docker inspect --format '{{.Config.Image}}' $c)"; docker exec $c php artisan tinker --execute="echo config('app.key') ? substr(hash('sha256', config('app.key')), 0, 12) : 'MISSING', ' ', json_decode(file_get_contents('vendor/fleetbase/fleetops-api/composer.json'))->version, PHP_EOL;"; done
188
+ ```
189
+
190
+ Confirm Laravel loaded the queue names in the application and a telematics worker:
191
+
192
+ ```bash
193
+ php artisan config:show telematics.telemetry
194
+ ```
195
+
196
+ Queue depth should stay close to zero between minute ticks:
197
+
198
+ ```bash
199
+ php artisan queue:monitor redis:default,redis:telematics,redis:telematics-broadcasts --max=500
200
+ ```
201
+
202
+ Also verify:
203
+
204
+ - `telematic_sync_runs` for the connection shows a new `completed` run about once
205
+ per minute. `incomplete` means the fetch failed and will retry.
206
+ - The worker logs show `PollTelematicTelemetry` and `ProcessTelematicDelivery` only
207
+ on the telematics workers, and broadcast jobs only on the broadcast worker.
208
+ - Other jobs on `default`, such as notifications and order events, start promptly
209
+ again.
210
+ - The connection's status and "Last sync" update after each completed scheduled
211
+ sweep. Clicking "Sync Devices" while a sweep is queued or running records the
212
+ request, and the next sweep completes it.
213
+
214
+ ## Troubleshooting
215
+
216
+ | Symptom | Cause |
217
+ | --- | --- |
218
+ | Telematics worker logs show `MissingAppKeyException` | The worker has no `APP_KEY`. Give every API container the same key and recreate the workers. |
219
+ | A worker reports a different Fleet-Ops version | The worker uses a different or stale cached image. Use the `queue` service's image. |
220
+ | Broadcast jobs are never processed | The broadcast worker's `--queue` name does not match `TELEMATICS_BROADCAST_QUEUE`. |
221
+ | Scheduled sync commands report `Queued 0 telematics sync job(s)` for an enabled connection | A poll job failed on a worker that could not load it, leaving its one-hour uniqueness lock. It expires on its own; after fixing the workers you can release it with `Cache::lock('laravel_unique_job:Fleetbase\\FleetOps\\Jobs\\PollTelematicTelemetry' . $telematicUuid)->forceRelease()`. |
222
+
223
+ ## Rollback
224
+
225
+ 1. Remove the three variables from the application, scheduler, and workers, rebuild
226
+ cached configuration, and restart processes as in setup step 3.
227
+ 2. Keep the telematics workers running until `telematics` and
228
+ `telematics-broadcasts` are empty. Jobs remaining on those queues are not moved
229
+ back to `default`.
230
+ 3. Remove the worker containers.
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.66",
3
+ "version": "0.6.68",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.66",
3
+ "version": "0.6.68",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -55,9 +55,62 @@ return [
55
55
  'auth_type' => 'ws_sk_header',
56
56
  'supported_auth_types' => ['ws_sk_header', 'bearer_token'],
57
57
  'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\PetroAppFuelProvider::BASE_URLS,
58
+ 'environment_labels' => ['sandbox' => 'Sandbox (PetroApp staging)', 'production' => 'Production'],
58
59
  'pagination' => 'page',
59
60
  'default_currency' => 'SAR',
60
61
  ],
61
62
  ],
63
+ [
64
+ 'key' => 'sasco',
65
+ 'label' => 'SASCO',
66
+ 'type' => 'native',
67
+ 'category' => 'Fuel card integration',
68
+ 'icon' => 'gas-pump',
69
+ 'driver_class' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::class,
70
+ 'description' => 'SASCO B2B fleet fuel transactions and vehicles.',
71
+ 'required_fields' => [
72
+ [
73
+ 'name' => 'username',
74
+ 'label' => 'B2B admin username',
75
+ 'type' => 'text',
76
+ 'help_text' => 'The SASCO B2B company admin login, usually an email address.',
77
+ 'required' => true,
78
+ ],
79
+ [
80
+ 'name' => 'password',
81
+ 'label' => 'B2B admin password',
82
+ 'type' => 'password',
83
+ 'help_text' => 'Used to request a short-lived access token from SASCO. Fleetbase re-authenticates automatically when it expires.',
84
+ 'required' => true,
85
+ ],
86
+ [
87
+ 'name' => 'base_url',
88
+ 'label' => 'Base URL override (optional)',
89
+ 'type' => 'url',
90
+ 'help_text' => 'Leave blank to use the selected environment. A custom URL overrides the environment selection.',
91
+ 'required' => false,
92
+ ],
93
+ ],
94
+ 'capabilities' => ['vehicles', 'transactions'],
95
+ 'sync_defaults' => [
96
+ 'window_days' => 7,
97
+ 'matching_order' => ['plate_number', 'internal_id', 'vin', 'serial_number', 'call_sign', 'fuel_card_number', 'trip_number'],
98
+ 'auto_create_fuel_reports' => true,
99
+ ],
100
+ 'setup_instructions' => [
101
+ 'Request B2B API access from SASCO and obtain the company admin username and password for QC/UAT and Production.',
102
+ 'Select Sandbox for the SASCO QC environment, enter the credentials, and run Test Connection.',
103
+ 'SASCO transactions carry the vehicle plate only, so make sure Fleetbase vehicle plate numbers use the letters-then-digits format (for example NXD 1240).',
104
+ 'Sync a small date window first to verify imported fuel transactions and vehicle matches.',
105
+ ],
106
+ 'metadata' => [
107
+ 'auth_type' => 'username_password',
108
+ 'supported_auth_types' => ['username_password'],
109
+ 'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::BASE_URLS,
110
+ 'environment_labels' => ['sandbox' => 'Sandbox (SASCO QC)', 'production' => 'Production'],
111
+ 'pagination' => 'current_page',
112
+ 'default_currency' => 'SAR',
113
+ ],
114
+ ],
62
115
  ],
63
116
  ];
@@ -0,0 +1,10 @@
1
+ <?php
2
+
3
+ return [
4
+ 'polling_enabled' => env('SAFEE_POLLING_ENABLED', true),
5
+ 'webhooks_enabled' => false,
6
+ 'manual_batch_sync' => true,
7
+ 'page_size' => 1000,
8
+ 'inventory_cache_seconds' => env('SAFEE_INVENTORY_CACHE_SECONDS', 300),
9
+ 'request_timeout_seconds' => env('SAFEE_REQUEST_TIMEOUT_SECONDS', 30),
10
+ ];
@@ -176,6 +176,7 @@ return [
176
176
  'driver_class' => Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider::class,
177
177
  'icon' => '/engines-dist/images/telematics/providers/safee.webp',
178
178
  'description' => 'Safee Tracking REST integration for vehicle discovery, last state, live positions, odometer, fuel, and sensor data.',
179
+ 'docs_url' => 'https://www.safee.com/_docs_/Safee_Tracking_REST_Service.pdf',
179
180
  'required_fields' => [
180
181
  [
181
182
  'name' => 'server_uri',
@@ -229,7 +230,13 @@ return [
229
230
  'supports_discovery' => true,
230
231
  'metadata' => [
231
232
  'rate_limit' => 3000,
232
- 'pagination' => 'none',
233
+ 'rate_limit_per_second' => 50,
234
+ 'pagination' => 'inventory_cursor',
235
+ 'telemetry' => [
236
+ 'durable_ingestion' => true,
237
+ 'secure_webhooks' => false,
238
+ 'reconciliation' => true,
239
+ ],
233
240
  'capabilities' => ['tracking', 'speed', 'heading', 'odometer', 'fuel_level', 'ignition'],
234
241
  ],
235
242
  ],
@@ -5,9 +5,12 @@ return [
5
5
  'webhooks_enabled' => false,
6
6
  'poll_queue' => env('TELEMATICS_POLL_QUEUE', 'default'),
7
7
  'ingestion_queue' => env('TELEMATICS_INGESTION_QUEUE', 'default'),
8
+ // Null keeps telemetry broadcasts on the connection's default queue.
9
+ 'broadcast_queue' => env('TELEMATICS_BROADCAST_QUEUE'),
8
10
  'page_size' => 1000,
9
11
  'max_pages' => 100,
10
- 'batch_size' => 100,
12
+ 'request_timeout_seconds' => env('TELEMATICS_REQUEST_TIMEOUT_SECONDS', 45),
13
+ 'batch_size' => env('TELEMATICS_BATCH_SIZE', 100),
11
14
  'max_payload_bytes' => 2097152,
12
15
  'max_pending_deliveries' => 10000,
13
16
  'processed_retention_hours' => 24,
@@ -0,0 +1,90 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ return new class extends Migration {
8
+ private const INDEX = 'device_events_uuid_lookup_index';
9
+
10
+ public function up(): void
11
+ {
12
+ if (!Schema::hasTable('device_events') || !Schema::hasColumn('device_events', 'uuid')) {
13
+ return;
14
+ }
15
+
16
+ $indexes = $this->indexes();
17
+ foreach ($indexes as $index) {
18
+ // A primary, unique, or composite index with uuid first already
19
+ // supports Eloquent's uuid lookup, regardless of the index name.
20
+ if ($index['usable'] && ($index['columns'][0] ?? null) === 'uuid') {
21
+ return;
22
+ }
23
+ }
24
+
25
+ if (isset($indexes[self::INDEX])) {
26
+ throw new RuntimeException('The device event UUID lookup index name is already used by another index.');
27
+ }
28
+
29
+ // The legacy table uses an integer primary key, while DeviceEvent uses
30
+ // uuid. In particular, activity logging refreshes every saved event by
31
+ // uuid. Keep this non-unique to preserve legacy nullable/duplicate data.
32
+ Schema::table('device_events', fn (Blueprint $table) => $table->index('uuid', self::INDEX));
33
+ }
34
+
35
+ public function down(): void
36
+ {
37
+ if (!Schema::hasTable('device_events')) {
38
+ return;
39
+ }
40
+
41
+ $index = $this->indexes()[self::INDEX] ?? null;
42
+ if ($index && $index['columns'] === ['uuid']) {
43
+ Schema::table('device_events', fn (Blueprint $table) => $table->dropIndex(self::INDEX));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Inspect indexes without relying on Schema::getIndexes, which is absent
49
+ * in Laravel 9 and early Laravel 10. Fleet-Ops uses MySQL in deployments
50
+ * and SQLite for its migration tests.
51
+ *
52
+ * @return array<string, array{columns: array, usable: bool}>
53
+ */
54
+ private function indexes(): array
55
+ {
56
+ $connection = Schema::getConnection();
57
+ $table = $connection->getTablePrefix() . 'device_events';
58
+ $indexes = [];
59
+
60
+ if ($connection->getDriverName() === 'sqlite') {
61
+ $table = str_replace('"', '""', $table);
62
+ foreach ($connection->select('PRAGMA index_list("' . $table . '")') as $index) {
63
+ $name = str_replace('"', '""', $index->name);
64
+ $columns = $connection->select('PRAGMA index_info("' . $name . '")');
65
+ usort($columns, fn ($a, $b) => $a->seqno <=> $b->seqno);
66
+ $indexes[$index->name] = ['columns' => array_column($columns, 'name'), 'usable' => !($index->partial ?? false)];
67
+ }
68
+
69
+ return $indexes;
70
+ }
71
+
72
+ if ($connection->getDriverName() !== 'mysql') {
73
+ throw new RuntimeException('Device event UUID index inspection requires MySQL or SQLite.');
74
+ }
75
+
76
+ $table = str_replace('`', '``', $table);
77
+ foreach ($connection->select('SHOW INDEX FROM `' . $table . '`') as $index) {
78
+ $indexes[$index->Key_name]['columns'][(int) $index->Seq_in_index - 1] = $index->Column_name;
79
+ $indexes[$index->Key_name]['usable'] = in_array(strtoupper($index->Index_type), ['BTREE', 'HASH'], true)
80
+ && strtoupper($index->Visible ?? 'YES') !== 'NO'
81
+ && strtoupper($index->Ignored ?? 'NO') !== 'YES';
82
+ }
83
+ foreach ($indexes as &$index) {
84
+ ksort($index['columns']);
85
+ $index['columns'] = array_values($index['columns']);
86
+ }
87
+
88
+ return $indexes;
89
+ }
90
+ };
@@ -24,7 +24,7 @@ class SyncTelematics extends Command
24
24
  $lock = null;
25
25
 
26
26
  if ($useLock) {
27
- $lock = Cache::lock('fleetops:sync-telematics', 600);
27
+ $lock = Cache::lock('fleetops:sync-telematics', 120);
28
28
  if (!$lock->get()) {
29
29
  $this->warn('Another telematics sync run appears to be in progress.');
30
30
 
@@ -40,6 +40,7 @@ class SyncTelematics extends Command
40
40
  return self::SUCCESS;
41
41
  }
42
42
 
43
+ $queued = 0;
43
44
  $telemetryProviders = [];
44
45
  foreach ($providerKeys as $key) {
45
46
  if (!data_get($registry->findByKey($key)?->metadata, 'telemetry.durable_ingestion', false)) {
@@ -50,18 +51,24 @@ class SyncTelematics extends Command
50
51
  continue;
51
52
  }
52
53
  $options = \Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider);
54
+ // Batch-only providers have no legacy fallback: pausing polling pauses their sync.
53
55
  if (!($options['polling_enabled'] ?? false)) {
56
+ if ($options['manual_batch_sync'] ?? false) {
57
+ $telemetryProviders[] = $key;
58
+ }
54
59
  continue;
55
60
  }
56
61
  $telemetryProviders[] = $key;
57
62
  Telematic::withoutGlobalScopes()->where('provider', $key)->whereIn('status', ['active', 'connected', 'error', 'synchronizing'])
58
- ->whereNotNull('company_uuid')->orderBy('id')->chunkById(100, function ($connections) use ($options) {
63
+ ->whereNotNull('company_uuid')->orderBy('id')->chunkById(100, function ($connections) use ($options, &$queued) {
59
64
  foreach ($connections as $connection) {
60
65
  if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($connection)) {
61
66
  try {
62
- \Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch((new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))
67
+ if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch((new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))
63
68
  ->onQueue($options['poll_queue'] ?? 'default')
64
- ->delay(now()->addSeconds(abs(crc32($connection->uuid)) % 10)));
69
+ ->delay(now()->addSeconds(abs(crc32($connection->uuid)) % 10)))) {
70
+ $queued++;
71
+ }
65
72
  } catch (\Throwable) {
66
73
  \Illuminate\Support\Facades\Log::warning('Telemetry polling dispatch failed; next tick will retry.', ['telematic_uuid' => $connection->uuid]);
67
74
  }
@@ -75,7 +82,6 @@ class SyncTelematics extends Command
75
82
  ->whereIn('status', ['active', 'connected'])
76
83
  ->whereNotNull('company_uuid');
77
84
 
78
- $queued = 0;
79
85
  $query->orderBy('id')->chunkById(100, function ($telematics) use (&$queued) {
80
86
  foreach ($telematics as $telematic) {
81
87
  SyncTelematicDevicesJob::dispatch($telematic, [
@@ -8,7 +8,8 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
8
8
 
9
9
  class DeviceTelemetryUpdated implements ShouldBroadcast
10
10
  {
11
- public bool $afterCommit = true;
11
+ public bool $afterCommit = true;
12
+ public ?string $broadcastQueue = null;
12
13
  public string $companyUuid;
13
14
  public string $deviceUuid;
14
15
  public array $data;
@@ -25,6 +25,8 @@ class TrailerLocationChanged implements ShouldBroadcast
25
25
  public mixed $heading;
26
26
  public mixed $speed;
27
27
  public array $additionalData;
28
+ // Optional queue for telematics-originated broadcasts. Null keeps Laravel's default.
29
+ public ?string $broadcastQueue = null;
28
30
 
29
31
  public function __construct(Trailer $trailer, array $additionalData = [])
30
32
  {
@@ -25,6 +25,11 @@ class VehicleLocationChanged implements ShouldBroadcast
25
25
  public ?string $companyUuid;
26
26
  public ?string $apiCredentialUuid;
27
27
 
28
+ /**
29
+ * Optional queue for telematics-originated broadcasts. Null keeps Laravel's default.
30
+ */
31
+ public ?string $broadcastQueue = null;
32
+
28
33
  /**
29
34
  * The datetime instance the broadcast ws triggered.
30
35
  *