@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
@@ -0,0 +1,155 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Models;
4
+
5
+ use Fleetbase\Traits\HasUuid;
6
+ use Illuminate\Database\Eloquent\Model;
7
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
8
+
9
+ /**
10
+ * GeofenceEventLog.
11
+ *
12
+ * Persistent audit log of all geofence events (entered, exited, dwelled).
13
+ * Powers the reporting dashboard, dwell time analytics, and geofence
14
+ * activity history views.
15
+ *
16
+ * @property string $uuid
17
+ * @property string $company_uuid
18
+ * @property string $driver_uuid
19
+ * @property string|null $vehicle_uuid
20
+ * @property string|null $order_uuid
21
+ * @property string $geofence_uuid
22
+ * @property string $geofence_type
23
+ * @property string|null $geofence_name
24
+ * @property string $event_type
25
+ * @property float|null $latitude
26
+ * @property float|null $longitude
27
+ * @property float|null $speed_kmh
28
+ * @property int|null $dwell_duration_minutes
29
+ * @property \Carbon\Carbon $occurred_at
30
+ */
31
+ class GeofenceEventLog extends Model
32
+ {
33
+ use HasUuid;
34
+
35
+ /**
36
+ * The database table used by the model.
37
+ *
38
+ * @var string
39
+ */
40
+ protected $table = 'geofence_events_log';
41
+
42
+ /**
43
+ * The primary key for the model.
44
+ *
45
+ * @var string
46
+ */
47
+ protected $primaryKey = 'uuid';
48
+
49
+ /**
50
+ * The "type" of the primary key ID.
51
+ *
52
+ * @var string
53
+ */
54
+ protected $keyType = 'string';
55
+
56
+ /**
57
+ * Indicates if the IDs are auto-incrementing.
58
+ *
59
+ * @var bool
60
+ */
61
+ public $incrementing = false;
62
+
63
+ /**
64
+ * The attributes that are mass assignable.
65
+ *
66
+ * @var array
67
+ */
68
+ protected $fillable = [
69
+ 'uuid',
70
+ 'company_uuid',
71
+ 'driver_uuid',
72
+ 'vehicle_uuid',
73
+ 'order_uuid',
74
+ 'subject_uuid',
75
+ 'subject_type',
76
+ 'subject_name',
77
+ 'geofence_uuid',
78
+ 'geofence_type',
79
+ 'geofence_name',
80
+ 'event_type',
81
+ 'latitude',
82
+ 'longitude',
83
+ 'speed_kmh',
84
+ 'dwell_duration_minutes',
85
+ 'occurred_at',
86
+ ];
87
+
88
+ /**
89
+ * The attributes that should be cast to native types.
90
+ *
91
+ * @var array
92
+ */
93
+ protected $casts = [
94
+ 'occurred_at' => 'datetime',
95
+ 'latitude' => 'float',
96
+ 'longitude' => 'float',
97
+ 'speed_kmh' => 'float',
98
+ 'dwell_duration_minutes' => 'integer',
99
+ ];
100
+
101
+ /**
102
+ * The attributes excluded from the model's JSON form.
103
+ *
104
+ * @var array
105
+ */
106
+ protected $hidden = [];
107
+
108
+ /**
109
+ * Get the driver associated with this event.
110
+ */
111
+ public function driver(): BelongsTo
112
+ {
113
+ return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid');
114
+ }
115
+
116
+ /**
117
+ * Get the order associated with this event (if any).
118
+ */
119
+ public function order(): BelongsTo
120
+ {
121
+ return $this->belongsTo(Order::class, 'order_uuid', 'uuid');
122
+ }
123
+
124
+ /**
125
+ * Get the vehicle associated with this event (if any).
126
+ */
127
+ public function vehicle(): BelongsTo
128
+ {
129
+ return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid');
130
+ }
131
+
132
+ /**
133
+ * Scope to filter by company.
134
+ *
135
+ * @param \Illuminate\Database\Eloquent\Builder $query
136
+ *
137
+ * @return \Illuminate\Database\Eloquent\Builder
138
+ */
139
+ public function scopeForCompany($query, string $companyUuid)
140
+ {
141
+ return $query->where('company_uuid', $companyUuid);
142
+ }
143
+
144
+ /**
145
+ * Scope to filter by event type.
146
+ *
147
+ * @param \Illuminate\Database\Eloquent\Builder $query
148
+ *
149
+ * @return \Illuminate\Database\Eloquent\Builder
150
+ */
151
+ public function scopeOfType($query, string $type)
152
+ {
153
+ return $query->where('event_type', $type);
154
+ }
155
+ }
@@ -60,7 +60,22 @@ class ServiceArea extends Model
60
60
  *
