@fleetbase/fleetops-engine 0.6.42 → 0.6.44

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 (52) hide show
  1. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  2. package/addon/components/map/drawer/geofence-event-listing.js +100 -0
  3. package/addon/components/map/drawer.js +1 -0
  4. package/addon/components/map/leaflet-live-map.js +83 -0
  5. package/addon/components/modals/orchestrator-import.js +2 -78
  6. package/addon/components/service-area/form.hbs +59 -0
  7. package/addon/components/zone/form.hbs +60 -1
  8. package/addon/services/geofence-event-bus.js +223 -0
  9. package/app/components/{modals/set-password.js → map/drawer/geofence-event-listing.js} +1 -1
  10. package/app/services/geofence-event-bus.js +1 -0
  11. package/composer.json +1 -1
  12. package/extension.json +1 -1
  13. package/package.json +2 -2
  14. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  15. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  16. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  17. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  18. package/server/migrations/2026_04_08_000001_add_orchestrator_columns_to_vehicles_table.php +3 -0
  19. package/server/migrations/2026_04_08_000004_add_orchestrator_columns_to_payloads_table.php +3 -0
  20. package/server/migrations/2026_04_14_000001_drop_redundant_capacity_columns_from_payloads_table.php +2 -0
  21. package/server/migrations/2026_04_14_000002_rename_capacity_columns_on_vehicles_table.php +2 -0
  22. package/server/migrations/2026_04_21_081353_add_internal_id_column_to_vehicles_table.php +27 -0
  23. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  24. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  25. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  26. package/server/src/Events/GeofenceDwelled.php +208 -0
  27. package/server/src/Events/GeofenceEntered.php +207 -0
  28. package/server/src/Events/GeofenceExited.php +213 -0
  29. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  30. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  31. package/server/src/Http/Controllers/Api/v1/OrderController.php +8 -3
  32. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  33. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  34. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  35. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  36. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  37. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  38. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  39. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  40. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  41. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  42. package/server/src/Models/GeofenceEventLog.php +155 -0
  43. package/server/src/Models/ServiceArea.php +21 -2
  44. package/server/src/Models/ServiceQuote.php +3 -1
  45. package/server/src/Models/Zone.php +20 -2
  46. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  47. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +2 -2
  48. package/server/src/Providers/EventServiceProvider.php +19 -0
  49. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  50. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  51. package/server/src/routes.php +16 -0
  52. package/addon/components/modals/set-password.hbs +0 -27
@@ -2,17 +2,23 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Events\GeofenceEntered;
6
+ use Fleetbase\FleetOps\Events\GeofenceExited;
5
7
  use Fleetbase\FleetOps\Events\VehicleLocationChanged;
6
8
  use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest;
7
9
  use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest;
8
10
  use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
9
11
  use Fleetbase\FleetOps\Http\Resources\v1\Vehicle as VehicleResource;
12
+ use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell;
10
13
  use Fleetbase\FleetOps\Models\Driver;
11
14
  use Fleetbase\FleetOps\Models\Vehicle;
15
+ use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
12
16
  use Fleetbase\FleetOps\Support\Utils;
13
17
  use Fleetbase\Http\Controllers\Controller;
14
18
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
15
19
  use Illuminate\Http\Request;
20
+ use Illuminate\Support\Carbon;
21
+ use Illuminate\Support\Facades\DB;
16
22
 
17
23
  class VehicleController extends Controller
18
24
  {
@@ -276,6 +282,88 @@ class VehicleController extends Controller
276
282
 
277
283
  broadcast(new VehicleLocationChanged($vehicle));
278
284
 
285
+ try {
286
+ $newLocation = new Point($latitude, $longitude);
287
+ $geofenceService = app(GeofenceIntersectionService::class);
288
+ $this->processVehicleGeofenceCrossings($vehicle, $newLocation, $geofenceService->detectVehicleCrossings($vehicle, $newLocation));
289
+ } catch (\Throwable $geofenceException) {
290
+ if (app()->bound('sentry')) {
291
+ app('sentry')->captureException($geofenceException);
292
+ }
293
+ }
294
+
279
295
  return new VehicleResource($vehicle);
280
296
  }
