@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.
- package/RELEASE.md +9 -20
- 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 +22 -0
- 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/src/Http/Controllers/Api/v1/DriverController.php +220 -3
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
- 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/Models/Place.php +1 -1
- package/server/src/routes.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +33 -0
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
- package/server/tests/CompactResourceSerializationTest.php +13 -7
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/RequestContractsTest.php +5 -0
- 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/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- 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 = '© <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
package/extension.json
CHANGED
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.
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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',
|