61
61
  * @var array
62
62
  */
63
- protected $fillable = ['_key', 'company_uuid', 'name', 'type', 'parent_uuid', 'border', 'color', 'stroke_color', 'status', 'country'];
63
+ protected $fillable = [
64
+ '_key',
65
+ 'company_uuid',
66
+ 'name',
67
+ 'type',
68
+ 'parent_uuid',
69
+ 'border',
70
+ 'color',
71
+ 'stroke_color',
72
+ 'status',
73
+ 'country',
74
+ 'trigger_on_entry',
75
+ 'trigger_on_exit',
76
+ 'dwell_threshold_minutes',
77
+ 'speed_limit_kmh',
78
+ ];
64
79
 
65
80
  /**
66
81
  * The attributes that are spatial columns.
@@ -96,7 +111,11 @@ class ServiceArea extends Model
96
111
  * @var array
97
112
  */
98
113
  protected $casts = [
99
- 'border' => MultiPolygonCast::class,
114
+ 'border' => MultiPolygonCast::class,
115
+ 'trigger_on_entry' => 'boolean',
116
+ 'trigger_on_exit' => 'boolean',
117
+ 'dwell_threshold_minutes' => 'integer',
118
+ 'speed_limit_kmh' => 'integer',
100
119
  ];
101
120
 
102
121
  /**
@@ -3,6 +3,8 @@
3
3
  namespace Fleetbase\FleetOps\Models;
4
4
 
5
5
  use Fleetbase\Casts\Json;
6
+ use Fleetbase\FleetOps\Http\Requests\CreateOrderRequest;
7
+ use Fleetbase\FleetOps\Http\Requests\UpdateOrderRequest;
6
8
  use Fleetbase\FleetOps\Integrations\Lalamove\Lalamove;
7
9
  use Fleetbase\FleetOps\Support\Payment;
8
10
  use Fleetbase\FleetOps\Support\Utils;
@@ -136,7 +138,7 @@ class ServiceQuote extends Model
136
138
  return Lalamove::serviceQuoteFromQuotation($quotation);
137
139
  }
138
140
 
139
- public static function resolveFromRequest(Request $request): ?ServiceQuote
141
+ public static function resolveFromRequest(Request|CreateOrderRequest|UpdateOrderRequest $request): ?ServiceQuote
140
142
  {
141
143
  $serviceQuote = $request->or(['order.service_quote_uuid', 'service_quote', 'service_quote_id', 'order.service_quote']);
142
144
 
@@ -62,7 +62,21 @@ class Zone extends Model
62
62
  *
63
63
  * @var array
64
64
  */
65
- protected $fillable = ['_key', 'company_uuid', 'service_area_uuid', 'name', 'description', 'border', 'color', 'stroke_color', 'status'];
65
+ protected $fillable = [
66
+ '_key',
67
+ 'company_uuid',
68
+ 'service_area_uuid',
69
+ 'name',
70
+ 'description',
71
+ 'border',
72
+ 'color',
73
+ 'stroke_color',
74
+ 'status',
75
+ 'trigger_on_entry',
76
+ 'trigger_on_exit',
77
+ 'dwell_threshold_minutes',
78
+ 'speed_limit_kmh',
79
+ ];
66
80
 
67
81
  /**
68
82
  * The attributes that should be cast to native types.
@@ -70,7 +84,11 @@ class Zone extends Model
70
84
  * @var array
71
85
  */
72
86
  protected $casts = [
73
- 'border' => PolygonCast::class,
87
+ 'border' => PolygonCast::class,
88
+ 'trigger_on_entry' => 'boolean',
89
+ 'trigger_on_exit' => 'boolean',
90
+ 'dwell_threshold_minutes' => 'integer',
91
+ 'speed_limit_kmh' => 'integer',
74
92
  ];
75
93
 
