@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.
- package/addon/components/entity/form.hbs +24 -0
- package/addon/components/entity/form.js +30 -0
- package/addon/components/leaflet-draw-control.js +1 -1
- package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
- package/addon/components/map/drawer/geofence-event-listing.js +103 -0
- package/addon/components/map/drawer.js +1 -0
- package/addon/components/map/leaflet-live-map.js +83 -0
- package/addon/components/modals/orchestrator-import.js +2 -78
- package/addon/components/order/form/service-rate.hbs +1 -1
- package/addon/components/service-area/form.hbs +59 -0
- package/addon/components/service-rate/details.hbs +6 -6
- package/addon/components/service-rate/form.hbs +28 -15
- package/addon/components/zone/form.hbs +60 -1
- package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
- package/addon/controllers/operations/orders/index/details.js +27 -2
- package/addon/controllers/operations/service-rates/index/edit.js +1 -0
- package/addon/controllers/operations/service-rates/index/new.js +1 -0
- package/addon/helpers/f-to-int.js +6 -0
- package/addon/routes/application.js +3 -3
- package/addon/routes/operations/orders/index/details/virtual.js +1 -0
- package/addon/routes/operations/orders/index/details.js +19 -3
- package/addon/services/equipment-actions.js +4 -6
- package/addon/services/geofence-event-bus.js +223 -0
- package/addon/services/leaflet-map-manager.js +4 -0
- package/addon/services/maintenance-actions.js +4 -6
- package/addon/services/part-actions.js +4 -6
- package/addon/services/service-rate-actions.js +8 -3
- package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
- package/addon/utils/fleet-ops-options.js +33 -0
- package/addon/utils/serialize-payload.js +1 -1
- package/app/components/map/drawer/geofence-event-listing.js +1 -0
- package/app/controllers/operations/orders/index/details/virtual.js +1 -0
- package/app/helpers/f-to-int.js +1 -0
- package/app/services/geofence-event-bus.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +2 -2
- package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
- package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
- package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
- package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
- package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
- package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
- package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
- package/server/src/Events/GeofenceDwelled.php +208 -0
- package/server/src/Events/GeofenceEntered.php +207 -0
- package/server/src/Events/GeofenceExited.php +213 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
- package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
- package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
- package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
- package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
- package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
- package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
- package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
- package/server/src/Http/Resources/v1/Order.php +0 -1
- package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
- package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
- package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
- package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
- package/server/src/Listeners/HandleGeofenceExited.php +56 -0
- package/server/src/Models/Entity.php +7 -0
- package/server/src/Models/GeofenceEventLog.php +155 -0
- package/server/src/Models/Order.php +39 -7
- package/server/src/Models/ServiceArea.php +21 -2
- package/server/src/Models/ServiceQuote.php +3 -1
- package/server/src/Models/ServiceRate.php +122 -30
- package/server/src/Models/Zone.php +20 -2
- package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
- package/server/src/Observers/PurchaseRateObserver.php +3 -5
- package/server/src/Providers/EventServiceProvider.php +19 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
- package/server/src/Rules/ComputableAlgo.php +1 -3
- package/server/src/Support/Algo.php +87 -1
- package/server/src/Support/GeofenceIntersectionService.php +206 -0
- package/server/src/Support/Utils.php +35 -0
- package/server/src/routes.php +16 -0
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
* GeofenceExited.
|
|
16
|
+
*
|
|
17
|
+
* Fired when a driver's location update causes them to cross out of a
|
|
18
|
+
* geofence boundary (Zone or ServiceArea) that they were previously inside.
|
|
19
|
+
*
|
|
20
|
+
* Listeners:
|
|
21
|
+
* - HandleGeofenceExited (event log)
|
|
22
|
+
* - SendResourceLifecycleWebhook (webhook delivery)
|
|
23
|
+
*/
|
|
24
|
+
class GeofenceExited 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 = 'exited';
|
|
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 exited 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 exited (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 exit.
|
|
67
|
+
*/
|
|
68
|
+
public Point $location;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The timestamp when the exit was detected.
|
|
72
|
+
*/
|
|
73
|
+
public \Carbon\Carbon $timestamp;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How many minutes the driver was inside the geofence before exiting.
|
|
77
|
+
* Null if the entry time was not recorded.
|
|
78
|
+
*/
|
|
79
|
+
public ?int $dwellDurationMinutes;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The subject that triggered the event: 'driver' or 'vehicle'.
|
|
83
|
+
*/
|
|
84
|
+
public string $subjectType;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a new GeofenceExited event.
|
|
88
|
+
*
|
|
89
|
+
* @param mixed $geofence Zone or ServiceArea
|
|
90
|
+
* @param string $geofenceType 'zone' | 'service_area'
|
|
91
|
+
*/
|
|
92
|
+
public function __construct(Driver|Vehicle $subject, $geofence, string $geofenceType, Point $location, ?int $dwellDurationMinutes = null)
|
|
93
|
+
{
|
|
94
|
+
if ($subject instanceof Driver) {
|
|
95
|
+
$this->driver = $subject;
|
|
96
|
+
$this->vehicle = $subject->vehicle;
|
|
97
|
+
$this->subjectType = 'driver';
|
|
98
|
+
} else {
|
|
99
|
+
$subject->loadMissing('driver');
|
|
100
|
+
$this->vehicle = $subject;
|
|
101
|
+
$this->driver = $subject->driver;
|
|
102
|
+
$this->subjectType = 'vehicle';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
$this->geofence = $geofence;
|
|
106
|
+
$this->geofenceType = $geofenceType;
|
|
107
|
+
$this->location = $location;
|
|
108
|
+
$this->timestamp = now();
|
|
109
|
+
$this->dwellDurationMinutes = $dwellDurationMinutes;
|
|
110
|
+
$this->modelRecordName = $geofence->name ?? $geofence->public_id ?? $geofence->uuid ?? null;
|
|
111
|
+
$this->userSession = session('user');
|
|
112
|
+
$this->companySession = session('company', $this->getCompanyUuid());
|
|
113
|
+
$this->requestMethod = request()?->method() ?? 'CLI';
|
|
114
|
+
$this->apiCredential = session('api_credential', 'console');
|
|
115
|
+
$this->apiSecret = session('api_secret', 'internal');
|
|
116
|
+
$this->apiKey = session('api_key');
|
|
117
|
+
$this->apiEnvironment = session('api_environment', 'live');
|
|
118
|
+
$this->isSandbox = session('is_sandbox', false);
|
|
119
|
+
$this->data = $this->getEventData();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the company UUID for this event.
|
|
124
|
+
*/
|
|
125
|
+
public function getCompanyUuid(): string
|
|
126
|
+
{
|
|
127
|
+
return $this->vehicle?->company_uuid ?? $this->driver->company_uuid;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public function broadcastOn(): array
|
|
131
|
+
{
|
|
132
|
+
$channels = [new Channel('company.' . $this->getCompanyUuid())];
|
|
133
|
+
|
|
134
|
+
if ($this->driver) {
|
|
135
|
+
$channels[] = new Channel('driver.' . $this->driver->public_id);
|
|
136
|
+
$channels[] = new Channel('driver.' . $this->driver->uuid);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ($this->vehicle) {
|
|
140
|
+
$channels[] = new Channel('vehicle.' . $this->vehicle->public_id);
|
|
141
|
+
$channels[] = new Channel('vehicle.' . $this->vehicle->uuid);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return $channels;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public function broadcastAs(): string
|
|
148
|
+
{
|
|
149
|
+
return 'geofence.exited';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns the standardised webhook payload for this event.
|
|
154
|
+
*/
|
|
155
|
+
public function broadcastWith(): array
|
|
156
|
+
{
|
|
157
|
+
return $this->getEventData();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public function getEventData(): array
|
|
161
|
+
{
|
|
162
|
+
$driver = $this->driver;
|
|
163
|
+
$vehicle = $this->vehicle;
|
|
164
|
+
$geofence = $this->geofence;
|
|
165
|
+
$subject = $this->subjectType === 'vehicle' ? $vehicle : $driver;
|
|
166
|
+
|
|
167
|
+
$payload = [
|
|
168
|
+
'event' => $this->broadcastAs(),
|
|
169
|
+
'event_type' => 'geofence.exited',
|
|
170
|
+
'occurred_at' => $this->timestamp->toIso8601String(),
|
|
171
|
+
'dwell_duration_minutes' => $this->dwellDurationMinutes,
|
|
172
|
+
'subject' => [
|
|
173
|
+
'type' => $this->subjectType,
|
|
174
|
+
'id' => $subject?->public_id ?? null,
|
|
175
|
+
'uuid' => $subject?->uuid ?? null,
|
|
176
|
+
'name' => $this->subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name,
|
|
177
|
+
],
|
|
178
|
+
'driver' => $driver ? [
|
|
179
|
+
'id' => $driver->public_id,
|
|
180
|
+
'uuid' => $driver->uuid,
|
|
181
|
+
'name' => $driver->name,
|
|
182
|
+
'phone' => $driver->phone,
|
|
183
|
+
] : null,
|
|
184
|
+
'vehicle' => $vehicle ? [
|
|
185
|
+
'id' => $vehicle->public_id,
|
|
186
|
+
'uuid' => $vehicle->uuid,
|
|
187
|
+
'name' => $vehicle->display_name ?? null,
|
|
188
|
+
'plate' => $vehicle->plate_number ?? null,
|
|
189
|
+
] : null,
|
|
190
|
+
'geofence' => [
|
|
191
|
+
'id' => $geofence->public_id,
|
|
192
|
+
'uuid' => $geofence->uuid,
|
|
193
|
+
'name' => $geofence->name,
|
|
194
|
+
'type' => $this->geofenceType,
|
|
195
|
+
],
|
|
196
|
+
'location' => [
|
|
197
|
+
'latitude' => $this->location->getLat(),
|
|
198
|
+
'longitude' => $this->location->getLng(),
|
|
199
|
+
],
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
$order = $driver?->getCurrentOrder();
|
|
203
|
+
if ($order) {
|
|
204
|
+
$payload['order'] = [
|
|
205
|
+
'id' => $order->public_id,
|
|
206
|
+
'uuid' => $order->uuid,
|
|
207
|
+
'status' => $order->status,
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return $payload;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
|
|
4
4
|
|
|
5
5
|
use Fleetbase\FleetOps\Events\DriverLocationChanged;
|
|
6
|
+
use Fleetbase\FleetOps\Events\GeofenceEntered;
|
|
7
|
+
use Fleetbase\FleetOps\Events\GeofenceExited;
|
|
6
8
|
use Fleetbase\FleetOps\Events\VehicleLocationChanged;
|
|
7
9
|
use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
|
|
8
10
|
use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest;
|
|
9
11
|
use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
|
|
10
12
|
use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
|
|
11
13
|
use Fleetbase\FleetOps\Http\Resources\v1\Driver as DriverResource;
|
|
14
|
+
use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell;
|
|
12
15
|
use Fleetbase\FleetOps\Jobs\SimulateDrivingRoute;
|
|
13
16
|
use Fleetbase\FleetOps\Models\Driver;
|
|
14
17
|
use Fleetbase\FleetOps\Models\Order;
|
|
18
|
+
use Fleetbase\FleetOps\Models\Vehicle;
|
|
19
|
+
use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
|
|
15
20
|
use Fleetbase\FleetOps\Support\OSRM;
|
|
16
21
|
use Fleetbase\FleetOps\Support\Utils;
|
|
17
22
|
use Fleetbase\Http\Controllers\Controller;
|
|
@@ -374,6 +379,29 @@ class DriverController extends Controller
|
|
|
374
379
|
|
|
375
380
|
broadcast(new DriverLocationChanged($driver));
|
|
376
381
|
|
|
382
|
+
// ----------------------------------------------------------------
|
|
383
|
+
// Geofence intersection detection
|
|
384
|
+
//
|
|
385
|
+
// After broadcasting the location change, run the geofence engine
|
|
386
|
+
// asynchronously. We catch all exceptions so that a geofence error
|
|
387
|
+
// never prevents the location update response from being returned.
|
|
388
|
+
// ----------------------------------------------------------------
|
|
389
|
+
try {
|
|
390
|
+
$newLocation = new Point($latitude, $longitude);
|
|
391
|
+
$geofenceService = app(GeofenceIntersectionService::class);
|
|
392
|
+
$this->processSubjectGeofenceCrossings($driver, $newLocation, 'driver_geofence_states', 'driver_uuid', $geofenceService->detectDriverCrossings($driver, $newLocation));
|
|
393
|
+
|
|
394
|
+
if ($vehicle) {
|
|
395
|
+
$vehicle->loadMissing('driver');
|
|
396
|
+
$this->processSubjectGeofenceCrossings($vehicle, $newLocation, 'vehicle_geofence_states', 'vehicle_uuid', $geofenceService->detectVehicleCrossings($vehicle, $newLocation));
|
|
397
|
+
}
|
|
398
|
+
} catch (\Throwable $geofenceException) {
|
|
399
|
+
// Log the error but never let geofence processing block the response
|
|
400
|
+
if (app()->bound('sentry')) {
|
|
401
|
+
app('sentry')->captureException($geofenceException);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
377
405
|
return new DriverResource($driver);
|
|
378
406
|
}
|
|
379
407
|
|
|
@@ -917,4 +945,76 @@ class DriverController extends Controller
|
|
|
917
945
|
|
|
918
946
|
return $phone;
|
|
919
947
|
}
|
|
948
|
+
|
|
949
|
+
private function processSubjectGeofenceCrossings(Driver|Vehicle $subject, Point $newLocation, string $stateTable, string $subjectColumn, array $crossings): void
|
|
950
|
+
{
|
|
951
|
+
foreach ($crossings as $crossing) {
|
|
952
|
+
$geofence = $crossing['geofence'];
|
|
953
|
+
$geofenceType = $crossing['geofence_type'];
|
|
954
|
+
|
|
955
|
+
if ($crossing['type'] === 'entered') {
|
|
956
|
+
if (!$geofence->trigger_on_entry && empty($geofence->dwell_threshold_minutes)) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
DB::table($stateTable)->upsert(
|
|
961
|
+
[
|
|
962
|
+
$subjectColumn => $subject->uuid,
|
|
963
|
+
'geofence_uuid' => $geofence->uuid,
|
|
964
|
+
'geofence_type' => $geofenceType,
|
|
965
|
+
'is_inside' => true,
|
|
966
|
+
'entered_at' => now(),
|
|
967
|
+
'exited_at' => null,
|
|
968
|
+
'dwell_job_id' => null,
|
|
969
|
+
'created_at' => now(),
|
|
970
|
+
'updated_at' => now(),
|
|
971
|
+
],
|
|
972
|
+
[$subjectColumn, 'geofence_uuid'],
|
|
973
|
+
['is_inside', 'entered_at', 'exited_at', 'dwell_job_id', 'updated_at']
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
if ($geofence->trigger_on_entry) {
|
|
977
|
+
event(new GeofenceEntered($subject, $geofence, $geofenceType, $newLocation));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if ($geofence->dwell_threshold_minutes > 0) {
|
|
981
|
+
$dwellJob = CheckGeofenceDwell::dispatch(
|
|
982
|
+
$subject->uuid,
|
|
983
|
+
$geofence->uuid,
|
|
984
|
+
$geofenceType,
|
|
985
|
+
$subjectColumn === 'vehicle_uuid' ? 'vehicle' : 'driver'
|
|
986
|
+
)->delay(now()->addMinutes($geofence->dwell_threshold_minutes));
|
|
987
|
+
|
|
988
|
+
DB::table($stateTable)
|
|
989
|
+
->where($subjectColumn, $subject->uuid)
|
|
990
|
+
->where('geofence_uuid', $geofence->uuid)
|
|
991
|
+
->update(['dwell_job_id' => (string) $dwellJob]);
|
|
992
|
+
}
|
|
993
|
+
} elseif ($crossing['type'] === 'exited') {
|
|
994
|
+
$state = DB::table($stateTable)
|
|
995
|
+
->where($subjectColumn, $subject->uuid)
|
|
996
|
+
->where('geofence_uuid', $geofence->uuid)
|
|
997
|
+
->first();
|
|
998
|
+
|
|
999
|
+
$dwellMinutes = null;
|
|
1000
|
+
if ($state && $state->entered_at) {
|
|
1001
|
+
$dwellMinutes = (int) Carbon::parse($state->entered_at)->diffInMinutes(now());
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
DB::table($stateTable)
|
|
1005
|
+
->where($subjectColumn, $subject->uuid)
|
|
1006
|
+
->where('geofence_uuid', $geofence->uuid)
|
|
1007
|
+
->update([
|
|
1008
|
+
'is_inside' => false,
|
|
1009
|
+
'exited_at' => now(),
|
|
1010
|
+
'dwell_job_id' => null,
|
|
1011
|
+
'updated_at' => now(),
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
if ($geofence->trigger_on_exit) {
|
|
1015
|
+
event(new GeofenceExited($subject, $geofence, $geofenceType, $newLocation, $dwellMinutes));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
920
1020
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Models\GeofenceEventLog;
|
|
6
|
+
use Fleetbase\Http\Controllers\Controller;
|
|
7
|
+
use Illuminate\Http\JsonResponse;
|
|
8
|
+
use Illuminate\Http\Request;
|
|
9
|
+
use Illuminate\Support\Facades\DB;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GeofenceController.
|
|
13
|
+
*
|
|
14
|
+
* Exposes read-only API endpoints for querying geofence event data:
|
|
15
|
+
* - Event log (paginated, filterable history of all geofence events)
|
|
16
|
+
* - Real-time inventory (which drivers are currently inside which geofences)
|
|
17
|
+
* - Dwell report (aggregated dwell time statistics per geofence)
|
|
18
|
+
*/
|
|
19
|
+
class GeofenceController extends Controller
|
|
20
|
+
{
|
|
21
|
+
/**
|
|
22
|
+
* GET /api/v1/geofences/events.
|
|
23
|
+
*
|
|
24
|
+
* Returns a paginated log of geofence events for the authenticated company.
|
|
25
|
+
*
|
|
26
|
+
* Query parameters:
|
|
27
|
+
* - driver_uuid (string) Filter by driver UUID
|
|
28
|
+
* - geofence_uuid (string) Filter by geofence UUID
|
|
29
|
+
* - event_type (string) Filter by event type: entered | exited | dwelled
|
|
30
|
+
* - from (string) Filter events after this ISO 8601 datetime
|
|
31
|
+
* - to (string) Filter events before this ISO 8601 datetime
|
|
32
|
+
* - per_page (int) Number of results per page (default: 50, max: 200)
|
|
33
|
+
*/
|
|
34
|
+
public function events(Request $request): JsonResponse
|
|
35
|
+
{
|
|
36
|
+
$companyUuid = session('company');
|
|
37
|
+
|
|
38
|
+
$query = GeofenceEventLog::where('company_uuid', $companyUuid)
|
|
39
|
+
->with(['driver.vehicle', 'vehicle', 'order'])
|
|
40
|
+
->orderBy('occurred_at', 'desc');
|
|
41
|
+
|
|
42
|
+
if ($request->filled('driver_uuid')) {
|
|
43
|
+
$query->where('driver_uuid', $request->input('driver_uuid'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ($request->filled('geofence_uuid')) {
|
|
47
|
+
$query->where('geofence_uuid', $request->input('geofence_uuid'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ($request->filled('vehicle_uuid')) {
|
|
51
|
+
$query->where('vehicle_uuid', $request->input('vehicle_uuid'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ($request->filled('subject_type')) {
|
|
55
|
+
$query->where('subject_type', $request->input('subject_type'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ($request->filled('event_type')) {
|
|
59
|
+
$query->where('event_type', str_replace('geofence.', '', $request->input('event_type')));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if ($request->filled('from')) {
|
|
63
|
+
$query->where('occurred_at', '>=', $request->input('from'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ($request->filled('to')) {
|
|
67
|
+
$query->where('occurred_at', '<=', $request->input('to'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
$perPage = min((int) $request->input('per_page', 50), 200);
|
|
71
|
+
|
|
72
|
+
$events = $query->paginate($perPage);
|
|
73
|
+
$events->getCollection()->transform(fn (GeofenceEventLog $event) => $this->serializeEvent($event));
|
|
74
|
+
|
|
75
|
+
return response()->json($events);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* GET /api/v1/geofences/inventory.
|
|
80
|
+
*
|
|
81
|
+
* Returns a real-time snapshot of which drivers are currently inside
|
|
82
|
+
* which geofences for the authenticated company.
|
|
83
|
+
*/
|
|
84
|
+
public function inventory(): JsonResponse
|
|
85
|
+
{
|
|
86
|
+
$companyUuid = session('company');
|
|
87
|
+
|
|
88
|
+
$driverStates = DB::table('driver_geofence_states as dgs')
|
|
89
|
+
->join('drivers as d', 'd.uuid', '=', 'dgs.driver_uuid')
|
|
90
|
+
->leftJoin('zones as z', function ($join) {
|
|
91
|
+
$join->on('z.uuid', '=', 'dgs.geofence_uuid')
|
|
92
|
+
->where('dgs.geofence_type', '=', 'zone');
|
|
93
|
+
})
|
|
94
|
+
->leftJoin('service_areas as sa', function ($join) {
|
|
95
|
+
$join->on('sa.uuid', '=', 'dgs.geofence_uuid')
|
|
96
|
+
->where('dgs.geofence_type', '=', 'service_area');
|
|
97
|
+
})
|
|
98
|
+
->where('d.company_uuid', $companyUuid)
|
|
99
|
+
->where('dgs.is_inside', true)
|
|
100
|
+
->whereNull('d.deleted_at')
|
|
101
|
+
->select([
|
|
102
|
+
DB::raw("'driver' as subject_type"),
|
|
103
|
+
'd.public_id as subject_id',
|
|
104
|
+
'd.uuid as subject_uuid',
|
|
105
|
+
'd.name as subject_name',
|
|
106
|
+
'dgs.driver_uuid',
|
|
107
|
+
'd.name as driver_name',
|
|
108
|
+
'dgs.entered_at',
|
|
109
|
+
'dgs.geofence_uuid',
|
|
110
|
+
DB::raw('COALESCE(z.name, sa.name) as geofence_name'),
|
|
111
|
+
'dgs.geofence_type',
|
|
112
|
+
DB::raw('TIMESTAMPDIFF(MINUTE, dgs.entered_at, NOW()) as minutes_inside'),
|
|
113
|
+
])
|
|
114
|
+
->get();
|
|
115
|
+
|
|
116
|
+
$vehicleStates = DB::table('vehicle_geofence_states as vgs')
|
|
117
|
+
->join('vehicles as v', 'v.uuid', '=', 'vgs.vehicle_uuid')
|
|
118
|
+
->leftJoin('zones as z', function ($join) {
|
|
119
|
+
$join->on('z.uuid', '=', 'vgs.geofence_uuid')
|
|
120
|
+
->where('vgs.geofence_type', '=', 'zone');
|
|
121
|
+
})
|
|
122
|
+
->leftJoin('service_areas as sa', function ($join) {
|
|
123
|
+
$join->on('sa.uuid', '=', 'vgs.geofence_uuid')
|
|
124
|
+
->where('vgs.geofence_type', '=', 'service_area');
|
|
125
|
+
})
|
|
126
|
+
->where('v.company_uuid', $companyUuid)
|
|
127
|
+
->where('vgs.is_inside', true)
|
|
128
|
+
->whereNull('v.deleted_at')
|
|
129
|
+
->select([
|
|
130
|
+
DB::raw("'vehicle' as subject_type"),
|
|
131
|
+
'v.public_id as subject_id',
|
|
132
|
+
'v.uuid as subject_uuid',
|
|
133
|
+
DB::raw('COALESCE(v.name, v.plate_number, v.public_id) as subject_name'),
|
|
134
|
+
DB::raw('NULL as driver_uuid'),
|
|
135
|
+
DB::raw('NULL as driver_name'),
|
|
136
|
+
'vgs.entered_at',
|
|
137
|
+
'vgs.geofence_uuid',
|
|
138
|
+
DB::raw('COALESCE(z.name, sa.name) as geofence_name'),
|
|
139
|
+
'vgs.geofence_type',
|
|
140
|
+
DB::raw('TIMESTAMPDIFF(MINUTE, vgs.entered_at, NOW()) as minutes_inside'),
|
|
141
|
+
])
|
|
142
|
+
->get();
|
|
143
|
+
|
|
144
|
+
$states = $driverStates->merge($vehicleStates)->sortBy('entered_at')->values();
|
|
145
|
+
|
|
146
|
+
return response()->json([
|
|
147
|
+
'data' => $states,
|
|
148
|
+
'total' => $states->count(),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* GET /api/v1/geofences/dwell-report.
|
|
154
|
+
*
|
|
155
|
+
* Returns aggregated dwell time statistics per geofence for the
|
|
156
|
+
* authenticated company.
|
|
157
|
+
*
|
|
158
|
+
* Query parameters:
|
|
159
|
+
* - from (string) Start of reporting period (ISO 8601)
|
|
160
|
+
* - to (string) End of reporting period (ISO 8601)
|
|
161
|
+
*/
|
|
162
|
+
public function dwellReport(Request $request): JsonResponse
|
|
163
|
+
{
|
|
164
|
+
$companyUuid = session('company');
|
|
165
|
+
|
|
166
|
+
$query = GeofenceEventLog::where('company_uuid', $companyUuid)
|
|
167
|
+
->where('event_type', 'exited')
|
|
168
|
+
->whereNotNull('dwell_duration_minutes');
|
|
169
|
+
|
|
170
|
+
if ($request->filled('from')) {
|
|
171
|
+
$query->where('occurred_at', '>=', $request->input('from'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if ($request->filled('to')) {
|
|
175
|
+
$query->where('occurred_at', '<=', $request->input('to'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
$report = $query
|
|
179
|
+
->groupBy('geofence_uuid', 'geofence_name', 'geofence_type')
|
|
180
|
+
->select([
|
|
181
|
+
'geofence_uuid',
|
|
182
|
+
'geofence_name',
|
|
183
|
+
'geofence_type',
|
|
184
|
+
DB::raw('COUNT(*) as visit_count'),
|
|
185
|
+
DB::raw('ROUND(AVG(dwell_duration_minutes), 1) as avg_dwell_minutes'),
|
|
186
|
+
DB::raw('MAX(dwell_duration_minutes) as max_dwell_minutes'),
|
|
187
|
+
DB::raw('MIN(dwell_duration_minutes) as min_dwell_minutes'),
|
|
188
|
+
DB::raw('SUM(dwell_duration_minutes) as total_dwell_minutes'),
|
|
189
|
+
])
|
|
190
|
+
->orderBy('visit_count', 'desc')
|
|
191
|
+
->get();
|
|
192
|
+
|
|
193
|
+
return response()->json(['data' => $report]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* GET /api/v1/geofences/driver/{driverUuid}/history.
|
|
198
|
+
*
|
|
199
|
+
* Returns the geofence event history for a specific driver.
|
|
200
|
+
*/
|
|
201
|
+
public function driverHistory(Request $request, string $driverUuid): JsonResponse
|
|
202
|
+
{
|
|
203
|
+
$companyUuid = session('company');
|
|
204
|
+
$perPage = min((int) $request->input('per_page', 50), 200);
|
|
205
|
+
|
|
206
|
+
$events = GeofenceEventLog::where('company_uuid', $companyUuid)
|
|
207
|
+
->where('driver_uuid', $driverUuid)
|
|
208
|
+
->with(['driver.vehicle', 'vehicle', 'order'])
|
|
209
|
+
->orderBy('occurred_at', 'desc')
|
|
210
|
+
->paginate($perPage);
|
|
211
|
+
|
|
212
|
+
$events->getCollection()->transform(fn (GeofenceEventLog $event) => $this->serializeEvent($event));
|
|
213
|
+
|
|
214
|
+
return response()->json($events);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected function serializeEvent(GeofenceEventLog $event): array
|
|
218
|
+
{
|
|
219
|
+
$driver = $event->driver;
|
|
220
|
+
$vehicle = $event->vehicle ?? $driver?->vehicle;
|
|
221
|
+
$subjectType = $event->subject_type ?? ($driver ? 'driver' : 'vehicle');
|
|
222
|
+
$subject = $subjectType === 'vehicle' ? $vehicle : $driver;
|
|
223
|
+
|
|
224
|
+
return [
|
|
225
|
+
'id' => $event->uuid,
|
|
226
|
+
'event_type' => 'geofence.' . $event->event_type,
|
|
227
|
+
'occurred_at' => optional($event->occurred_at)->toIso8601String(),
|
|
228
|
+
'dwell_duration_minutes' => $event->dwell_duration_minutes,
|
|
229
|
+
'subject' => [
|
|
230
|
+
'type' => $subjectType,
|
|
231
|
+
'id' => $subject?->public_id,
|
|
232
|
+
'uuid' => $event->subject_uuid ?? $subject?->uuid,
|
|
233
|
+
'name' => $event->subject_name ?? ($subjectType === 'vehicle' ? ($vehicle?->display_name ?? $vehicle?->plate_number) : $driver?->name),
|
|
234
|
+
],
|
|
235
|
+
'driver' => $driver ? [
|
|
236
|
+
'id' => $driver->public_id,
|
|
237
|
+
'uuid' => $driver->uuid,
|
|
238
|
+
'name' => $driver->name,
|
|
239
|
+
'phone' => $driver->phone,
|
|
240
|
+
] : null,
|
|
241
|
+
'vehicle' => $vehicle ? [
|
|
242
|
+
'id' => $vehicle->public_id,
|
|
243
|
+
'uuid' => $vehicle->uuid,
|
|
244
|
+
'name' => $vehicle->display_name ?? $vehicle->name,
|
|
245
|
+
'plate' => $vehicle->plate_number,
|
|
246
|
+
] : null,
|
|
247
|
+
'geofence' => [
|
|
248
|
+
'uuid' => $event->geofence_uuid,
|
|
249
|
+
'name' => $event->geofence_name,
|
|
250
|
+
'type' => $event->geofence_type,
|
|
251
|
+
],
|
|
252
|
+
'location' => [
|
|
253
|
+
'latitude' => $event->latitude,
|
|
254
|
+
'longitude' => $event->longitude,
|
|
255
|
+
],
|
|
256
|
+
'order' => $event->order ? [
|
|
257
|
+
'id' => $event->order->public_id,
|
|
258
|
+
'uuid' => $event->order->uuid,
|
|
259
|
+
'status' => $event->order->status,
|
|
260
|
+
] : null,
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -46,7 +46,7 @@ class OrderController extends Controller
|
|
|
46
46
|
/**
|
|
47
47
|
* Creates a new Fleetbase Order resource.
|
|
48
48
|
*
|
|
49
|
-
* @param Request
|
|
49
|
+
* @param Request|CreateOrderRequest $request
|
|
50
50
|
*
|
|
51
51
|
* @return \Fleetbase\Http\Resources\Order
|
|
52
52
|
*/
|
|
@@ -321,7 +321,7 @@ class OrderController extends Controller
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
// load required relations
|
|
324
|
-
$order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
|
|
324
|
+
$order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
|
|
325
325
|
|
|
326
326
|
// Determine if order should be dispatched on creation
|
|
327
327
|
$shouldDispatch = $request->boolean('dispatch') && $integratedVendorOrder === null;
|
|
@@ -354,8 +354,7 @@ class OrderController extends Controller
|
|
|
354
354
|
/**
|
|
355
355
|
* Updates a Fleetbase Order resource.
|
|
356
356
|
*
|
|
357
|
-
* @param string
|
|
358
|
-
* @param \Fleetbase\Http\Requests\UpdateOrderRequest $request
|
|
357
|
+
* @param string $id
|
|
359
358
|
*
|
|
360
359
|
* @return \Fleetbase\Http\Resources\Order
|
|
361
360
|
*/
|
|
@@ -365,7 +364,7 @@ class OrderController extends Controller
|
|
|
365
364
|
|
|
366
365
|
// find for the order
|
|
367
366
|
try {
|
|
368
|
-
$order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate', 'customer', 'facilitator']);
|
|
367
|
+
$order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
|
|
369
368
|
} catch (ModelNotFoundException $exception) {
|
|
370
369
|
return response()->json(
|
|
371
370
|
[
|
|
@@ -528,6 +527,12 @@ class OrderController extends Controller
|
|
|
528
527
|
}
|
|
529
528
|
}
|
|
530
529
|
|
|
530
|
+
// If adding a service quote for a purchase
|
|
531
|
+
$serviceQuote = ServiceQuote::resolveFromRequest($request);
|
|
532
|
+
if ($serviceQuote) {
|
|
533
|
+
$order->purchaseServiceQuote($serviceQuote);
|
|
534
|
+
}
|
|
535
|
+
|
|
531
536
|
// dispatch if flagged true
|
|
532
537
|
if ($request->boolean('dispatch')) {
|
|
533
538
|
$order->dispatch();
|
|
@@ -544,7 +549,7 @@ class OrderController extends Controller
|
|
|
544
549
|
$order->flushAttributesCache();
|
|
545
550
|
|
|
546
551
|
// load required relations
|
|
547
|
-
$order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
|
|
552
|
+
$order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
|
|
548
553
|
|
|
549
554
|
// response the order resource
|
|
550
555
|
return new OrderResource($order);
|
|
@@ -754,7 +759,7 @@ class OrderController extends Controller
|
|
|
754
759
|
{
|
|
755
760
|
// find for the order
|
|
756
761
|
try {
|
|
757
|
-
$order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
|
|
762
|
+
$order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
|
|
758
763
|
} catch (ModelNotFoundException $exception) {
|
|
759
764
|
return response()->json(
|
|
760
765
|
[
|
|
@@ -834,7 +839,7 @@ class OrderController extends Controller
|
|
|
834
839
|
public function dispatchOrder(string $id)
|
|
835
840
|
{
|
|
836
841
|
try {
|
|
837
|
-
$order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
|
|
842
|
+
$order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
|
|
838
843
|
} catch (ModelNotFoundException $exception) {
|
|
839
844
|
return response()->json(
|
|
840
845
|
[
|