@fleetbase/fleetops-engine 0.6.60 → 0.6.61

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 (57) hide show
  1. package/RELEASE.md +9 -20
  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 +22 -0
  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/src/Http/Controllers/Api/v1/DriverController.php +220 -3
  33. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  34. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  35. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  36. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  37. package/server/src/Http/Filter/IssueFilter.php +29 -0
  38. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  39. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  40. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  41. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  42. package/server/src/Models/Place.php +1 -1
  43. package/server/src/routes.php +21 -0
  44. package/server/tests/ApiDriverControllerContractsTest.php +33 -0
  45. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  46. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  47. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  48. package/server/tests/CompactResourceSerializationTest.php +13 -7
  49. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  50. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  51. package/server/tests/RequestContractsTest.php +5 -0
  52. package/server/tests/SettingControllerContractsTest.php +33 -0
  53. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  54. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  55. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  56. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  57. package/translations/en-us.yaml +4 -0
@@ -31,6 +31,16 @@
31
31
  />
32
32
  </InputGroup>
33
33
 
34
+ {{#if this.isLeafletSelected}}
35
+ <InputGroup @name={{t "settings.map.leaflet-tile-url"}} @helpText={{t "settings.map.leaflet-tile-url-help-text"}}>
36
+ <Input @type="text" @value={{this.leafletTileUrl}} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" class="w-full form-input" />
37
+ </InputGroup>
38
+
39
+ <InputGroup @name={{t "settings.map.leaflet-dark-tile-url"}} @helpText={{t "settings.map.leaflet-dark-tile-url-help-text"}}>
40
+ <Input @type="text" @value={{this.leafletDarkTileUrl}} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" class="w-full form-input" />
41
+ </InputGroup>
42
+ {{/if}}
43
+
34
44
  {{#if this.isGoogleMapsSelected}}
35
45
  <InputGroup @name={{t "settings.map.google-maps-map-type"}} @helpText={{t "settings.map.google-maps-map-type-help-text"}}>
36
46
  <Select
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Default Leaflet raster tile sources.
3
+ *
4
+ * CARTO's `basemaps.cartocdn.com` raster tiles now require an API key and
5
+ * render an "API key required" watermark without one, so the default basemap
6
+ * is the keyless OpenStreetMap tile server. Operators can point Leaflet at any
7
+ * other XYZ provider (including a keyed CARTO URL) via Fleet-Ops map settings.
8
+ */
9
+ export const DEFAULT_LEAFLET_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
10
+ export const DEFAULT_LEAFLET_DARK_TILE_URL = DEFAULT_LEAFLET_TILE_URL;
11
+ export const DEFAULT_LEAFLET_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
12
+
13
+ /**
14
+ * Resolve the Leaflet tile URL for a theme from a map settings object.
15
+ *
16
+ * @param {Object} settings map settings, may contain `leafletTileUrl` and `leafletDarkTileUrl`
17
+ * @param {String} theme 'light' or 'dark'
18
+ * @return {String} the XYZ tile URL template to use
19
+ */
20
+ export function getLeafletTileUrl(settings = {}, theme = 'light') {
21
+ const customLight = typeof settings.leafletTileUrl === 'string' ? settings.leafletTileUrl.trim() : '';
22
+ const customDark = typeof settings.leafletDarkTileUrl === 'string' ? settings.leafletDarkTileUrl.trim() : '';
23
+
24
+ if (theme === 'dark') {
25
+ return customDark || customLight || DEFAULT_LEAFLET_DARK_TILE_URL;
26
+ }
27
+
28
+ return customLight || DEFAULT_LEAFLET_TILE_URL;
29
+ }
30
+
31
+ export default getLeafletTileUrl;
@@ -0,0 +1,13 @@
1
+ export default function preparePlaceForSave(store, place) {
2
+ if (!place || place.isNew || place.public_id) {
3
+ return place;
4
+ }
5
+
6
+ const attributes = {};
7
+
8
+ place.constructor.eachAttribute((attribute) => {
9
+ attributes[attribute] = place[attribute];
10
+ });
11
+
12
+ return store.createRecord('place', attributes);
13
+ }
@@ -0,0 +1 @@
1
+ export { default } from '@fleetbase/fleetops-engine/components/driver/card';
@@ -0,0 +1 @@
1
+ export { default } from '@fleetbase/fleetops-engine/helpers/leaflet-tile-url';
@@ -0,0 +1 @@
1
+ export { default, DEFAULT_LEAFLET_TILE_URL, DEFAULT_LEAFLET_DARK_TILE_URL, DEFAULT_LEAFLET_TILE_ATTRIBUTION, getLeafletTileUrl } from '@fleetbase/fleetops-engine/utils/leaflet-tile-url';
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.60",
3
+ "version": "0.6.61",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.60",
3
+ "version": "0.6.61",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/index.js CHANGED
@@ -44,6 +44,15 @@ module.exports = buildEngine({
44
44
  return trees;
45
45
  },
46
46
 
47
+ treeForLeafletImages: function () {
48
+ const leafletImagesPath = path.join(this.pathBase('leaflet'), 'dist', 'images');
49
+
50
+ return new Funnel(leafletImagesPath, {
51
+ destDir: 'assets/images',
52
+ include: ['marker-icon.png', 'marker-icon-2x.png', 'marker-shadow.png'],
53
+ });
54
+ },
55
+
47
56
  treeForJointJs: function () {
48
57
  const trees = [];
49
58
 
@@ -70,12 +79,14 @@ module.exports = buildEngine({
70
79
 
71
80
  mergeWithPublicTree: function (publicTree) {
72
81
  const leafletTree = this.treeForLeaflet();
82
+ const leafletImagesTree = this.treeForLeafletImages();
73
83
  const jointJsTree = this.treeForJointJs();
74
84
  const assetsTree = [
75
85
  new Funnel(path.join(__dirname, 'assets'), {
76
86
  destDir: '',
77
87
  }),
78
88
  ...leafletTree,
89
+ leafletImagesTree,
79
90
  ...jointJsTree,
80
91
  ];
81
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.60",
3
+ "version": "0.6.61",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -70,6 +70,7 @@
70
70
  "ember-radio-button": "^3.0.0-beta.1",
71
71
  "ember-tag-input": "^3.1.0",
72
72
  "ember-wormhole": "^0.6.0",
73
+ "leaflet": "^1.9.4",
73
74
  "leaflet-contextmenu": "^1.4.0",
74
75
  "leaflet-draw": "^1.0.4",
75
76
  "xlsx": "^0.18.5"
@@ -166,8 +166,14 @@ class DriverController extends Controller
166
166
  // get request input
167
167
  $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
168
168
 
169
- // get user details for driver
170
- $userDetails = $request->only(['name', 'password', 'email', 'phone']);
169
+ /*
170
+ * Deliberately no `password` here. Setting one through a general update
171
+ * meant anyone holding a driver's token — or an unlocked handset —
172
+ * could take the account without proving they knew the existing
173
+ * password. Changing a password is its own operation with its own
174
+ * proof: see changePassword(), forgotPassword() and resetPassword().
175
+ */
176
+ $userDetails = $request->only(['name', 'email', 'phone']);
171
177
 
172
178
  // update driver user details
173
179
  $driverUser = $driver->getUser();
@@ -935,7 +941,24 @@ class DriverController extends Controller
935
941
 
936
942
  protected function createUser(array $userDetails): User
937
943
  {
938
- return User::create($userDetails);
944
+ /*
945
+ * `password` is guarded on User, so mass assignment drops it without a
946
+ * word. The create endpoint has always accepted and validated one, so a
947
+ * driver created through the API could never sign in with the password
948
+ * their operator chose for them. Set it after the fact, where the
949
+ * model's mutator hashes it.
950
+ */
951
+ $password = $userDetails['password'] ?? null;
952
+ unset($userDetails['password']);
953
+
954
+ $user = User::create($userDetails);
955
+
956
+ if (is_string($password) && strlen($password)) {
957
+ $user->password = $password;
958
+ $user->save();
959
+ }
960
+
961
+ return $user;
939
962
  }
940
963
 
941
964
  protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -1177,4 +1200,198 @@ class DriverController extends Controller
1177
1200
  }
1178
1201
  }
1179
1202
  }
1203
+
1204
+ /**
1205
+ * Change the authenticated driver's password, proving the current one.
1206
+ *
1207
+ * A password change is an authorisation decision, not an attribute update.
1208
+ * Requiring the existing password is what stops a borrowed handset or a
1209
+ * leaked token from becoming a permanent account takeover, and re-issuing
1210
+ * the caller's token afterwards is what stops every *other* session from
1211
+ * outliving the change.
1212
+ */
1213
+ public function changePassword(Request $request, string $id)
1214
+ {
1215
+ $current = $request->input('current_password');
1216
+ $password = $request->input('password');
1217
+
1218
+ if (!$current || !$password) {
1219
+ return response()->apiError('current_password and password are required.', 400);
1220
+ }
1221
+
1222
+ if (strlen($password) < 8) {
1223
+ return response()->apiError('Password must be at least 8 characters.', 400);
1224
+ }
1225
+
1226
+ if ($request->filled('password_confirmation') && $request->input('password_confirmation') !== $password) {
1227
+ return response()->apiError('Password confirmation does not match.', 422);
1228
+ }
1229
+
1230
+ try {
1231
+ $driver = $this->findDriver($id, ['user']);
1232
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) {
1233
+ return response()->apiError('Driver resource not found.', 404);
1234
+ }
1235
+
1236
+ $user = static::findDriverUserRecord($driver);
1237
+ if (!$user) {
1238
+ return response()->apiError('Driver has no user account.', 422);
1239
+ }
1240
+
1241
+ if (!static::passwordMatches($user, $current)) {
1242
+ // Same wording whether the password was wrong or the account is odd
1243
+ // — a change endpoint should not become an oracle.
1244
+ return response()->apiError('The current password is incorrect.', 422);
1245
+ }
1246
+
1247
+ $user->password = $password;
1248
+ $user->save();
1249
+
1250
+ /*
1251
+ * Every other session dies with the old password. The caller keeps
1252
+ * working by being handed a fresh token in the same response, so a
1253
+ * driver who changes their password mid-shift is not signed out of the
1254
+ * device in their hand.
1255
+ */
1256
+ $user->tokens()->delete();
1257
+ $token = $user->createToken($request->input('device_name', 'navigator'));
1258
+
1259
+ return response()->json([
1260
+ 'status' => 'ok',
1261
+ 'token' => $token->plainTextToken,
1262
+ ]);
1263
+ }
1264
+
1265
+ /**
1266
+ * Send a password reset code to a driver who cannot sign in.
1267
+ *
1268
+ * Answers identically whether or not the identity exists: a reset endpoint
1269
+ * that 404s on an unknown phone number is a way to enumerate a company's
1270
+ * drivers.
1271
+ */
1272
+ public function forgotPassword(Request $request)
1273
+ {
1274
+ $identity = $request->input('identity');
1275
+ if (!$identity) {
1276
+ return response()->apiError('Identity is required.', 400);
1277
+ }
1278
+
1279
+ $user = static::findDriverUserByIdentity($identity);
1280
+ if (!$user) {
1281
+ return response()->json(['status' => 'ok']);
1282
+ }
1283
+
1284
+ try {
1285
+ static::sendResetCode($user, $identity);
1286
+ } catch (\Throwable $e) {
1287
+ return response()->apiError(static::debugEnabled() ? $e->getMessage() : 'Unable to send reset code.');
1288
+ }
1289
+
1290
+ return response()->json(['status' => 'ok']);
1291
+ }
1292
+
1293
+ /**
1294
+ * Set a new password using a code sent by forgotPassword().
1295
+ */
1296
+ public function resetPassword(Request $request)
1297
+ {
1298
+ $identity = $request->input('identity');
1299
+ $code = $request->input('code');
1300
+ $password = $request->input('password');
1301
+
1302
+ if (!$identity || !$code || !$password) {
1303
+ return response()->apiError('identity, code, and password are required.', 400);
1304
+ }
1305
+
1306
+ if (strlen($password) < 8) {
1307
+ return response()->apiError('Password must be at least 8 characters.', 400);
1308
+ }
1309
+
1310
+ $user = static::findDriverUserByIdentity($identity);
1311
+ if (!$user) {
1312
+ return response()->apiError('Invalid or expired reset code.', 422);
1313
+ }
1314
+
1315
+ $verification = static::findResetCode($user, $code);
1316
+
1317
+ if (!$verification) {
1318
+ // One message for a wrong code, an expired code and an unknown
1319
+ // identity alike.
1320
+ return response()->apiError('Invalid or expired reset code.', 422);
1321
+ }
1322
+
1323
+ $user->password = $password;
1324
+ $user->save();
1325
+
1326
+ $verification->delete();
1327
+ $user->tokens()->delete();
1328
+
1329
+ return response()->json(['status' => 'ok']);
1330
+ }
1331
+
1332
+ /** Whether the application is in debug mode, so an error may be echoed. */
1333
+ protected static function debugEnabled(): bool
1334
+ {
1335
+ return app()->hasDebugModeEnabled();
1336
+ }
1337
+
1338
+ /**
1339
+ * Load a driver's user account with every column.
1340
+ *
1341
+ * The `user` relation selects a named subset of columns, and `password` is
1342
+ * not among them — reading it through the relation yields an empty string,
1343
+ * so a password comparison would fail for everyone regardless of what they
1344
+ * typed. Anything checking a password has to load the record itself.
1345
+ */
1346
+ protected static function findDriverUserRecord(Driver $driver): ?User
1347
+ {
1348
+ if (!$driver->user_uuid) {
1349
+ return null;
1350
+ }
1351
+
1352
+ return User::where('uuid', $driver->user_uuid)->first();
1353
+ }
1354
+
1355
+ /** Whether a plaintext password matches the stored hash. */
1356
+ protected static function passwordMatches(User $user, string $plain): bool
1357
+ {
1358
+ return Hash::check($plain, $user->password);
1359
+ }
1360
+
1361
+ /** The unexpired reset code for this user, if the one supplied matches. */
1362
+ protected static function findResetCode(User $user, string $code)
1363
+ {
1364
+ return VerificationCode::where([
1365
+ 'subject_uuid' => $user->uuid,
1366
+ 'code' => $code,
1367
+ 'for' => 'driver_password_reset',
1368
+ ])->where('expires_at', '>', now())->first();
1369
+ }
1370
+
1371
+ /** Sends the reset code by whichever channel the identity names. */
1372
+ protected static function sendResetCode(User $user, string $identity): void
1373
+ {
1374
+ // if/else rather than an early return: a `return` sitting after a call
1375
+ // that can throw is a line no test can reach.
1376
+ if (Utils::isEmail($identity)) {
1377
+ VerificationCode::generateEmailVerificationFor($user, 'driver_password_reset', [
1378
+ 'subject' => config('app.name') . ' password reset',
1379
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1380
+ ]);
1381
+ } else {
1382
+ VerificationCode::generateSmsVerificationFor($user, 'driver_password_reset', [
1383
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1384
+ ]);
1385
+ }
1386
+ }
1387
+
1388
+ /** Resolve a driver's user account from an email address or a phone number. */
1389
+ protected static function findDriverUserByIdentity(string $identity): ?User
1390
+ {
1391
+ $isEmail = Utils::isEmail($identity);
1392
+ $column = $isEmail ? 'email' : 'phone';
1393
+ $value = $isEmail ? $identity : static::phone($identity);
1394
+
1395
+ return User::whereHas('driver')->where($column, $value)->first();
1396
+ }
1180
1397
  }
@@ -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
+ }
@@ -352,6 +352,12 @@ class VehicleController extends Controller
352
352
  return $request->only([
353
353
  'status', 'make', 'model', 'year', 'trim', 'type', 'plate_number', 'vin',
354
354
  'meta', 'online', 'location', 'altitude', 'heading', 'speed',
355
+ // Odometer is fillable on the model and unrestricted by the request
356
+ // rules, but was absent here — so a caller sending one received a
357
+ // 200 and a response body that looked correct while the reading was
358
+ // discarded. Recording mileage is the single most common write a
359
+ // driver app makes against a vehicle.
360
+ 'odometer', 'odometer_unit',
355
361
  'payload_capacity', 'payload_capacity_volume',
356
362
  'payload_capacity_pallets', 'payload_capacity_parcels',
357
363
  'skills', 'max_tasks', 'time_window_start', 'time_window_end', 'return_to_depot',