76
94
  /**
@@ -0,0 +1,86 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Notifications;
4
+
5
+ use Fleetbase\FleetOps\Models\Order;
6
+ use Illuminate\Bus\Queueable;
7
+ use Illuminate\Notifications\Messages\MailMessage;
8
+ use Illuminate\Notifications\Notification;
9
+
10
+ /**
11
+ * DriverArrivedAtGeofence.
12
+ *
13
+ * Sent to the order customer when a driver enters the geofence associated
14
+ * with the order's current destination waypoint.
15
+ *
16
+ * Delivery channels: mail and database.
17
+ */
18
+ class DriverArrivedAtGeofence extends Notification
19
+ {
20
+ use Queueable;
21
+
22
+ /**
23
+ * The order associated with the arrival.
24
+ */
25
+ protected Order $order;
26
+
27
+ /**
28
+ * The geofence that was entered (Zone or ServiceArea).
29
+ */
30
+ protected $geofence;
31
+
32
+ /**
33
+ * Create a new DriverArrivedAtGeofence notification.
34
+ *
35
+ * @param mixed $geofence Zone or ServiceArea
36
+ */
37
+ public function __construct(Order $order, $geofence)
38
+ {
39
+ $this->order = $order;
40
+ $this->geofence = $geofence;
41
+ }
42
+
43
+ /**
44
+ * Get the notification's delivery channels.
45
+ */
46
+ public function via($notifiable): array
47
+ {
48
+ return ['mail', 'database'];
49
+ }
50
+
51
+ /**
52
+ * Get the mail representation of the notification.
53
+ */
54
+ public function toMail($notifiable): MailMessage
55
+ {
56
+ $geofenceName = $this->geofence->name ?? 'your location';
57
+ $orderId = $this->order->public_id ?? $this->order->uuid;
58
+
59
+ return (new MailMessage())
60
+ ->subject("Your driver has arrived — Order #{$orderId}")
61
+ ->greeting('Good news!')
62
+ ->line("Your driver has arrived at {$geofenceName} for order #{$orderId}.")
63
+ ->line('Please be ready to receive your delivery.')
64
+ ->action('Track Your Order', url("/tracking/{$this->order->tracking_number}"))
65
+ ->line('Thank you for using our service.');
66
+ }
67
+
68
+ /**
69
+ * Get the array representation of the notification (database channel).
70
+ */
71
+ public function toArray($notifiable): array
72
+ {
73
+ return [
74
+ 'event' => 'driver.arrived_at_geofence',
75
+ 'order_id' => $this->order->public_id,
76
+ 'order_uuid' => $this->order->uuid,
77
+ 'geofence_id' => $this->geofence->public_id ?? null,
78
+ 'geofence_name' => $this->geofence->name ?? null,
79
+ 'message' => sprintf(
80
+ 'Your driver has arrived at %s for order #%s.',
81
+ $this->geofence->name ?? 'your location',
82
+ $this->order->public_id
83
+ ),
84
+ ];
85
+ }
86
+ }
@@ -70,9 +70,9 @@ class OrchestrationPayloadBuilder
70
70
 
