@fleetbase/fleetops-engine 0.6.43 → 0.6.45

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 (82) hide show
  1. package/addon/components/entity/form.hbs +24 -0
  2. package/addon/components/entity/form.js +30 -0
  3. package/addon/components/leaflet-draw-control.js +1 -1
  4. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  5. package/addon/components/map/drawer/geofence-event-listing.js +103 -0
  6. package/addon/components/map/drawer.js +1 -0
  7. package/addon/components/map/leaflet-live-map.js +83 -0
  8. package/addon/components/modals/orchestrator-import.js +2 -78
  9. package/addon/components/order/form/service-rate.hbs +1 -1
  10. package/addon/components/service-area/form.hbs +59 -0
  11. package/addon/components/service-rate/details.hbs +6 -6
  12. package/addon/components/service-rate/form.hbs +28 -15
  13. package/addon/components/zone/form.hbs +60 -1
  14. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  15. package/addon/controllers/operations/orders/index/details.js +27 -2
  16. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  17. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  18. package/addon/helpers/f-to-int.js +6 -0
  19. package/addon/routes/application.js +3 -3
  20. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  21. package/addon/routes/operations/orders/index/details.js +19 -3
  22. package/addon/services/equipment-actions.js +4 -6
  23. package/addon/services/geofence-event-bus.js +223 -0
  24. package/addon/services/leaflet-map-manager.js +4 -0
  25. package/addon/services/maintenance-actions.js +4 -6
  26. package/addon/services/part-actions.js +4 -6
  27. package/addon/services/service-rate-actions.js +8 -3
  28. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  29. package/addon/utils/fleet-ops-options.js +33 -0
  30. package/addon/utils/serialize-payload.js +1 -1
  31. package/app/components/map/drawer/geofence-event-listing.js +1 -0
  32. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  33. package/app/helpers/f-to-int.js +1 -0
  34. package/app/services/geofence-event-bus.js +1 -0
  35. package/composer.json +1 -1
  36. package/extension.json +1 -1
  37. package/package.json +2 -2
  38. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  39. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  40. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  41. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  42. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  43. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  44. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  45. package/server/src/Events/GeofenceDwelled.php +208 -0
  46. package/server/src/Events/GeofenceEntered.php +207 -0
  47. package/server/src/Events/GeofenceExited.php +213 -0
  48. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  49. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  50. package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
  51. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  52. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  53. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
  54. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  55. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  56. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  57. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  58. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  59. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  60. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  61. package/server/src/Http/Resources/v1/Order.php +0 -1
  62. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  63. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  64. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  65. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  66. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  67. package/server/src/Models/Entity.php +7 -0
  68. package/server/src/Models/GeofenceEventLog.php +155 -0
  69. package/server/src/Models/Order.php +39 -7
  70. package/server/src/Models/ServiceArea.php +21 -2
  71. package/server/src/Models/ServiceQuote.php +3 -1
  72. package/server/src/Models/ServiceRate.php +122 -30
  73. package/server/src/Models/Zone.php +20 -2
  74. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  75. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  76. package/server/src/Providers/EventServiceProvider.php +19 -0
  77. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  78. package/server/src/Rules/ComputableAlgo.php +1 -3
  79. package/server/src/Support/Algo.php +87 -1
  80. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  81. package/server/src/Support/Utils.php +35 -0
  82. package/server/src/routes.php +16 -0