297
+
298
+ private function processVehicleGeofenceCrossings(Vehicle $vehicle, Point $newLocation, array $crossings): void
299
+ {
300
+ foreach ($crossings as $crossing) {
301
+ $geofence = $crossing['geofence'];
302
+ $geofenceType = $crossing['geofence_type'];
303
+
304
+ if ($crossing['type'] === 'entered') {
305
+ if (!$geofence->trigger_on_entry && empty($geofence->dwell_threshold_minutes)) {
306
+ continue;
307
+ }
308
+
309
+ DB::table('vehicle_geofence_states')->upsert(
310
+ [
311
+ 'vehicle_uuid' => $vehicle->uuid,
312
+ 'geofence_uuid' => $geofence->uuid,
313
+ 'geofence_type' => $geofenceType,
314
+ 'is_inside' => true,
315
+ 'entered_at' => now(),
316
+ 'exited_at' => null,
317
+ 'dwell_job_id' => null,
318
+ 'created_at' => now(),
319
+ 'updated_at' => now(),
320
+ ],
321
+ ['vehicle_uuid', 'geofence_uuid'],
322
+ ['is_inside', 'entered_at', 'exited_at', 'dwell_job_id', 'updated_at']
323
+ );
324
+
325
+ if ($geofence->trigger_on_entry) {
326
+ event(new GeofenceEntered($vehicle, $geofence, $geofenceType, $newLocation));
327
+ }
328
+
329
+ if ($geofence->dwell_threshold_minutes > 0) {
330
+ $dwellJob = CheckGeofenceDwell::dispatch(
331
+ $vehicle->uuid,
332
+ $geofence->uuid,
333
+ $geofenceType,
334
+ 'vehicle'
335
+ )->delay(now()->addMinutes($geofence->dwell_threshold_minutes));
336
+
337
+ DB::table('vehicle_geofence_states')
338
+ ->where('vehicle_uuid', $vehicle->uuid)
339
+ ->where('geofence_uuid', $geofence->uuid)
340
+ ->update(['dwell_job_id' => (string) $dwellJob]);
341
+ }
342
+ } elseif ($crossing['type'] === 'exited') {
343
+ $state = DB::table('vehicle_geofence_states')
344
+ ->where('vehicle_uuid', $vehicle->uuid)
345
+ ->where('geofence_uuid', $geofence->uuid)
346
+ ->first();
347
+
348
+ $dwellMinutes = null;
349
+ if ($state && $state->entered_at) {
350
+ $dwellMinutes = (int) Carbon::parse($state->entered_at)->diffInMinutes(now());
351
+ }
352
+
353
+ DB::table('vehicle_geofence_states')
354
+ ->where('vehicle_uuid', $vehicle->uuid)
355
+ ->where('geofence_uuid', $geofence->uuid)
356
+ ->update([
357
+ 'is_inside' => false,
358
+ 'exited_at' => now(),
359
+ 'dwell_job_id' => null,
360
+ 'updated_at' => now(),
361
+ ]);
362
+
363
+ if ($geofence->trigger_on_exit) {
364
+ event(new GeofenceExited($vehicle, $geofence, $geofenceType, $newLocation, $dwellMinutes));
365
+ }
366
+ }
367
+ }
368
+ }
281
369
  }
@@ -24,7 +24,7 @@ class ZoneController extends Controller
24
24
  public function create(CreateZoneRequest $request)