71
71
  foreach ($payload->entities as $entity) {
72
72
  // --- Weight ---
73
- $rawWeight = (float) ($entity->weight ?? 0);
73
+ $rawWeight = (float) ($entity->weight ?? 0);
74
74
  $weightUnit = strtolower($entity->weight_unit ?? 'kg');
75
- $weightKg = match ($weightUnit) {
75
+ $weightKg = match ($weightUnit) {
76
76
  'g', 'gram', 'grams' => $rawWeight / 1000,
77
77
  'lb', 'lbs', 'pound', 'pounds' => $rawWeight * 0.453592,
78
78
  'oz', 'ounce', 'ounces' => $rawWeight * 0.0283495,
@@ -23,6 +23,25 @@ class EventServiceProvider extends ServiceProvider
23
23
  \Fleetbase\FleetOps\Events\OrderFailed::class => [\Fleetbase\Listeners\SendResourceLifecycleWebhook::class, \Fleetbase\FleetOps\Listeners\NotifyOrderEvent::class],
24
24
  \Fleetbase\FleetOps\Events\OrderReady::class => [\Fleetbase\FleetOps\Listeners\HandleOrderReady::class],
25
25
 
26
+ /*
27
+ * Geofence Events
28
+ *
29
+ * Each event is handled by a domain listener (business logic, event log)
30
+ * and the generic SendResourceLifecycleWebhook listener (webhook delivery).
31
+ */
32
+ \Fleetbase\FleetOps\Events\GeofenceEntered::class => [
33
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceEntered::class,
34
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
35
+ ],
36
+ \Fleetbase\FleetOps\Events\GeofenceExited::class => [
37
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceExited::class,
38
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
39
+ ],
40
+ \Fleetbase\FleetOps\Events\GeofenceDwelled::class => [
41
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceDwelled::class,
42
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
43
+ ],
44
+
26
45
  /*
27
46
  * Core Events
28
47
  */
@@ -61,6 +61,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
61
61
  \Fleetbase\FleetOps\Console\Commands\PurgeUnpurchasedServiceQuotes::class,
62
62
  \Fleetbase\FleetOps\Console\Commands\SendDriverNotification::class,
63
63
  \Fleetbase\FleetOps\Console\Commands\ReplayVehicleLocations::class,
64
+ \Fleetbase\FleetOps\Console\Commands\SimulateGeofenceEvents::class,
64
65
  \Fleetbase\FleetOps\Console\Commands\TestEmail::class,
65
66
  \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class,
66
67
  \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class,
@@ -84,6 +85,14 @@ class FleetOpsServiceProvider extends CoreServiceProvider
84
85
  $this->app->register(CoreServiceProvider::class);
85
86
  $this->app->register(ReportSchemaServiceProvider::class);
86
87
 
88
+ // Register the GeofenceIntersectionService as a singleton so that
89
+ // the same instance is reused across the request lifecycle, avoiding
90
+ // repeated instantiation on high-frequency location update calls.
91
+ $this->app->singleton(
92
+ \Fleetbase\FleetOps\Support\GeofenceIntersectionService::class,
93
+ fn () => new \Fleetbase\FleetOps\Support\GeofenceIntersectionService()
94
+ );
95
+
87
96
  // Register the OrchestrationEngineRegistry as a singleton so that engines
88
97
  // registered from any service provider share the same instance.
89
98
  $this->app->singleton(
@@ -155,6 +164,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
155
164
  \Fleetbase\FleetOps\Notifications\OrderPing::class,
156
165
  \Fleetbase\FleetOps\Notifications\OrderFailed::class,
157
166
  \Fleetbase\FleetOps\Notifications\OrderCompleted::class,
167
+ \Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence::class,
158
168
  ]);
159
169
 
160
170
  // Register Notifiables
@@ -0,0 +1,206 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\ServiceArea;
7
+ use Fleetbase\FleetOps\Models\Vehicle;
8
+ use Fleetbase\FleetOps\Models\Zone;
9
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
10
+ use Illuminate\Support\Facades\DB;
11
+
12
+ /**
13
+ * GeofenceIntersectionService.
14
+ *
15
+ * The core spatial engine for the active geofencing system.
16
+ *
17
+ * On every driver location update, this service:
18
+ * 1. Queries all zones and service areas containing the new point using
19
+ * a two-pass MySQL spatial strategy (MBRContains for index-assisted
20
+ * bounding-box pre-filter, then ST_Contains for precise polygon check).
21
+ * 2. Compares the result against the driver's current state in the
22
+ * driver_geofence_states table.
23
+ * 3. Returns a list of crossing events (entered / exited) for the caller
24
+ * to dispatch as Laravel events.
25
+ *
26
+ * This class is registered as a singleton in FleetOpsServiceProvider.
27
+ */
28
+ class GeofenceIntersectionService
29
+ {
30
+ /**
31
+ * Detect geofence boundary crossings for a driver given their new location.
32
+ *
33
+ * Returns an array of crossing events. Each element is an associative array:
34
+ * - 'type': 'entered' | 'exited'
35
+ * - 'geofence': Zone or ServiceArea model instance
36
+ * - 'geofence_type': 'zone' | 'service_area'
37
+ */
38
+ public function detectCrossings(Driver|Vehicle $subject, Point $newLocation): array
39
+ {
40
+ if ($subject instanceof Driver) {
41
+ return $this->detectDriverCrossings($subject, $newLocation);
42
+ }
43
+
44
+ return $this->detectVehicleCrossings($subject, $newLocation);
45
+ }
46
+
47
+ public function detectDriverCrossings(Driver $driver, Point $newLocation): array
48
+ {
49
+ return $this->detectSubjectCrossings($driver->company_uuid, $newLocation, 'driver_geofence_states', 'driver_uuid', $driver->uuid);
50
+ }
51
+
52
+ public function detectVehicleCrossings(Vehicle $vehicle, Point $newLocation): array
53
+ {
54
+ return $this->detectSubjectCrossings($vehicle->company_uuid, $newLocation, 'vehicle_geofence_states', 'vehicle_uuid', $vehicle->uuid);
55
+ }
56
+
57
+ protected function detectSubjectCrossings(string $companyUuid, Point $newLocation, string $stateTable, string $subjectColumn, string $subjectUuid): array
58
+ {
59
+ $crossings = [];
60
+
61
+ // Build the WKT point string. MySQL ST_GeomFromText expects (lng lat) order.
62
+ $wkt = sprintf('POINT(%s %s)', $newLocation->getLng(), $newLocation->getLat());
63
+
64
+ // ----------------------------------------------------------------
65
+ // 1. Find all Zones the driver is currently inside.
66
+ //
67
+ // Two-pass strategy for MySQL performance:
68
+ // - MBRContains uses the SPATIAL index to filter by minimum
69
+ // bounding rectangle (fast, may include false positives).
70
+ // - ST_Contains performs the precise polygon containment check
71
+ // on the reduced candidate set (accurate, no index needed).
72
+ // ----------------------------------------------------------------
73
+ $insideZones = Zone::where('company_uuid', $companyUuid)
74
+ ->whereNotNull('border')
75
+ ->where(function ($q) {
76
+ $q->where('trigger_on_entry', true)
77
+ ->orWhere('trigger_on_exit', true)
78
+ ->orWhereNotNull('dwell_threshold_minutes');
79
+ })
80
+ ->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
81
+ ->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
82
+ ->get();
83
+
84
+ // ----------------------------------------------------------------
85
+ // 2. Find all Service Areas the driver is currently inside.
86
+ //
87
+ // Service areas use MultiPolygon borders, so we use
88
+ // ST_Contains which handles both Polygon and MultiPolygon.
89
+ // ----------------------------------------------------------------
90
+ $insideServiceAreas = ServiceArea::where('company_uuid', $companyUuid)
91
+ ->whereNotNull('border')
92
+ ->where(function ($q) {
93
+ $q->where('trigger_on_entry', true)
94
+ ->orWhere('trigger_on_exit', true)
95
+ ->orWhereNotNull('dwell_threshold_minutes');
96
+ })
97
+ ->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
98
+ ->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
99
+ ->get();
100
+
101
+ // Merge into a unified collection with a type discriminator
102
+ $currentlyInside = collect()
103
+ ->merge($insideZones->map(fn ($z) => ['model' => $z, 'geofence_type' => 'zone']))
104
+ ->merge($insideServiceAreas->map(fn ($sa) => ['model' => $sa, 'geofence_type' => 'service_area']));
105
+
106
+ $currentlyInsideUuids = $currentlyInside->pluck('model.uuid')->toArray();
107
+
108
+ // ----------------------------------------------------------------
109
+ // 3. Load the driver's current geofence state records.
110
+ // ----------------------------------------------------------------
111
+ $currentStates = DB::table($stateTable)
112
+ ->where($subjectColumn, $subjectUuid)
113
+ ->get()
114
+ ->keyBy('geofence_uuid');
115
+
116
+ // ----------------------------------------------------------------
117
+ // 4. Detect ENTRIES: geofences the driver is now inside but
118
+ // was not inside before (or has no state record yet).
119
+ // ----------------------------------------------------------------
120
+ foreach ($currentlyInside as $item) {
121
+ $geofence = $item['model'];
122
+ $state = $currentStates->get($geofence->uuid);
123
+
124
+ if (!$state || !$state->is_inside) {
125
+ $crossings[] = [
126
+ 'type' => 'entered',
127
+ 'geofence' => $geofence,
128
+ 'geofence_type' => $item['geofence_type'],
129
+ ];
130
+ }
131
+ }
132
+
133
+ // ----------------------------------------------------------------
134
+ // 5. Detect EXITS: geofences the driver was inside but is no
135
+ // longer inside (not in the current ST_Contains result set).
136
+ // ----------------------------------------------------------------
137
+ foreach ($currentStates as $geofenceUuid => $state) {
138
+ if ($state->is_inside && !in_array($geofenceUuid, $currentlyInsideUuids)) {
139
+ $geofence = $state->geofence_type === 'service_area'
140
+ ? ServiceArea::where('uuid', $geofenceUuid)->first()
141
+ : Zone::where('uuid', $geofenceUuid)->first();
142
+
143
+ if ($geofence) {
144
+ $crossings[] = [
145
+ 'type' => 'exited',
146
+ 'geofence' => $geofence,
147
+ 'geofence_type' => $state->geofence_type,
148
+ ];
149
+ }
150
+ }
151
+ }
152
+
153
+ return $crossings;
154
+ }
155
+
156
+ /**
157
+ * Check if a driver is currently recorded as inside a specific geofence.
158
+ *
159
+ * @param mixed $geofence Zone or ServiceArea
160
+ */
161
+ public function isDriverInsideGeofence(Driver $driver, $geofence): bool
162
+ {
163
+ return DB::table('driver_geofence_states')
164
+ ->where('driver_uuid', $driver->uuid)
165
+ ->where('geofence_uuid', $geofence->uuid)
166
+ ->where('is_inside', true)
167
+ ->exists();
168
+ }
169
+
170
+ public function isVehicleInsideGeofence(Vehicle $vehicle, $geofence): bool
171
+ {
172
+ return DB::table('vehicle_geofence_states')
173
+ ->where('vehicle_uuid', $vehicle->uuid)
174
+ ->where('geofence_uuid', $geofence->uuid)
175
+ ->where('is_inside', true)
176
+ ->exists();
177
+ }
178
+
179
+ /**
180
+ * Clear all geofence state records for a driver.
181
+ * Called when a driver goes offline or completes a shift.
182
+ */
183
+ public function clearDriverState(Driver $driver): void
184
+ {
185
+ DB::table('driver_geofence_states')
186
+ ->where('driver_uuid', $driver->uuid)
187
+ ->update([
188
+ 'is_inside' => false,
189
+ 'exited_at' => now(),
190
+ 'dwell_job_id' => null,
191
+ 'updated_at' => now(),
192
+ ]);
193
+ }
194
+
195
+ public function clearVehicleState(Vehicle $vehicle): void
196
+ {
197
+ DB::table('vehicle_geofence_states')
198
+ ->where('vehicle_uuid', $vehicle->uuid)
199
+ ->update([
200
+ 'is_inside' => false,
201
+ 'exited_at' => now(),
202
+ 'dwell_job_id' => null,
203
+ 'updated_at' => now(),
204
+ ]);
205
+ }
206
+ }
@@ -145,6 +145,13 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
145
145
  $router->put('{id}', 'ServiceAreaController@update');
146
146
  $router->delete('{id}', 'ServiceAreaController@delete');
147
147
  });