@@ -0,0 +1,278 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Console\Commands;
4
+
5
+ use Carbon\Carbon;
6
+ use Fleetbase\FleetOps\Events\GeofenceDwelled;
7
+ use Fleetbase\FleetOps\Events\GeofenceEntered;
8
+ use Fleetbase\FleetOps\Events\GeofenceExited;
9
+ use Fleetbase\FleetOps\Models\Driver;
10
+ use Fleetbase\FleetOps\Models\ServiceArea;
11
+ use Fleetbase\FleetOps\Models\Vehicle;
12
+ use Fleetbase\FleetOps\Models\Zone;
13
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
14
+ use Illuminate\Console\Command;
15
+ use Illuminate\Support\Facades\DB;
16
+ use Illuminate\Support\Str;
17
+
18
+ class SimulateGeofenceEvents extends Command
19
+ {
20
+ /**
21
+ * The name and signature of the console command.
22
+ *
23
+ * @var string
24
+ */
25
+ protected $signature = 'fleetops:simulate-geofence
26
+ {subject : Subject public_id or uuid}
27
+ {geofence : Geofence public_id or uuid}
28
+ {events=sequence : entered|exited|dwelled|sequence or comma-separated list}
29
+ {--repeat=1 : Number of times to repeat the event sequence}
30
+ {--sleep=0 : Seconds to sleep between events}
31
+ {--dwell-minutes=10 : Minutes to use for simulated dwell timing}
32
+ {--reset-state : Reset the geofence state before running}
33
+ {--no-log : Skip dispatching Laravel geofence events, update state only}';
34
+
35
+ /**
36
+ * The console command description.
37
+ *
38
+ * @var string
39
+ */
40
+ protected $description = 'Simulate geofence events for a driver or vehicle against a zone or service area';
41
+
42
+ public function handle(): int
43
+ {
44
+ $events = $this->parseEvents($this->argument('events'));
45
+ $repeat = max(1, (int) $this->option('repeat'));
46
+ $sleep = max(0, (int) $this->option('sleep'));
47
+ $dwellMinutes = max(1, (int) $this->option('dwell-minutes'));
48
+ $dispatchEvent = !$this->option('no-log');
49
+ $subjectId = $this->argument('subject');
50
+ $geofenceId = $this->argument('geofence');
51
+
52
+ if (empty($events)) {
53
+ $this->error('Invalid arguments. Events must be entered|exited|dwelled|sequence or a comma-separated combination.');
54
+
55
+ return Command::FAILURE;
56
+ }
57
+
58
+ [$subjectType, $subject] = $this->resolveSubject($subjectId);
59
+ [$geofenceType, $geofence] = $this->resolveGeofence($geofenceId);
60
+
61
+ if (!$subject) {
62
+ $this->error("Unable to resolve subject [{$subjectId}]. Expected a `driver_` or `vehicle_` public ID, or a UUID matching one of those resources.");
63
+
64
+ return Command::FAILURE;
65
+ }
66
+
67
+ if (!$geofence) {
68
+ $this->error("Unable to resolve geofence [{$geofenceId}]. Expected a `zone_` or `sa_` public ID, or a UUID matching one of those resources.");
69
+
70
+ return Command::FAILURE;
71
+ }
72
+
73
+ session(['company' => $subject->company_uuid]);
74
+ $location = new Point($geofence->getLatitudeAttribute(), $geofence->getLongitudeAttribute());
75
+
76
+ if ($this->option('reset-state')) {
77
+ $this->resetState($subjectType, $subject, $geofence);
78
+ }
79
+
80
+ $this->info(sprintf(
81
+ 'Testing geofence events: %s [%s] -> %s [%s] | events=%s | repeat=%d',
82
+ $subjectType,
83
+ $subject->public_id ?? $subject->uuid,
84
+ $geofenceType,
85
+ $geofence->public_id ?? $geofence->uuid,
86
+ implode(',', $events),
87
+ $repeat
88
+ ));
89
+
90
+ for ($iteration = 1; $iteration <= $repeat; $iteration++) {
91
+ $this->line("Run {$iteration}/{$repeat}");
92
+
93
+ foreach ($events as $eventName) {
94
+ $enteredAt = Carbon::now()->subMinutes($dwellMinutes);
95
+
96
+ switch ($eventName) {
97
+ case 'entered':
98
+ $this->markInside($subjectType, $subject, $geofence, Carbon::now());
99
+ if ($dispatchEvent) {
100
+ event(new GeofenceEntered($subject, $geofence, $geofenceType, $location));
101
+ }
102
+ $this->info(' -> dispatched geofence.entered');
103
+ break;
104
+
105
+ case 'dwelled':
106
+ $this->markInside($subjectType, $subject, $geofence, $enteredAt);
107
+ if ($dispatchEvent) {
108
+ event(new GeofenceDwelled($subject, $geofence, $geofenceType, $enteredAt));
109
+ }
110
+ $this->info(" -> dispatched geofence.dwelled ({$dwellMinutes} min)");
111
+ break;
112
+
113
+ case 'exited':
114
+ $this->ensureEnteredAt($subjectType, $subject, $geofence, $enteredAt);
115
+ $dwellDuration = $this->calculateDwellMinutes($subjectType, $subject, $geofence, $dwellMinutes);
116
+ $this->markOutside($subjectType, $subject, $geofence);
117
+ if ($dispatchEvent) {
118
+ event(new GeofenceExited($subject, $geofence, $geofenceType, $location, $dwellDuration));
119
+ }
120
+ $this->info(" -> dispatched geofence.exited ({$dwellDuration} min)");
121
+ break;
122
+ }
123
+
124
+ if ($sleep > 0) {
125
+ sleep($sleep);
126
+ }
127
+ }
128
+ }
129
+
130
+ $this->newLine();
131
+ $this->info('Geofence simulation complete.');
132
+
133
+ return Command::SUCCESS;
134
+ }
135
+
136
+ protected function parseEvents(string $events): array
137
+ {
138
+ $events = Str::lower(trim($events));
139
+
140
+ if ($events === 'sequence') {
141
+ return ['entered', 'dwelled', 'exited'];
142
+ }
143
+
144
+ $parsed = collect(explode(',', $events))
145
+ ->map(fn ($event) => trim($event))
146
+ ->filter(fn ($event) => in_array($event, ['entered', 'exited', 'dwelled'], true))
147
+ ->values()
148
+ ->all();
149
+
150
+ return $parsed;
151
+ }
152
+
153
+ protected function resolveSubject(string $identifier): array
154
+ {
155
+ if (Str::startsWith($identifier, 'driver_')) {
156
+ return ['driver', Driver::withoutGlobalScopes()->where('public_id', $identifier)->first()];
157
+ }
158
+
159
+ if (Str::startsWith($identifier, 'vehicle_')) {
160
+ return ['vehicle', Vehicle::withoutGlobalScopes()->where('public_id', $identifier)->first()];
161
+ }
162
+
163
+ if (Str::isUuid($identifier)) {
164
+ $driver = Driver::withoutGlobalScopes()->where('uuid', $identifier)->first();
165
+ if ($driver) {
166
+ return ['driver', $driver];
167
+ }
168
+
169
+ $vehicle = Vehicle::withoutGlobalScopes()->where('uuid', $identifier)->first();
170
+ if ($vehicle) {
171
+ return ['vehicle', $vehicle];
172
+ }
173
+ }
174
+
175
+ return [null, null];
176
+ }
177
+
178
+ protected function resolveGeofence(string $identifier): array
179
+ {
180
+ if (Str::startsWith($identifier, 'zone_')) {
181
+ return ['zone', Zone::withoutGlobalScopes()->where('public_id', $identifier)->first()];
182
+ }
183
+
184
+ if (Str::startsWith($identifier, 'sa_')) {
185
+ return ['service_area', ServiceArea::withoutGlobalScopes()->where('public_id', $identifier)->first()];
186
+ }
187
+
188
+ if (Str::isUuid($identifier)) {
189
+ $zone = Zone::withoutGlobalScopes()->where('uuid', $identifier)->first();
190
+ if ($zone) {
191
+ return ['zone', $zone];
192
+ }
193
+
194
+ $serviceArea = ServiceArea::withoutGlobalScopes()->where('uuid', $identifier)->first();
195
+ if ($serviceArea) {
196
+ return ['service_area', $serviceArea];
197
+ }
198
+ }
199
+
200
+ return [null, null];
201
+ }
202
+
203
+ protected function stateTable(string $subjectType): string
204
+ {
205
+ return $subjectType === 'vehicle' ? 'vehicle_geofence_states' : 'driver_geofence_states';
206
+ }
207
+
208
+ protected function subjectColumn(string $subjectType): string
209
+ {
210
+ return $subjectType === 'vehicle' ? 'vehicle_uuid' : 'driver_uuid';
211
+ }
212
+
213
+ protected function resetState(string $subjectType, Driver|Vehicle $subject, Zone|ServiceArea $geofence): void
214
+ {
215
+ DB::table($this->stateTable($subjectType))
216
+ ->where($this->subjectColumn($subjectType), $subject->uuid)
217
+ ->where('geofence_uuid', $geofence->uuid)
218
+ ->delete();
219
+ }
220
+
221
+ protected function markInside(string $subjectType, Driver|Vehicle $subject, Zone|ServiceArea $geofence, Carbon $enteredAt): void
222
+ {
223
+ DB::table($this->stateTable($subjectType))->upsert(
224
+ [
225
+ $this->subjectColumn($subjectType) => $subject->uuid,
226
+ 'geofence_uuid' => $geofence->uuid,
227
+ 'geofence_type' => $geofence instanceof ServiceArea ? 'service_area' : 'zone',
228
+ 'is_inside' => true,
229
+ 'entered_at' => $enteredAt,
230
+ 'exited_at' => null,
231
+ 'dwell_job_id' => null,
232
+ 'created_at' => now(),
233
+ 'updated_at' => now(),
234
+ ],
235
+ [$this->subjectColumn($subjectType), 'geofence_uuid'],
236
+ ['is_inside', 'entered_at', 'exited_at', 'dwell_job_id', 'updated_at']
237
+ );
238
+ }
239
+
240
+ protected function ensureEnteredAt(string $subjectType, Driver|Vehicle $subject, Zone|ServiceArea $geofence, Carbon $enteredAt): void
241
+ {
242
+ $state = DB::table($this->stateTable($subjectType))
243
+ ->where($this->subjectColumn($subjectType), $subject->uuid)
244
+ ->where('geofence_uuid', $geofence->uuid)
245
+ ->first();
246
+
247
+ if (!$state || !$state->entered_at) {
248
+ $this->markInside($subjectType, $subject, $geofence, $enteredAt);
249
+ }
250
+ }
251
+
252
+ protected function markOutside(string $subjectType, Driver|Vehicle $subject, Zone|ServiceArea $geofence): void
253
+ {
254
+ DB::table($this->stateTable($subjectType))
255
+ ->where($this->subjectColumn($subjectType), $subject->uuid)
256
+ ->where('geofence_uuid', $geofence->uuid)
257
+ ->update([
258
+ 'is_inside' => false,
259
+ 'exited_at' => now(),
260
+ 'dwell_job_id' => null,
261
+ 'updated_at' => now(),
262
+ ]);
263
+ }
264
+
265
+ protected function calculateDwellMinutes(string $subjectType, Driver|Vehicle $subject, Zone|ServiceArea $geofence, int $fallback): int
266
+ {
267
+ $state = DB::table($this->stateTable($subjectType))
268
+ ->where($this->subjectColumn($subjectType), $subject->uuid)
269
+ ->where('geofence_uuid', $geofence->uuid)
270
+ ->first();
271
+
272
+ if (!$state || !$state->entered_at) {
273
+ return $fallback;
274
+ }
275
+
276
+ return max(1, Carbon::parse($state->entered_at)->diffInMinutes(now()));
277
+ }
278
+ }
@@ -0,0 +1,208 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Events;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Vehicle;
7
+ use Illuminate\Broadcasting\Channel;
8
+ use Illuminate\Broadcasting\InteractsWithSockets;
9
+ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
10
+ use Illuminate\Foundation\Events\Dispatchable;
11
+ use Illuminate\Queue\SerializesModels;
12
+
13
+ /**
14
+ * GeofenceDwelled.
15
+ *
16
+ * Fired by the CheckGeofenceDwell queue job when a driver has remained
17
+ * inside a geofence for at least the configured dwell_threshold_minutes.
18
+ *
19
+ * Listeners:
20
+ * - HandleGeofenceDwelled (event log)
21
+ * - SendResourceLifecycleWebhook (webhook delivery)
22
+ */
23
+ class GeofenceDwelled implements ShouldBroadcast
24
+ {
25
+ use Dispatchable;
26
+ use InteractsWithSockets;
27
+ use SerializesModels;
28
+
29
+ public string $modelHumanName = 'Geofence';
30
+ public ?string $modelRecordName = null;
31
+ public string $eventName = 'dwelled';
32
+ public $userSession;
33
+ public $companySession;
34
+ public $requestMethod;
35
+ public $apiCredential;
36
+ public $apiSecret;
37
+ public $apiKey;
38
+ public $apiEnvironment;
39
+ public $isSandbox;
40
+ public array $data = [];
41
+
42
+ /**
43
+ * The driver who has been dwelling inside the geofence.
44
+ */
45
+ public ?Driver $driver = null;
46
+
47
+ /**
48
+ * The vehicle associated with the geofence event, if any.
49
+ */
50
+ public ?Vehicle $vehicle = null;
51
+
52
+ /**
53
+ * The geofence where the driver is dwelling (Zone or ServiceArea).
54
+ *
55
+ * @var \Fleetbase\FleetOps\Models\Zone|\Fleetbase\FleetOps\Models\ServiceArea
56
+ */
57
+ public $geofence;
58
+
59
+ /**
60
+ * The type of geofence: 'zone' or 'service_area'.
61
+ */
62
+ public string $geofenceType;
63
+
64
+ /**
65
+ * The timestamp when the driver entered the geofence.
66
+ */
67
+ public \Carbon\Carbon $enteredAt;
68
+
69
+ /**
70
+ * The number of minutes the driver has been inside the geofence.
71
+ */
72
+ public int $dwellDurationMinutes;
73
+
74
+ /**
75
+ * The timestamp when this dwell event was fired.
76
+ */
77
+ public \Carbon\Carbon $timestamp;
78
+
79
+ /**
80
+ * The subject that triggered the event: 'driver' or 'vehicle'.
81
+ */
82
+ public string $subjectType;
83
+
84
+ /**
85
+ * Create a new GeofenceDwelled event.
86
+ *
87
+ * @param mixed $geofence Zone or ServiceArea
88
+ * @param string $geofenceType 'zone' | 'service_area'
89
+ */
90
+ public function __construct(Driver|Vehicle $subject, $geofence, string $geofenceType, \Carbon\Carbon $enteredAt)
91
+ {
92
+ if ($subject instanceof Driver) {
93
+ $this->driver = $subject;
94
+ $this->vehicle = $subject->vehicle;
95
+ $this->subjectType = 'driver';
96
+ } else {
97
+ $subject->loadMissing('driver');
98
+ $this->vehicle = $subject;
99
+ $this->driver = $subject->driver;
100
+ $this->subjectType = 'vehicle';
101
+ }
102
+
103
+ $this->geofence = $geofence;
104
+ $this->geofenceType = $geofenceType;
105
+ $this->enteredAt = $enteredAt;
106
+ $this->dwellDurationMinutes = (int) $enteredAt->diffInMinutes(now());
107
+ $this->timestamp = now();
108
+ $this->modelRecordName = $geofence->name ?? $geofence->public_id ?? $geofence->uuid ?? null;
109
+ $this->userSession = session('user');
110
+ $this->companySession = session('company', $this->getCompanyUuid());
111
+ $this->requestMethod = request()?->method() ?? 'CLI';
112
+ $this->apiCredential = session('api_credential', 'console');
113
+ $this->apiSecret = session('api_secret', 'internal');
114
+ $this->apiKey = session('api_key');
115
+ $this->apiEnvironment = session('api_environment', 'live');
116
+ $this->isSandbox = session('is_sandbox', false);
117
+ $this->data = $this->getEventData();
118
+ }
119
+
120
+ /**
121
+ * Returns the company UUID for this event.
122
+ */
123
+ public function getCompanyUuid(): string
124
+ {
125
+ return $this->vehicle?->company_uuid ?? $this->driver->company_uuid;
126
+ }
127
+
128
+ public function broadcastOn(): array
129
+ {
130
+ $channels = [new Channel('company.' . $this->getCompanyUuid())];
131
+
132
+ if ($this->driver) {
133
+ $channels[] = new Channel('driver.' . $this->driver->public_id);
134
+ $channels[] = new Channel('driver.' . $this->driver->uuid);
135
+ }
136
+
137
+ if ($this->vehicle) {
138
+ $channels[] = new Channel('vehicle.' . $this->vehicle->public_id);
139
+ $channels[] = new Channel('vehicle.' . $this->vehicle->uuid);
140
+ }
141
+
142
+ return $channels;
143
+ }
144
+
145
+ public function broadcastAs(): string
146
+ {
147
+ return 'geofence.dwelled';
148
+ }
149
+
150
+ /**
151
+ * Returns the standardised webhook payload for this event.
152
+ */
153
+ public function broadcastWith(): array
154
+ {
155
+ return $this->getEventData();
156
+ }
157
+
158
+ public function getEventData(): array
159
+ {
160
+ $driver = $this->driver;
161
+ $vehicle = $this->vehicle;
162
+ $geofence = $this->geofence;
163
+ $subject = $this->subjectType === 'vehicle' ? $vehicle : $driver;
164
+
165
+ $payload = [
166
+ 'event' => $this->broadcastAs(),
167
+ 'event_type' => 'geofence.dwelled',
168
+ 'occurred_at' => $this->timestamp->toIso8601String(),
169
+ 'entered_at' => $this->enteredAt->toIso8601String(),
170
+ 'dwell_duration_minutes' => $this->dwellDurationMinutes,
171
+ 'subject' => [
172
+ 'type' => $this->subjectType,
173
+ 'id' => $subject?->public_id ?? null,
174
+ 'uuid' => $subject?->uuid ?? null,
175
+ 'name' => $this->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
176
+ ],
177
+ 'driver' => $driver ? [
178
+ 'id' => $driver->public_id,
179
+ 'uuid' => $driver->uuid,
180
+ 'name' => $driver->name,
181
+ 'phone' => $driver->phone,
182
+ ] : null,
183
+ 'vehicle' => $vehicle ? [
184
+ 'id' => $vehicle->public_id,
185
+ 'uuid' => $vehicle->uuid,
186
+ 'name' => $vehicle->display_name ?? null,
187
+ 'plate' => $vehicle->plate_number ?? null,
188
+ ] : null,
189
+ 'geofence' => [
190
+ 'id' => $geofence->public_id,
191
+ 'uuid' => $geofence->uuid,
192
+ 'name' => $geofence->name,
193
+ 'type' => $this->geofenceType,
194
+ ],
195
+ ];
196
+
197
+ $order = $driver?->getCurrentOrder();
198
+ if ($order) {
199
+ $payload['order'] = [
200
+ 'id' => $order->public_id,
201
+ 'uuid' => $order->uuid,
202
+ 'status' => $order->status,
203
+ ];
204
+ }
205
+
206
+ return $payload;
207
+ }
208
+ }
@@ -0,0 +1,207 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Events;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Vehicle;
7
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
8
+ use Illuminate\Broadcasting\Channel;
9
+ use Illuminate\Broadcasting\InteractsWithSockets;
10
+ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
11
+ use Illuminate\Foundation\Events\Dispatchable;
12
+ use Illuminate\Queue\SerializesModels;
13
+
14
+ /**
15
+ * GeofenceEntered.
16
+ *
17
+ * Fired when a driver's location update causes them to cross into a
18
+ * geofence boundary (Zone or ServiceArea) for the first time.
19
+ *
20
+ * Listeners:
21
+ * - HandleGeofenceEntered (order automation, customer notification, event log)
22
+ * - SendResourceLifecycleWebhook (webhook delivery to configured endpoints)
23
+ */
24
+ class GeofenceEntered implements ShouldBroadcast
25
+ {
26
+ use Dispatchable;
27
+ use InteractsWithSockets;
28
+ use SerializesModels;
29
+
30
+ public string $modelHumanName = 'Geofence';
31
+ public ?string $modelRecordName = null;
32
+ public string $eventName = 'entered';
33
+ public $userSession;
34
+ public $companySession;
35
+ public $requestMethod;
36
+ public $apiCredential;
37
+ public $apiSecret;
38
+ public $apiKey;
39
+ public $apiEnvironment;
40
+ public $isSandbox;
41
+ public array $data = [];
42
+
43
+ /**
44
+ * The driver who entered the geofence.
45
+ */
46
+ public ?Driver $driver = null;
47
+
48
+ /**
49
+ * The vehicle associated with the geofence event, if any.
50
+ */
51
+ public ?Vehicle $vehicle = null;
52
+
53
+ /**
54
+ * The geofence that was entered (Zone or ServiceArea model instance).
55
+ *
56
+ * @var \Fleetbase\FleetOps\Models\Zone|\Fleetbase\FleetOps\Models\ServiceArea
57
+ */
58
+ public $geofence;
59
+
60
+ /**
61
+ * The type of geofence: 'zone' or 'service_area'.
62
+ */
63
+ public string $geofenceType;
64
+
65
+ /**
66
+ * The driver's location at the time of entry.
67
+ */
68
+ public Point $location;
69
+
70
+ /**
71
+ * The timestamp when the entry was detected.
72
+ */
73
+ public \Carbon\Carbon $timestamp;
74
+
75
+ /**
76
+ * The subject that triggered the event: 'driver' or 'vehicle'.
77
+ */
78
+ public string $subjectType;
79
+
80
+ /**
81
+ * Create a new GeofenceEntered event.
82
+ *
83
+ * @param mixed $geofence Zone or ServiceArea
84
+ * @param string $geofenceType 'zone' | 'service_area'
85
+ */
86
+ public function __construct(Driver|Vehicle $subject, $geofence, string $geofenceType, Point $location)
87
+ {
88
+ if ($subject instanceof Driver) {
89
+ $this->driver = $subject;
90
+ $this->vehicle = $subject->vehicle;
91
+ $this->subjectType = 'driver';
92
+ } else {
93
+ $subject->loadMissing('driver');
94
+ $this->vehicle = $subject;
95
+ $this->driver = $subject->driver;
96
+ $this->subjectType = 'vehicle';
97
+ }
98
+
99
+ $this->geofence = $geofence;
100
+ $this->geofenceType = $geofenceType;
101
+ $this->location = $location;
102
+ $this->timestamp = now();
103
+ $this->modelRecordName = $geofence->name ?? $geofence->public_id ?? $geofence->uuid ?? null;
104
+ $this->userSession = session('user');
105
+ $this->companySession = session('company', $this->getCompanyUuid());
106
+ $this->requestMethod = request()?->method() ?? 'CLI';
107
+ $this->apiCredential = session('api_credential', 'console');
108
+ $this->apiSecret = session('api_secret', 'internal');
109
+ $this->apiKey = session('api_key');
110
+ $this->apiEnvironment = session('api_environment', 'live');
111
+ $this->isSandbox = session('is_sandbox', false);
112
+ $this->data = $this->getEventData();
113
+ }
114
+
115
+ /**
116
+ * Returns the company UUID for this event.
117
+ * Used by the webhook infrastructure to scope delivery.
118
+ */
119
+ public function getCompanyUuid(): string
120
+ {
121
+ return $this->vehicle?->company_uuid ?? $this->driver->company_uuid;
122
+ }
123
+
124
+ public function broadcastOn(): array
125
+ {
126
+ $channels = [new Channel('company.' . $this->getCompanyUuid())];
127
+
128
+ if ($this->driver) {
129
+ $channels[] = new Channel('driver.' . $this->driver->public_id);
130
+ $channels[] = new Channel('driver.' . $this->driver->uuid);
131
+ }
132
+
133
+ if ($this->vehicle) {
134
+ $channels[] = new Channel('vehicle.' . $this->vehicle->public_id);
135
+ $channels[] = new Channel('vehicle.' . $this->vehicle->uuid);
136
+ }
137
+
138
+ return $channels;
139
+ }
140
+
141
+ public function broadcastAs(): string
142
+ {
143
+ return 'geofence.entered';
144
+ }
145
+
146
+ /**
147
+ * Returns the standardised webhook payload for this event.
148
+ */
149
+ public function broadcastWith(): array
150
+ {
151
+ return $this->getEventData();
152
+ }
153
+
154
+ public function getEventData(): array
155
+ {
156
+ $driver = $this->driver;
157
+ $vehicle = $this->vehicle;
158
+ $geofence = $this->geofence;
159
+ $subject = $this->subjectType === 'vehicle' ? $vehicle : $driver;
160
+
161
+ $payload = [
162
+ 'event' => $this->broadcastAs(),
163
+ 'event_type' => 'geofence.entered',
164
+ 'occurred_at' => $this->timestamp->toIso8601String(),
165
+ 'subject' => [
166
+ 'type' => $this->subjectType,
167
+ 'id' => $subject?->public_id ?? null,
168
+ 'uuid' => $subject?->uuid ?? null,
169
+ 'name' => $this->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
170
+ ],
171
+ 'driver' => $driver ? [
172
+ 'id' => $driver->public_id,
173
+ 'uuid' => $driver->uuid,
174
+ 'name' => $driver->name,
175
+ 'phone' => $driver->phone,
176
+ ] : null,
177
+ 'vehicle' => $vehicle ? [
178
+ 'id' => $vehicle->public_id,
179
+ 'uuid' => $vehicle->uuid,
180
+ 'name' => $vehicle->display_name ?? null,
181
+ 'plate' => $vehicle->plate_number ?? null,
182
+ ] : null,
183
+ 'geofence' => [
184
+ 'id' => $geofence->public_id,
185
+ 'uuid' => $geofence->uuid,
186
+ 'name' => $geofence->name,
187
+ 'type' => $this->geofenceType,
188
+ ],
189
+ 'location' => [
190
+ 'latitude' => $this->location->getLat(),
191
+ 'longitude' => $this->location->getLng(),
192
+ ],
193
+ ];
194
+
195
+ // Attach active order context if the driver has one
196
+ $order = $driver?->getCurrentOrder();
197
+ if ($order) {
198
+ $payload['order'] = [
199
+ 'id' => $order->public_id,
200
+ 'uuid' => $order->uuid,
201
+ 'status' => $order->status,
202
+ ];
203
+ }
204
+
205
+ return $payload;
206
+ }
207
+ }