@fleetbase/fleetops-engine 0.6.60 → 0.6.62
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/RELEASE.md +21 -17
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +26 -1
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/Models/Place.php +1 -1
- package/server/src/routes.php +29 -0
- package/server/tests/ApiDriverControllerContractsTest.php +317 -13
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
- package/server/tests/CompactResourceSerializationTest.php +13 -7
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +105 -7
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
- package/translations/en-us.yaml +4 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Http\Resources\v1\Manifest as ManifestResource;
|
|
6
|
+
use Fleetbase\FleetOps\Http\Resources\v1\ManifestStop as ManifestStopResource;
|
|
7
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Manifest;
|
|
9
|
+
use Fleetbase\FleetOps\Models\ManifestStop;
|
|
10
|
+
use Fleetbase\FleetOps\Support\Utils;
|
|
11
|
+
use Fleetbase\Http\Controllers\Controller;
|
|
12
|
+
use Illuminate\Http\Request;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Driver-facing manifests.
|
|
16
|
+
*
|
|
17
|
+
* A manifest is a driver's route: an order-agnostic sequence of stops, which
|
|
18
|
+
* may span several orders or none the driver has seen as an order. Everything a
|
|
19
|
+
* driver app needs to *run* a route lived only behind the console's internal
|
|
20
|
+
* namespace, so a driver could be assigned a route they had no way to read.
|
|
21
|
+
*
|
|
22
|
+
* These endpoints are deliberately narrow. A driver may read their own
|
|
23
|
+
* manifests, update the stops on them, and re-sequence the ones they have not
|
|
24
|
+
* done yet. They may not create, cancel or delete a manifest — that is dispatch
|
|
25
|
+
* work, and it stays on the internal namespace.
|
|
26
|
+
*/
|
|
27
|
+
class ManifestController extends Controller
|
|
28
|
+
{
|
|
29
|
+
/**
|
|
30
|
+
* GET /v1/drivers/{id}/manifests.
|
|
31
|
+
*
|
|
32
|
+
* Defaults to what a driver actually needs on shift — today and anything
|
|
33
|
+
* still unfinished — rather than their whole history, which on a busy fleet
|
|
34
|
+
* is thousands of rows nobody asked for.
|
|
35
|
+
*/
|
|
36
|
+
public function forDriver(Request $request, string $id)
|
|
37
|
+
{
|
|
38
|
+
$driver = static::findDriverRecord($id);
|
|
39
|
+
if (!$driver) {
|
|
40
|
+
return response()->apiError('Driver resource not found.', 404);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$query = static::manifestsFor($driver);
|
|
44
|
+
|
|
45
|
+
if ($request->filled('status')) {
|
|
46
|
+
$query->whereIn('status', Utils::arrayFrom($request->input('status')));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ($request->filled('on')) {
|
|
50
|
+
$query->whereDate('scheduled_date', $request->input('on'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return ManifestResource::collection($query->limit((int) $request->input('limit', 30))->get());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* GET /v1/manifests/{id} — the route, with its stops in sequence.
|
|
58
|
+
*/
|
|
59
|
+
public function show(string $id)
|
|
60
|
+
{
|
|
61
|
+
$manifest = static::findManifest($id);
|
|
62
|
+
if (!$manifest) {
|
|
63
|
+
return response()->apiError('Manifest resource not found.', 404);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$manifest->load(['driver', 'vehicle', 'stops' => fn ($q) => $q->orderBy('sequence')->with(['place', 'order.trackingNumber'])]);
|
|
67
|
+
|
|
68
|
+
return new ManifestResource($manifest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* PATCH /v1/manifest-stops/{id}.
|
|
73
|
+
*
|
|
74
|
+
* Status changes go through the model's own transitions rather than being
|
|
75
|
+
* written as a column, because arriving and completing carry side effects —
|
|
76
|
+
* timestamps, and the manifest completing itself once its last stop does.
|
|
77
|
+
*/
|
|
78
|
+
public function updateStop(Request $request, string $id)
|
|
79
|
+
{
|
|
80
|
+
$stop = static::findStop($id);
|
|
81
|
+
if (!$stop) {
|
|
82
|
+
return response()->apiError('Manifest stop resource not found.', 404);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$status = $request->input('status');
|
|
86
|
+
if ($status) {
|
|
87
|
+
// Checked before it is applied: an unknown status must change
|
|
88
|
+
// nothing, not fall through a match and then be refused.
|
|
89
|
+
if (!in_array($status, ['arrived', 'completed', 'skipped'], true)) {
|
|
90
|
+
return response()->apiError('Status must be one of: arrived, completed, skipped.', 422);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
match ($status) {
|
|
94
|
+
'arrived' => $stop->markArrived(),
|
|
95
|
+
'completed' => $stop->markCompleted(),
|
|
96
|
+
default => $stop->markSkipped(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ($request->filled('meta')) {
|
|
101
|
+
$stop->update(['meta' => $request->input('meta')]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$stop->load(['place', 'order.trackingNumber']);
|
|
105
|
+
|
|
106
|
+
return new ManifestStopResource($stop);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* POST /v1/manifests/{id}/optimize — re-sequence the stops still to do.
|
|
111
|
+
*
|
|
112
|
+
* This is the *driver's* optimise, not the orchestrator's. The orchestrator
|
|
113
|
+
* allocates orders across a fleet and produces manifests; this reorders the
|
|
114
|
+
* stops on one manifest that is already assigned, which is a different job
|
|
115
|
+
* with a different owner.
|
|
116
|
+
*
|
|
117
|
+
* **It is a nearest-neighbour heuristic, not a solved routing problem.** It
|
|
118
|
+
* walks from the driver's current position to the closest remaining stop,
|
|
119
|
+
* then to the closest from there, by straight-line distance. That is
|
|
120
|
+
* typically a large improvement over an arbitrary order and is not
|
|
121
|
+
* guaranteed optimal; calling it anything stronger would be a claim the
|
|
122
|
+
* implementation does not support. Completed and skipped stops keep their
|
|
123
|
+
* place — a route already driven is not re-planned.
|
|
124
|
+
*/
|
|
125
|
+
public function optimize(Request $request, string $id)
|
|
126
|
+
{
|
|
127
|
+
$manifest = static::findManifest($id);
|
|
128
|
+
if (!$manifest) {
|
|
129
|
+
return response()->apiError('Manifest resource not found.', 404);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$manifest->load(['stops' => fn ($q) => $q->orderBy('sequence')->with('place')]);
|
|
133
|
+
|
|
134
|
+
$done = $manifest->stops->filter(fn ($s) => in_array($s->status, ['completed', 'skipped'], true))->values();
|
|
135
|
+
$pending = $manifest->stops->reject(fn ($s) => in_array($s->status, ['completed', 'skipped'], true))->values();
|
|
136
|
+
|
|
137
|
+
if ($pending->count() < 3) {
|
|
138
|
+
// Two stops have only one order, and one has none. Re-sequencing
|
|
139
|
+
// either is work with no possible result.
|
|
140
|
+
return $this->show($id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
$from = $request->filled(['latitude', 'longitude'])
|
|
144
|
+
? ['lat' => (float) $request->input('latitude'), 'lon' => (float) $request->input('longitude')]
|
|
145
|
+
: static::coordinatesOf($pending->first());
|
|
146
|
+
|
|
147
|
+
$remaining = $pending->all();
|
|
148
|
+
$ordered = [];
|
|
149
|
+
|
|
150
|
+
while (count($remaining)) {
|
|
151
|
+
$closestIndex = 0;
|
|
152
|
+
$closestCost = null;
|
|
153
|
+
|
|
154
|
+
foreach ($remaining as $index => $stop) {
|
|
155
|
+
$to = static::coordinatesOf($stop);
|
|
156
|
+
if (!$from || !$to) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
$cost = static::distanceBetween($from, $to);
|
|
161
|
+
|
|
162
|
+
if ($closestCost === null || $cost < $closestCost) {
|
|
163
|
+
$closestCost = $cost;
|
|
164
|
+
$closestIndex = $index;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$next = $remaining[$closestIndex];
|
|
169
|
+
$ordered[] = $next;
|
|
170
|
+
$from = static::coordinatesOf($next) ?? $from;
|
|
171
|
+
unset($remaining[$closestIndex]);
|
|
172
|
+
$remaining = array_values($remaining);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Done stops keep the front of the sequence; the rest follow in the new
|
|
176
|
+
// order. Sequence stays dense so nothing downstream has to cope with
|
|
177
|
+
// gaps.
|
|
178
|
+
$sequence = 1;
|
|
179
|
+
foreach ($done as $stop) {
|
|
180
|
+
$stop->update(['sequence' => $sequence++]);
|
|
181
|
+
}
|
|
182
|
+
foreach ($ordered as $stop) {
|
|
183
|
+
$stop->update(['sequence' => $sequence++]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return $this->show($id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The driver's manifests, newest first. Separated so it can be stubbed. */
|
|
190
|
+
protected static function manifestsFor(Driver $driver)
|
|
191
|
+
{
|
|
192
|
+
return Manifest::where('driver_uuid', $driver->uuid)
|
|
193
|
+
->with(['driver', 'vehicle'])
|
|
194
|
+
->orderBy('scheduled_date', 'desc');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
protected static function findDriverRecord(string $id): ?Driver
|
|
198
|
+
{
|
|
199
|
+
return Driver::where('public_id', $id)->orWhere('uuid', $id)->first();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
protected static function findManifest(string $id): ?Manifest
|
|
203
|
+
{
|
|
204
|
+
return Manifest::where('public_id', $id)->orWhere('uuid', $id)->first();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
protected static function findStop(string $id): ?ManifestStop
|
|
208
|
+
{
|
|
209
|
+
return ManifestStop::where('public_id', $id)->orWhere('uuid', $id)->first();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Straight-line distance between two coordinates, in metres.
|
|
214
|
+
*
|
|
215
|
+
* Deliberately not a routing call. A nearest-neighbour walk compares every
|
|
216
|
+
* remaining stop at every step, so a twenty-stop route asks roughly four
|
|
217
|
+
* hundred distance questions — as road lookups that is four hundred network
|
|
218
|
+
* round trips for one tap, which is not a thing to do on a handset waiting
|
|
219
|
+
* on a driver.
|
|
220
|
+
*
|
|
221
|
+
* Straight-line ordering and road ordering rarely disagree about which of
|
|
222
|
+
* several stops is nearest, and where they do the result is still a valid
|
|
223
|
+
* route, just not the shortest one. Since the method is a heuristic either
|
|
224
|
+
* way, the cheap version is the honest choice.
|
|
225
|
+
*/
|
|
226
|
+
protected static function distanceBetween(array $from, array $to): float
|
|
227
|
+
{
|
|
228
|
+
$earthRadius = 6371000.0;
|
|
229
|
+
|
|
230
|
+
$lat1 = deg2rad($from['lat']);
|
|
231
|
+
$lat2 = deg2rad($to['lat']);
|
|
232
|
+
$dLat = $lat2 - $lat1;
|
|
233
|
+
$dLon = deg2rad($to['lon'] - $from['lon']);
|
|
234
|
+
|
|
235
|
+
$a = sin($dLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dLon / 2) ** 2;
|
|
236
|
+
|
|
237
|
+
return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Latitude and longitude of a stop's place, when it has one. */
|
|
241
|
+
protected static function coordinatesOf(?ManifestStop $stop): ?array
|
|
242
|
+
{
|
|
243
|
+
$location = data_get($stop, 'place.location');
|
|
244
|
+
if (!$location) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return ['lat' => (float) $location->getLat(), 'lon' => (float) $location->getLng()];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -1873,7 +1873,7 @@ class OrderController extends Controller
|
|
|
1873
1873
|
return new Payload();
|
|
1874
1874
|
}
|
|
1875
1875
|
|
|
1876
|
-
protected function findDriverByPublicId(string $publicId): ?Driver
|
|
1876
|
+
protected function findDriverByPublicId(?string $publicId): ?Driver
|
|
1877
1877
|
{
|
|
1878
1878
|
return Driver::where(['public_id' => $publicId, 'company_uuid' => session('company')])->first();
|
|
1879
1879
|
}
|
|
@@ -218,7 +218,7 @@ class ServiceAreaController extends Controller
|
|
|
218
218
|
return ServiceArea::createMultiPolygonFromPoint($point, $radius);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
protected function serviceAreaUuid(string $publicId, array $where): ?string
|
|
221
|
+
protected function serviceAreaUuid(?string $publicId, array $where): ?string
|
|
222
222
|
{
|
|
223
223
|
return Utils::getUuid('service_areas', $where);
|
|
224
224
|
}
|
|
@@ -187,7 +187,7 @@ class TrackingStatusController extends Controller
|
|
|
187
187
|
return Utils::getUuid($table, $where);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
protected function getOrderTrackingNumberUuid(string $orderId): ?string
|
|
190
|
+
protected function getOrderTrackingNumberUuid(?string $orderId): ?string
|
|
191
191
|
{
|
|
192
192
|
return Order::where('public_id', $orderId)->value('tracking_number_uuid');
|
|
193
193
|
}
|
|
@@ -5,6 +5,9 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
|
|
|
5
5
|
use Fleetbase\FleetOps\Events\GeofenceEntered;
|
|
6
6
|
use Fleetbase\FleetOps\Events\GeofenceExited;
|
|
7
7
|
use Fleetbase\FleetOps\Events\VehicleLocationChanged;
|
|
8
|
+
use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
|
|
9
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
|
|
10
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
|
|
8
11
|
use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest;
|
|
9
12
|
use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest;
|
|
10
13
|
use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
|
|
@@ -12,16 +15,45 @@ use Fleetbase\FleetOps\Http\Resources\v1\Vehicle as VehicleResource;
|
|
|
12
15
|
use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell;
|
|
13
16
|
use Fleetbase\FleetOps\Models\Driver;
|
|
14
17
|
use Fleetbase\FleetOps\Models\Vehicle;
|
|
18
|
+
use Fleetbase\FleetOps\Models\Vendor;
|
|
19
|
+
use Fleetbase\FleetOps\Models\Warranty;
|
|
15
20
|
use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
|
|
16
21
|
use Fleetbase\FleetOps\Support\Utils;
|
|
17
22
|
use Fleetbase\Http\Controllers\Controller;
|
|
18
23
|
use Fleetbase\LaravelMysqlSpatial\Types\Point;
|
|
24
|
+
use Fleetbase\Models\Category;
|
|
25
|
+
use Fleetbase\Models\File;
|
|
19
26
|
use Illuminate\Http\Request;
|
|
20
27
|
use Illuminate\Support\Carbon;
|
|
21
28
|
use Illuminate\Support\Facades\DB;
|
|
22
29
|
|
|
23
30
|
class VehicleController extends Controller
|
|
24
31
|
{
|
|
32
|
+
use ResolvesFleetOpsApiResources;
|
|
33
|
+
use ResolvesPublicExpansions;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Relationships eager loaded so the public resource can report each
|
|
37
|
+
* assignment as a public id without a query per vehicle.
|
|
38
|
+
*
|
|
39
|
+
* Loading them does not expand them: the nested object is returned only for
|
|
40
|
+
* a relation the caller named in `with`, which is the shape the endpoint has
|
|
41
|
+
* always had.
|
|
42
|
+
*/
|
|
43
|
+
protected const PUBLIC_RELATIONS = ['vendor', 'category', 'warranty', 'driver', 'photo'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Public expansion name => Eloquent relation name.
|
|
47
|
+
*/
|
|
48
|
+
public const EXPANDABLE = [
|
|
49
|
+
'driver' => 'driver',
|
|
50
|
+
'vendor' => 'vendor',
|
|
51
|
+
'category' => 'category',
|
|
52
|
+
'warranty' => 'warranty',
|
|
53
|
+
'photo' => 'photo',
|
|
54
|
+
'devices' => 'devices',
|
|
55
|
+
];
|
|
56
|
+
|
|
25
57
|
/**
|
|
26
58
|
* Creates a new Fleetbase Vehicle resource.
|
|
27
59
|
*
|
|
@@ -31,8 +63,14 @@ class VehicleController extends Controller
|
|
|
31
63
|
*/
|
|
32
64
|
public function create(CreateVehicleRequest $request)
|
|
33
65
|
{
|
|
66
|
+
$this->applyPublicExpansions($request, static::EXPANDABLE);
|
|
67
|
+
|
|
34
68
|
// get request input
|
|
35
|
-
|
|
69
|
+
try {
|
|
70
|
+
$input = $this->vehicleInputFromRequest($request);
|
|
71
|
+
} catch (PublicRelationNotFoundException $exception) {
|
|
72
|
+
return $this->jsonResponse(['error' => $exception->getMessage()], 404);
|
|
73
|
+
}
|
|
36
74
|
|
|
37
75
|
// make sure company is set
|
|
38
76
|
$input['company_uuid'] = session('company');
|
|
@@ -40,14 +78,6 @@ class VehicleController extends Controller
|
|
|
40
78
|
// set default online
|
|
41
79
|
$input = $this->withDefaultOnline($input);
|
|
42
80
|
|
|
43
|
-
// vendor assignment
|
|
44
|
-
if ($request->has('vendor')) {
|
|
45
|
-
$input['vendor_uuid'] = $this->getVendorUuid('vendors', [
|
|
46
|
-
'public_id' => $request->input('vendor'),
|
|
47
|
-
'company_uuid' => session('company'),
|
|
48
|
-
]);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
81
|
// latitude / longitude
|
|
52
82
|
$input = $this->withCoordinateLocation($input, $request);
|
|
53
83
|
|
|
@@ -85,6 +115,8 @@ class VehicleController extends Controller
|
|
|
85
115
|
*/
|
|
86
116
|
public function update($id, UpdateVehicleRequest $request)
|
|
87
117
|
{
|
|
118
|
+
$this->applyPublicExpansions($request, static::EXPANDABLE);
|
|
119
|
+
|
|
88
120
|
// find for the vehicle
|
|
89
121
|
try {
|
|
90
122
|
$vehicle = $this->findVehicle($id);
|
|
@@ -98,19 +130,12 @@ class VehicleController extends Controller
|
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
// get request input
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
$input['vendor_uuid'] = $this->getVendorUuid('vendors', [
|
|
106
|
-
'public_id' => $request->input('vendor'),
|
|
107
|
-
'company_uuid' => session('company'),
|
|
108
|
-
]);
|
|
133
|
+
try {
|
|
134
|
+
$input = $this->vehicleInputFromRequest($request);
|
|
135
|
+
} catch (PublicRelationNotFoundException $exception) {
|
|
136
|
+
return $this->jsonResponse(['error' => $exception->getMessage()], 404);
|
|
109
137
|
}
|
|
110
138
|
|
|
111
|
-
// set default online
|
|
112
|
-
$input = $this->withDefaultOnline($input);
|
|
113
|
-
|
|
114
139
|
// latitude / longitude
|
|
115
140
|
$input = $this->withCoordinateLocation($input, $request);
|
|
116
141
|
|
|
@@ -157,6 +182,8 @@ class VehicleController extends Controller
|
|
|
157
182
|
*/
|
|
158
183
|
public function query(Request $request)
|
|
159
184
|
{
|
|
185
|
+
$this->applyPublicExpansions($request, static::EXPANDABLE);
|
|
186
|
+
|
|
160
187
|
$results = $this->queryVehicles($request);
|
|
161
188
|
|
|
162
189
|
return $this->vehicleResourceCollection($results);
|
|
@@ -169,8 +196,17 @@ class VehicleController extends Controller
|
|
|
169
196
|
*
|
|
170
197
|
* @return \Fleetbase\Http\Resources\VehicleCollection
|
|
171
198
|
*/
|
|
172
|
-
public function find($id)
|
|
199
|
+
public function find($id, ?Request $request = null)
|
|
173
200
|
{
|
|
201
|
+
// Falls back to the container's request rather than trusting injection:
|
|
202
|
+
// the parameter carries a default, and Laravel's controller dispatcher
|
|
203
|
+
// skips resolving a type-hinted dependency that has one. It arrives null,
|
|
204
|
+
// the expansions are never mapped, and an unsupported name reaches
|
|
205
|
+
// Eloquent — a 500 for a typo, on the one endpoint most likely to be
|
|
206
|
+
// handed one.
|
|
207
|
+
$request = $request instanceof Request ? $request : request();
|
|
208
|
+
$this->applyPublicExpansions($request, static::EXPANDABLE);
|
|
209
|
+
|
|
174
210
|
// find for the vehicle
|
|
175
211
|
try {
|
|
176
212
|
$vehicle = $this->findVehicle($id);
|
|
@@ -347,17 +383,75 @@ class VehicleController extends Controller
|
|
|
347
383
|
}
|
|
348
384
|
}
|
|
349
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The explicit public input allowlist for a vehicle.
|
|
388
|
+
*
|
|
389
|
+
* Mirrors every safe business field the model and the Fleet-Ops vehicle form
|
|
390
|
+
* expose. Deliberately absent: `company_uuid` and the raw `*_uuid` relation
|
|
391
|
+
* columns (resolved from public ids below), `public_id` / `uuid` / `_key` /
|
|
392
|
+
* `slug` (generated), `avatar_url` when given as a uuid, `vin_data` (written
|
|
393
|
+
* by the VIN decoder) and `telematics` (written by telematics ingestion).
|
|
394
|
+
*
|
|
395
|
+
* @throws PublicRelationNotFoundException
|
|
396
|
+
*/
|
|
350
397
|
protected function vehicleInputFromRequest(Request $request): array
|
|
351
398
|
{
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
'
|
|
355
|
-
'
|
|
356
|
-
'
|
|
399
|
+
$input = $request->only([
|
|
400
|
+
// Identity and description
|
|
401
|
+
'internal_id', 'name', 'description', 'make', 'model', 'model_type', 'year',
|
|
402
|
+
'trim', 'color', 'type', 'class', 'plate_number', 'vin', 'serial_number',
|
|
403
|
+
'call_sign', 'fuel_card_number',
|
|
404
|
+
// Measurement and operation.
|
|
405
|
+
//
|
|
406
|
+
// Odometer is fillable on the model and unrestricted by the request
|
|
407
|
+
// rules, but was absent here — so a caller sending one received a
|
|
408
|
+
// 200 and a response body that looked correct while the reading was
|
|
409
|
+
// discarded. Recording mileage is the single most common write a
|
|
410
|
+
// driver app makes against a vehicle.
|
|
411
|
+
'odometer', 'odometer_unit', 'odometer_at_purchase', 'measurement_system',
|
|
412
|
+
'fuel_type', 'fuel_volume_unit', 'online', 'status',
|
|
413
|
+
'location', 'altitude', 'heading', 'speed',
|
|
414
|
+
// Body, capacity and dimensions
|
|
415
|
+
'transmission', 'body_type', 'body_sub_type', 'usage_type', 'ownership_type',
|
|
416
|
+
'cargo_volume', 'passenger_volume', 'interior_volume', 'weight', 'width',
|
|
417
|
+
'length', 'height', 'towing_capacity', 'payload_capacity', 'seating_capacity',
|
|
418
|
+
'ground_clearance', 'bed_length', 'fuel_capacity',
|
|
419
|
+
// Lifecycle and financing
|
|
420
|
+
'financing_status', 'loan_number_of_payments', 'loan_first_payment', 'loan_amount',
|
|
421
|
+
'estimated_service_life_distance_unit', 'estimated_service_life_distance',
|
|
422
|
+
'estimated_service_life_months', 'insurance_value', 'depreciation_rate',
|
|
423
|
+
'current_value', 'acquisition_cost', 'currency', 'purchased_at', 'lease_expires_at',
|
|
424
|
+
// Regulatory and engine specifications
|
|
425
|
+
'emission_standard', 'dpf_equipped', 'scr_equipped', 'gvwr', 'gcwr',
|
|
426
|
+
'engine_number', 'engine_model', 'engine_make', 'engine_family',
|
|
427
|
+
'engine_configuration', 'engine_displacement', 'engine_size', 'horsepower',
|
|
428
|
+
'horsepower_rpm', 'torque', 'torque_rpm', 'number_of_cylinders',
|
|
429
|
+
'cylinder_arrangement',
|
|
430
|
+
// Structured and descriptive fields
|
|
431
|
+
'specs', 'details', 'notes', 'meta',
|
|
432
|
+
// Orchestrator
|
|
433
|
+
'payload_capacity_volume', 'payload_capacity_pallets', 'payload_capacity_parcels',
|
|
357
434
|
'skills', 'max_tasks', 'time_window_start', 'time_window_end', 'return_to_depot',
|
|
358
435
|
]);
|
|
436
|
+
|
|
437
|
+
$this->applyPublicIdRelations($input, [
|
|
438
|
+
'vendor' => ['vendor_uuid', Vendor::class],
|
|
439
|
+
'category' => ['category_uuid', Category::class],
|
|
440
|
+
'warranty' => ['warranty_uuid', Warranty::class],
|
|
441
|
+
'photo' => ['photo_uuid', File::class],
|
|
442
|
+
], $request);
|
|
443
|
+
|
|
444
|
+
return $input;
|
|
359
445
|
}
|
|
360
446
|
|
|
447
|
+
/**
|
|
448
|
+
* A vehicle that does not say otherwise starts offline.
|
|
449
|
+
*
|
|
450
|
+
* Applied on create only. Applying it on update too meant any partial write —
|
|
451
|
+
* a plate correction, an odometer reading — silently knocked the vehicle
|
|
452
|
+
* offline, because the absent key was read as "set it to false" rather than
|
|
453
|
+
* "leave it alone".
|
|
454
|
+
*/
|
|
361
455
|
protected function withDefaultOnline(array $input): array
|
|
362
456
|
{
|
|
363
457
|
if (!isset($input['online'])) {
|
|
@@ -388,11 +482,6 @@ class VehicleController extends Controller
|
|
|
388
482
|
];
|
|
389
483
|
}
|
|
390
484
|
|
|
391
|
-
protected function getVendorUuid(string $table, array $where): ?string
|
|
392
|
-
{
|
|
393
|
-
return Utils::getUuid($table, $where);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
485
|
protected function createVehicle(array $input): Vehicle
|
|
397
486
|
{
|
|
398
487
|
return Vehicle::create($input);
|
|
@@ -403,19 +492,35 @@ class VehicleController extends Controller
|
|
|
403
492
|
return Vehicle::findRecordOrFail($id);
|
|
404
493
|
}
|
|
405
494
|
|
|
406
|
-
protected function findDriver(string $id): Driver
|
|
495
|
+
protected function findDriver(?string $id): Driver
|
|
407
496
|
{
|
|
408
497
|
return Driver::findRecordOrFail($id);
|
|
409
498
|
}
|
|
410
499
|
|
|
411
500
|
protected function queryVehicles(Request $request)
|
|
412
501
|
{
|
|
413
|
-
return Vehicle::queryWithRequest($request)
|
|
502
|
+
return Vehicle::queryWithRequest($request, function (&$query) {
|
|
503
|
+
$query->with(static::PUBLIC_RELATIONS);
|
|
504
|
+
});
|
|
414
505
|
}
|
|
415
506
|
|
|
416
507
|
protected function vehicleResource(Vehicle $vehicle)
|
|
417
508
|
{
|
|
418
|
-
return new VehicleResource($vehicle);
|
|
509
|
+
return new VehicleResource($this->withPublicRelations($vehicle));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Load the relations the public resource reports as identifiers.
|
|
514
|
+
*
|
|
515
|
+
* One query per relation instead of one per relation per read, and it makes
|
|
516
|
+
* an assignment made moments earlier in the same request readable back
|
|
517
|
+
* immediately.
|
|
518
|
+
*/
|
|
519
|
+
protected function withPublicRelations(Vehicle $vehicle): Vehicle
|
|
520
|
+
{
|
|
521
|
+
$vehicle->loadMissing(static::PUBLIC_RELATIONS);
|
|
522
|
+
|
|
523
|
+
return $vehicle;
|
|
419
524
|
}
|
|
420
525
|
|
|
421
526
|
protected function vehicleResourceCollection($results)
|
|
@@ -212,7 +212,7 @@ class ZoneController extends Controller
|
|
|
212
212
|
return (int) $request->input('radius', 500);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
protected function serviceAreaUuid(string $publicId, array $where): ?string
|
|
215
|
+
protected function serviceAreaUuid(?string $publicId, array $where): ?string
|
|
216
216
|
{
|
|
217
217
|
return Utils::getUuid('service_areas', $where);
|
|
218
218
|
}
|
|
@@ -328,6 +328,8 @@ class SettingController extends Controller
|
|
|
328
328
|
{
|
|
329
329
|
$defaults = [
|
|
330
330
|
'mapProvider' => 'leaflet',
|
|
331
|
+
'leafletTileUrl' => '',
|
|
332
|
+
'leafletDarkTileUrl' => '',
|
|
331
333
|
'googleMapsMapType' => 'roadmap',
|
|
332
334
|
'showGoogleMapsTrafficLayer' => false,
|
|
333
335
|
'showGoogleMapsTransitLayer' => false,
|
|
@@ -418,12 +420,32 @@ class SettingController extends Controller
|
|
|
418
420
|
return [
|
|
419
421
|
...$settings,
|
|
420
422
|
'mapProvider' => $mapProvider,
|
|
423
|
+
'leafletTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletTileUrl', '')),
|
|
424
|
+
'leafletDarkTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletDarkTileUrl', '')),
|
|
421
425
|
'googleMapsMapType' => $googleMapsMapType,
|
|
422
426
|
'showGoogleMapsTrafficLayer' => filter_var(data_get($settings, 'showGoogleMapsTrafficLayer', false), FILTER_VALIDATE_BOOLEAN),
|
|
423
427
|
'showGoogleMapsTransitLayer' => filter_var(data_get($settings, 'showGoogleMapsTransitLayer', false), FILTER_VALIDATE_BOOLEAN),
|
|
424
428
|
];
|
|
425
429
|
}
|
|
426
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Sanitize a Leaflet XYZ tile URL template — only http(s) URLs are accepted,
|
|
433
|
+
* anything else is discarded so the frontend falls back to the default tiles.
|
|
434
|
+
*/
|
|
435
|
+
protected function sanitizeTileUrl($url): string
|
|
436
|
+
{
|
|
437
|
+
if (!is_string($url)) {
|
|
438
|
+
return '';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
$url = trim($url);
|
|
442
|
+
if ($url === '' || !preg_match('/^https?:\/\//i', $url)) {
|
|
443
|
+
return '';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return $url;
|
|
447
|
+
}
|
|
448
|
+
|
|
427
449
|
/**
|
|
428
450
|
* Retrieve driver scheduling settings for the current company.
|
|
429
451
|
*
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Filter\Concerns;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Support\Utils;
|
|
6
|
+
use Fleetbase\Support\Http;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a relationship filter value expressed as a public identifier into the
|
|
10
|
+
* internal uuids the column actually stores.
|
|
11
|
+
*
|
|
12
|
+
* The public API never hands out uuids, so `?vendor=vendor_abc` has to be
|
|
13
|
+
* translated before it can be compared against `vendor_uuid`. Internal console
|
|
14
|
+
* requests deliberately keep passing uuids straight through, so the uuid arm is
|
|
15
|
+
* gated on the request kind rather than on the shape of the value.
|
|
16
|
+
*/
|
|
17
|
+
trait ResolvesPublicRelationUuids
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* Resolve one or more public identifiers to uuids within the session company.
|
|
21
|
+
*
|
|
22
|
+
* @param class-string $modelClass the model the identifiers belong to
|
|
23
|
+
* @param string|array<int, string> $identifiers public_id / internal_id values (uuid when internal)
|
|
24
|
+
*
|
|
25
|
+
* @return array<int, string>
|
|
26
|
+
*/
|
|
27
|
+
protected function resolvePublicRelationUuids(string $modelClass, string|array $identifiers, ?bool $allowUuid = null): array
|
|
28
|
+
{
|
|
29
|
+
$identifiers = array_values(array_filter(Utils::arrayFrom($identifiers), static fn ($identifier) => filled($identifier)));
|
|
30
|
+
|
|
31
|
+
if ($identifiers === []) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$allowUuid = $allowUuid ?? Http::isInternalRequest($this->request);
|
|
36
|
+
$instance = new $modelClass();
|
|
37
|
+
|
|
38
|
+
return $modelClass::query()
|
|
39
|
+
->where('company_uuid', $this->session->get('company'))
|
|
40
|
+
->where(function ($query) use ($identifiers, $instance, $allowUuid) {
|
|
41
|
+
$query->whereIn('public_id', $identifiers);
|
|
42
|
+
|
|
43
|
+
if (in_array('internal_id', $instance->getFillable())) {
|
|
44
|
+
$query->orWhereIn('internal_id', $identifiers);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if ($allowUuid) {
|
|
48
|
+
$query->orWhereIn('uuid', $identifiers);
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
->pluck('uuid')
|
|
52
|
+
->all();
|
|
53
|
+
}
|
|
54
|
+
}
|