25
25
  {
26
26
  // get request input
27
- $input = $request->only(['name', 'border', 'status', 'description', 'color', 'stroke_color']);
27
+ $input = $request->only(['name', 'border', 'status', 'description', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
28
28
 
29
29
  // get radius for creating zone border - default to 500 meters
30
30
  $radius = $request->input('radius', 500);
@@ -97,7 +97,7 @@ class ZoneController extends Controller
97
97
  }
98
98
 
99
99
  // get request input
100
- $input = $request->only(['name', 'border', 'status', 'description', 'color', 'stroke_color']);
100
+ $input = $request->only(['name', 'border', 'status', 'description', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
101
101
 
102
102
  // get radius for creating zone border - default to 500 meters
103
103
  $radius = $request->input('radius', 500);
@@ -0,0 +1,7 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
+
5
+ class GeofenceController extends \Fleetbase\FleetOps\Http\Controllers\Api\v1\GeofenceController
6
+ {
7
+ }
@@ -46,10 +46,14 @@ class CreateServiceAreaRequest extends FleetbaseRequest
46
46
 
47
47
  return false;
48
48
  })],
49
- 'parent' => ['nullable', 'exists:service_areas,public_id'],
50
- 'location' => ['nullable', new ResolvablePoint()],
51
- 'latitude' => ['nullable', 'required_with:longitude'],
52
- 'longitude' => ['nullable', 'required_with:latitude'],
49
+ 'parent' => ['nullable', 'exists:service_areas,public_id'],
50
+ 'location' => ['nullable', new ResolvablePoint()],
51
+ 'latitude' => ['nullable', 'required_with:longitude'],
52
+ 'longitude' => ['nullable', 'required_with:latitude'],
53
+ 'trigger_on_entry' => ['nullable', 'boolean'],
54
+ 'trigger_on_exit' => ['nullable', 'boolean'],
55
+ 'dwell_threshold_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'],
56
+ 'speed_limit_kmh' => ['nullable', 'integer', 'min:1', 'max:1000'],
53
57
  ];
54
58
  }
55
59
  }
@@ -45,10 +45,14 @@ class CreateZoneRequest extends FleetbaseRequest
45
45
 
46
46
  return false;
47
47
  })],
48
- 'location' => ['nullable', new ResolvablePoint()],
49
- 'latitude' => ['nullable', 'required_with:longitude'],
50
- 'longitude' => ['nullable', 'required_with:latitude'],
51
- 'status' => ['nullable', 'in:active,inactive'],
48
+ 'location' => ['nullable', new ResolvablePoint()],
49
+ 'latitude' => ['nullable', 'required_with:longitude'],
50
+ 'longitude' => ['nullable', 'required_with:latitude'],
51
+ 'status' => ['nullable', 'in:active,inactive'],
52
+ 'trigger_on_entry' => ['nullable', 'boolean'],
53
+ 'trigger_on_exit' => ['nullable', 'boolean'],
54
+ 'dwell_threshold_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'],
55
+ 'speed_limit_kmh' => ['nullable', 'integer', 'min:1', 'max:1000'],
52
56
  ];
53
57
  }
54
58
  }