148
+ // geofences routes
149
+ $router->group(['prefix' => 'geofences'], function () use ($router) {
150
+ $router->get('events', 'GeofenceController@events');
151
+ $router->get('inventory', 'GeofenceController@inventory');
152
+ $router->get('dwell-report', 'GeofenceController@dwellReport');
153
+ $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory');
154
+ });
148
155
  // service-rates routes
149
156
  $router->group(['prefix' => 'service-rates'], function () use ($router) {
150
157
  $router->post('/', 'ServiceRateController@create');
@@ -375,6 +382,15 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
375
382
  $router->delete('bulk-delete', $controller('bulkDelete'));
376
383
  }
377
384
  );
385
+ $router->group(
386
+ ['prefix' => 'geofences'],
387
+ function () use ($router) {
388
+ $router->get('events', 'GeofenceController@events');
389
+ $router->get('inventory', 'GeofenceController@inventory');
390
+ $router->get('dwell-report', 'GeofenceController@dwellReport');
391
+ $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory');
392
+ }
393
+ );
378
394
  $router->fleetbaseRoutes('zones');
379
395
  $router->fleetbaseRoutes(
380
396
  'service-quotes',
@@ -1,27 +0,0 @@
1
- <Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
2
- <div class="modal-body-container">
3
- <div class="flex px-3 py-2 mb-6 rounded-md shadow-sm bg-blue-200">
4
- <div>
5
- <FaIcon @icon="info-circle" class="text-blue-900 mr-4" />
6
- </div>
7
- <p class="flex-1 text-sm text-blue-900 dark:text-blue-900">
8
- {{t "modals.set-password.message"}}
9
- </p>
10
- </div>
11
-
12
- <InputGroup
13
- @name={{t "modals.set-password.new-password"}}
14
- @value={{@options.password}}
15
- @type="password"
16
- @inputClass="form-input-lg"
17
- @helpText={{t "modals.set-password.help-text"}}
18
- />
19
- <InputGroup
20
- @name={{t "modals.set-password.confirm-password"}}
21
- @value={{@options.password_confirmation}}
22
- @type="password"
23
- @inputClass="form-input-lg"
24
- @helpText={{t "modals.set-password.help-text"}}
25
- />
26
- </div>
27
- </Modal::Default>