@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.
Files changed (109) hide show
  1. package/RELEASE.md +21 -17
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +26 -1
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. 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
- $input = $this->vehicleInputFromRequest($request);
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
- $input = $this->vehicleInputFromRequest($request);
102
-
103
- // vendor assignment
104
- if ($request->has('vendor')) {
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
- return $request->only([
353
- 'status', 'make', 'model', 'year', 'trim', 'type', 'plate_number', 'vin',
354
- 'meta', 'online', 'location', 'altitude', 'heading', 'speed',
355
- 'payload_capacity', 'payload_capacity_volume',
356
- 'payload_capacity_pallets', 'payload_capacity_parcels',
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
+ }