@@ -0,0 +1,139 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Jobs;
4
+
5
+ use Fleetbase\FleetOps\Events\GeofenceDwelled;
6
+ use Fleetbase\FleetOps\Models\Driver;
7
+ use Fleetbase\FleetOps\Models\ServiceArea;
8
+ use Fleetbase\FleetOps\Models\Vehicle;
9
+ use Fleetbase\FleetOps\Models\Zone;
10
+ use Illuminate\Bus\Queueable;
11
+ use Illuminate\Contracts\Queue\ShouldQueue;
12
+ use Illuminate\Foundation\Bus\Dispatchable;
13
+ use Illuminate\Queue\InteractsWithQueue;
14
+ use Illuminate\Queue\SerializesModels;
15
+ use Illuminate\Support\Facades\DB;
16
+ use Illuminate\Support\Facades\Log;
17
+
18
+ /**
19
+ * CheckGeofenceDwell.
20
+ *
21
+ * A delayed queue job dispatched when a driver enters a geofence that has
22
+ * a dwell_threshold_minutes configured. The job is delayed by that number
23
+ * of minutes. When it runs, it checks whether the driver is still inside
24
+ * the geofence. If so, it fires the GeofenceDwelled event.
25
+ *
26
+ * If the driver exits before the job runs, the dwell_job_id in the state
27
+ * table is cleared, but the job still runs and exits silently because the
28
+ * state record will show is_inside = false.
29
+ *
30
+ * This job runs on the dedicated 'geofence' queue and has tries = 1 because
31
+ * dwell checks should not be retried — a failed check is simply missed.
32
+ */
33
+ class CheckGeofenceDwell implements ShouldQueue
34
+ {
35
+ use Dispatchable;
36
+ use InteractsWithQueue;
37
+ use Queueable;
38
+ use SerializesModels;
39
+
40
+ /**
41
+ * The number of times the job may be attempted.
42
+ * Dwell checks are fire-and-forget; retrying would produce false events.
43
+ */
44
+ public int $tries = 1;
45
+
46
+ /**
47
+ * The number of seconds the job can run before timing out.
48
+ */
49
+ public int $timeout = 30;
50
+
51
+ /**
52
+ * The UUID of the driver to check.
53
+ */
54
+ protected string $driverUuid;
55
+
56
+ /**
57
+ * The UUID of the geofence to check.
58
+ */
59
+ protected string $geofenceUuid;
60
+
61
+ /**
62
+ * The type of geofence: 'zone' or 'service_area'.
63
+ */
64
+ protected string $geofenceType;
65
+
66
+ /**
67
+ * The subject type for the dwell check: 'driver' or 'vehicle'.
68
+ */
69
+ protected string $subjectType;
70
+
71
+ /**
72
+ * Create a new CheckGeofenceDwell job.
73
+ *
74
+ * @param string $geofenceType 'zone' | 'service_area'
75
+ */
76
+ public function __construct(string $subjectUuid, string $geofenceUuid, string $geofenceType, string $subjectType = 'driver')
77
+ {
78
+ $this->driverUuid = $subjectUuid;
79
+ $this->geofenceUuid = $geofenceUuid;
80
+ $this->geofenceType = $geofenceType;
81
+ $this->subjectType = $subjectType;
82
+ $this->onQueue('geofence');
83
+ }
84
+
85
+ /**
86
+ * Execute the job.
87
+ */
88
+ public function handle(): void
89
+ {
90
+ // Check if the driver is still inside the geofence
91
+ $stateTable = $this->subjectType === 'vehicle' ? 'vehicle_geofence_states' : 'driver_geofence_states';
92
+ $subjectColumn = $this->subjectType === 'vehicle' ? 'vehicle_uuid' : 'driver_uuid';
93
+
94
+ $state = DB::table($stateTable)
95
+ ->where($subjectColumn, $this->driverUuid)
96
+ ->where('geofence_uuid', $this->geofenceUuid)
97
+ ->where('is_inside', true)
98
+ ->first();
99
+
100
+ if (!$state) {
101
+ // Driver has already exited; dwell event should not fire
102
+ return;
103
+ }
104
+
105
+ // Load the driver
106
+ $subject = $this->subjectType === 'vehicle'
107
+ ? Vehicle::where('uuid', $this->driverUuid)->withoutGlobalScopes()->first()
108
+ : Driver::where('uuid', $this->driverUuid)->withoutGlobalScopes()->first();
109
+
110
+ if (!$subject) {
111
+ Log::warning('CheckGeofenceDwell: Subject not found', [
112
+ 'subject_uuid' => $this->driverUuid,
113
+ 'subject_type' => $this->subjectType,
114
+ ]);
115
+
116
+ return;
117
+ }
118
+
119
+ // Load the geofence model
120
+ $geofence = $this->geofenceType === 'service_area'
121
+ ? ServiceArea::where('uuid', $this->geofenceUuid)->first()
122
+ : Zone::where('uuid', $this->geofenceUuid)->first();
123
+
124
+ if (!$geofence) {
125
+ Log::warning('CheckGeofenceDwell: Geofence not found', [
126
+ 'geofence_uuid' => $this->geofenceUuid,
127
+ 'geofence_type' => $this->geofenceType,
128
+ ]);
129
+
130
+ return;
131
+ }
132
+
133
+ // Parse the entry timestamp
134
+ $enteredAt = \Carbon\Carbon::parse($state->entered_at);
135
+
136
+ // Fire the dwell event
137
+ event(new GeofenceDwelled($subject, $geofence, $this->geofenceType, $enteredAt));
138
+ }
139
+ }
@@ -0,0 +1,56 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Listeners;
4
+
5
+ use Fleetbase\FleetOps\Events\GeofenceDwelled;
6
+ use Fleetbase\FleetOps\Models\GeofenceEventLog;
7
+ use Illuminate\Contracts\Queue\ShouldQueue;
8
+ use Illuminate\Queue\InteractsWithQueue;
9
+ use Illuminate\Support\Str;
10
+
11
+ /**
12
+ * HandleGeofenceDwelled.
13
+ *
14
+ * Handles the business logic triggered when a driver has dwelled inside
15
+ * a geofence for the configured threshold duration:
16
+ * 1. Writes a record to the geofence_events_log table with the dwell duration.
17
+ */
18
+ class HandleGeofenceDwelled implements ShouldQueue
19
+ {
20
+ use InteractsWithQueue;
21
+
22
+ /**
23
+ * The number of times the job may be attempted.
24
+ */
25
+ public int $tries = 3;
26
+
27
+ /**
28
+ * Handle the event.
29
+ */
30
+ public function handle(GeofenceDwelled $event): void
31
+ {
32
+ $driver = $event->driver;
33
+ $vehicle = $event->vehicle;
34
+ $order = $driver?->getCurrentOrder();
35
+ $subject = $event->subjectType === 'vehicle' ? $vehicle : $driver;
36
+
37
+ GeofenceEventLog::create([
38
+ 'uuid' => Str::uuid()->toString(),
39
+ 'company_uuid' => $event->getCompanyUuid(),
40
+ 'driver_uuid' => $driver?->uuid,
41
+ 'vehicle_uuid' => $vehicle?->uuid,
42
+ 'order_uuid' => $order?->uuid,
43
+ 'subject_uuid' => $subject?->uuid,
44
+ 'subject_type' => $event->subjectType,
45
+ 'subject_name' => $event->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
46
+ 'geofence_uuid' => $event->geofence->uuid,
47
+ 'geofence_type' => $event->geofenceType,
48
+ 'geofence_name' => $event->geofence->name,
49
+ 'event_type' => 'dwelled',
50
+ 'latitude' => null,
51
+ 'longitude' => null,
52
+ 'dwell_duration_minutes' => $event->dwellDurationMinutes,
53
+ 'occurred_at' => $event->timestamp,
54
+ ]);
55
+ }
56
+ }
@@ -0,0 +1,187 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Listeners;
4
+
5
+ use Fleetbase\FleetOps\Events\GeofenceEntered;
6
+ use Fleetbase\FleetOps\Models\GeofenceEventLog;
7
+ use Fleetbase\FleetOps\Models\Order;
8
+ use Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence;
9
+ use Illuminate\Contracts\Queue\ShouldQueue;
10
+ use Illuminate\Queue\InteractsWithQueue;
11
+ use Illuminate\Support\Facades\Log;
12
+ use Illuminate\Support\Str;
13
+
14
+ /**
15
+ * HandleGeofenceEntered.
16
+ *
17
+ * Handles the business logic triggered when a driver enters a geofence:
18
+ * 1. Writes a record to the geofence_events_log table.
19
+ * 2. Checks if the entered geofence corresponds to the driver's current
20
+ * order destination and auto-transitions the order to "arrived" status.
21
+ * 3. Sends a customer notification if the order destination matches.
22
+ */
23
+ class HandleGeofenceEntered implements ShouldQueue
24
+ {
25
+ use InteractsWithQueue;
26
+
27
+ /**
28
+ * The number of times the job may be attempted.
29
+ */
30
+ public int $tries = 3;
31
+
32
+ /**
33
+ * Handle the event.
34
+ */
35
+ public function handle(GeofenceEntered $event): void
36
+ {
37
+ $driver = $event->driver;
38
+ $vehicle = $event->vehicle;
39
+ $geofence = $event->geofence;
40
+
41
+ // Set company session context for any subsequent queries
42
+ session(['company' => $event->getCompanyUuid()]);
43
+
44
+ // ----------------------------------------------------------------
45
+ // 1. Write to the geofence event log
46
+ // ----------------------------------------------------------------
47
+ $order = $driver?->getCurrentOrder();
48
+ $subject = $event->subjectType === 'vehicle' ? $vehicle : $driver;
49
+
50
+ GeofenceEventLog::create([
51
+ 'uuid' => Str::uuid()->toString(),
52
+ 'company_uuid' => $event->getCompanyUuid(),
53
+ 'driver_uuid' => $driver?->uuid,
54
+ 'vehicle_uuid' => $vehicle?->uuid,
55
+ 'order_uuid' => $order?->uuid,
56
+ 'subject_uuid' => $subject?->uuid,
57
+ 'subject_type' => $event->subjectType,
58
+ 'subject_name' => $event->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
59
+ 'geofence_uuid' => $geofence->uuid,
60
+ 'geofence_type' => $event->geofenceType,
61
+ 'geofence_name' => $geofence->name,
62
+ 'event_type' => 'entered',
63
+ 'latitude' => $event->location->getLat(),
64
+ 'longitude' => $event->location->getLng(),
65
+ 'occurred_at' => $event->timestamp,
66
+ ]);
67
+
68
+ // ----------------------------------------------------------------
69
+ // 2. Order status automation
70
+ //
71
+ // If the driver has an active order and the geofence is in
72
+ // proximity to the current destination waypoint, auto-transition
73
+ // the order to "arrived" status and notify the customer.
74
+ // ----------------------------------------------------------------
75
+ if ($driver && $order) {
76
+ $this->handleOrderArrival($driver, $geofence, $order, $event);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Attempt to auto-transition an order to "arrived" status when the
82
+ * driver enters a geofence near the order's current destination.
83
+ */
84
+ private function handleOrderArrival($driver, $geofence, Order $order, GeofenceEntered $event): void
85
+ {
86
+ // Do not re-trigger if the order is already arrived or completed
87
+ if (in_array($order->status, ['arrived', 'completed', 'canceled'])) {
88
+ return;
89
+ }
90
+
91
+ // Get the current destination waypoint
92
+ $destination = null;
93
+ try {
94
+ $destination = $order->payload?->getPickupOrCurrentWaypoint();
95
+ } catch (\Throwable $e) {
96
+ Log::warning('GeofenceEntered: Could not resolve order destination', [
97
+ 'order_uuid' => $order->uuid,
98
+ 'driver_uuid' => $driver->uuid,
99
+ 'error' => $e->getMessage(),
100
+ ]);
101
+
102
+ return;
103
+ }
104
+
105
+ if (!$destination) {
106
+ return;
107
+ }
108
+
109
+ // Resolve the destination place
110
+ $place = null;
111
+ try {
112
+ $place = $destination->place ?? $destination->getPlace();
113
+ } catch (\Throwable $e) {
114
+ return;
115
+ }
116
+
117
+ if (!$place || !$place->location) {
118
+ return;
119
+ }
120
+
121
+ // Check proximity: is the geofence centroid within 500m of the destination place?
122
+ try {
123
+ $geofenceLat = $geofence->getLatitudeAttribute();
124
+ $geofenceLng = $geofence->getLongitudeAttribute();
125
+ } catch (\Throwable $e) {
126
+ return;
127
+ }
128
+
129
+ $placeLat = $place->location->getLat();
130
+ $placeLng = $place->location->getLng();
131
+
132
+ $distanceMeters = $this->haversineDistance($geofenceLat, $geofenceLng, $placeLat, $placeLng);
133
+
134
+ // Only trigger if the geofence is within 500m of the destination
135
+ if ($distanceMeters > 500) {
136
+ return;
137
+ }
138
+
139
+ // Auto-transition order to "arrived"
140
+ try {
141
+ $order->setStatus('arrived');
142
+ $order->createActivity(
143
+ [
144
+ 'status' => 'arrived',
145
+ 'details' => sprintf('Driver entered destination geofence "%s".', $geofence->name),
146
+ ],
147
+ $event->location
148
+ );
149
+ } catch (\Throwable $e) {
150
+ Log::error('GeofenceEntered: Failed to set order status to arrived', [
151
+ 'order_uuid' => $order->uuid,
152
+ 'error' => $e->getMessage(),
153
+ ]);
154
+
155
+ return;
156
+ }
157
+
158
+ // Notify the customer
159
+ if ($order->customer) {
160
+ try {
161
+ $order->customer->notify(new DriverArrivedAtGeofence($order, $geofence));
162
+ } catch (\Throwable $e) {
163
+ // Notification failure must not interrupt the geofence pipeline
164
+ Log::warning('GeofenceEntered: Failed to notify customer', [
165
+ 'order_uuid' => $order->uuid,
166
+ 'error' => $e->getMessage(),
167
+ ]);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Calculate the haversine distance in metres between two lat/lng points.
174
+ *
175
+ * @return float Distance in metres
176
+ */
177
+ private function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float
178
+ {
179
+ $earthRadius = 6371000; // metres
180
+ $dLat = deg2rad($lat2 - $lat1);
181
+ $dLng = deg2rad($lng2 - $lng1);
182
+ $a = sin($dLat / 2) ** 2
183
+ + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
184
+
185
+ return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
186
+ }
187
+ }
@@ -0,0 +1,56 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Listeners;
4
+
5
+ use Fleetbase\FleetOps\Events\GeofenceExited;
6
+ use Fleetbase\FleetOps\Models\GeofenceEventLog;
7
+ use Illuminate\Contracts\Queue\ShouldQueue;
8
+ use Illuminate\Queue\InteractsWithQueue;
9
+ use Illuminate\Support\Str;
10
+
11
+ /**
12
+ * HandleGeofenceExited.
13
+ *
14
+ * Handles the business logic triggered when a driver exits a geofence:
15
+ * 1. Writes a record to the geofence_events_log table, including the
16
+ * dwell duration calculated from the entry timestamp.
17
+ */
18
+ class HandleGeofenceExited implements ShouldQueue
19
+ {
20
+ use InteractsWithQueue;
21
+
22
+ /**
23
+ * The number of times the job may be attempted.
24
+ */
25
+ public int $tries = 3;
26
+
27
+ /**
28
+ * Handle the event.
29
+ */
30
+ public function handle(GeofenceExited $event): void
31
+ {
32
+ $driver = $event->driver;
33
+ $vehicle = $event->vehicle;
34
+ $order = $driver?->getCurrentOrder();
35
+ $subject = $event->subjectType === 'vehicle' ? $vehicle : $driver;
36
+
37
+ GeofenceEventLog::create([
38
+ 'uuid' => Str::uuid()->toString(),
39
+ 'company_uuid' => $event->getCompanyUuid(),
40
+ 'driver_uuid' => $driver?->uuid,
41
+ 'vehicle_uuid' => $vehicle?->uuid,
42
+ 'order_uuid' => $order?->uuid,
43
+ 'subject_uuid' => $subject?->uuid,
44
+ 'subject_type' => $event->subjectType,
45
+ 'subject_name' => $event->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
46
+ 'geofence_uuid' => $event->geofence->uuid,
47
+ 'geofence_type' => $event->geofenceType,
48
+ 'geofence_name' => $event->geofence->name,
49
+ 'event_type' => 'exited',
50
+ 'latitude' => $event->location->getLat(),
51
+ 'longitude' => $event->location->getLng(),
52
+ 'dwell_duration_minutes' => $event->dwellDurationMinutes,
53
+ 'occurred_at' => $event->timestamp,
54
+ ]);
55
+ }
56
+ }