@fleetbase/fleetops-engine 0.6.59 → 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 +25 -0
- 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/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -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/services/vehicle-actions.js +4 -2
- 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/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- 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/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- 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/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Place.php +1 -1
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +22 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +65 -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 +99 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +102 -5
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/SmallControllerContractsTest.php +28 -4
- 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/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
- package/translations/en-us.yaml +4 -0
|
@@ -39,6 +39,8 @@ use Illuminate\Support\Str;
|
|
|
39
39
|
|
|
40
40
|
class DriverController extends Controller
|
|
41
41
|
{
|
|
42
|
+
use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
|
|
43
|
+
|
|
42
44
|
/**
|
|
43
45
|
* Creates a new Fleetbase Driver resource.
|
|
44
46
|
*
|
|
@@ -164,8 +166,14 @@ class DriverController extends Controller
|
|
|
164
166
|
// get request input
|
|
165
167
|
$input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
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']);
|
|
169
177
|
|
|
170
178
|
// update driver user details
|
|
171
179
|
$driverUser = $driver->getUser();
|
|
@@ -438,6 +446,15 @@ class DriverController extends Controller
|
|
|
438
446
|
*/
|
|
439
447
|
public function registerDevice(?string $id = null, ?Request $request = null)
|
|
440
448
|
{
|
|
449
|
+
// Laravel does NOT inject a class-typed parameter that declares a default value —
|
|
450
|
+
// RouteDependencyResolverTrait skips it and the default (null) is used. So the
|
|
451
|
+
// router always called this with $request === null, which made
|
|
452
|
+
// POST /v1/drivers/{id}/register-device fail with "Call to a member function
|
|
453
|
+
// input() on null" and POST /v1/drivers/register-device answer 404 (the driver was
|
|
454
|
+
// looked up by a null user_uuid). The default has to stay, because the internal
|
|
455
|
+
// controller delegates to this method with an id only.
|
|
456
|
+
$request = $request ?? request();
|
|
457
|
+
|
|
441
458
|
try {
|
|
442
459
|
// With an id (…/{id}/register-device) look the driver up directly; without
|
|
443
460
|
// one (…/register-device and the internal delegation) resolve the driver
|
|
@@ -611,7 +628,7 @@ class DriverController extends Controller
|
|
|
611
628
|
|
|
612
629
|
// find and verify code
|
|
613
630
|
$verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
|
|
614
|
-
if (!$verificationCode && $code
|
|
631
|
+
if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
|
|
615
632
|
return response()->apiError('Invalid verification code!');
|
|
616
633
|
}
|
|
617
634
|
|
|
@@ -924,7 +941,24 @@ class DriverController extends Controller
|
|
|
924
941
|
|
|
925
942
|
protected function createUser(array $userDetails): User
|
|
926
943
|
{
|
|
927
|
-
|
|
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;
|
|
928
962
|
}
|
|
929
963
|
|
|
930
964
|
protected function getUuid(array|string $table, array $where, array $options = []): mixed
|
|
@@ -960,7 +994,21 @@ class DriverController extends Controller
|
|
|
960
994
|
*/
|
|
961
995
|
protected function currentDriver(?Request $request): Driver
|
|
962
996
|
{
|
|
963
|
-
|
|
997
|
+
// $request->user() is never populated on the public API: the `fleetbase.api`
|
|
998
|
+
// middleware calls Auth::setSession($apiCredential), which writes the session keys
|
|
999
|
+
// but leaves $login false, so no user resolver is ever bound. Reading it alone
|
|
1000
|
+
// meant POST /v1/drivers/register-device looked a driver up by a null user_uuid
|
|
1001
|
+
// and answered 404 for every consumer of the route.
|
|
1002
|
+
//
|
|
1003
|
+
// Auth::getUserFromSession() encodes this same order, but it also calls auth() and
|
|
1004
|
+
// session()->has(), neither of which exists in the SQLite test harness — so the
|
|
1005
|
+
// two sources are read directly here.
|
|
1006
|
+
$user = optional($request)->user();
|
|
1007
|
+
if (!$user instanceof User) {
|
|
1008
|
+
$user = User::where('uuid', session('user'))->first();
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return Driver::where('user_uuid', optional($user)->uuid)->firstOrFail();
|
|
964
1012
|
}
|
|
965
1013
|
|
|
966
1014
|
protected function queryDrivers(Request $request)
|
|
@@ -1035,6 +1083,36 @@ class DriverController extends Controller
|
|
|
1035
1083
|
return $company;
|
|
1036
1084
|
}
|
|
1037
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* Whether the supplied code matches the configured testing bypass code.
|
|
1088
|
+
*
|
|
1089
|
+
* Three conditions, all required, mirroring the console equivalent in
|
|
1090
|
+
* Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode:
|
|
1091
|
+
*
|
|
1092
|
+
* - a bypass code must actually be configured. The previous
|
|
1093
|
+
* `$code !== config(...)` comparison meant that on a default install --
|
|
1094
|
+
* where the config resolves to null -- omitting `code` entirely made the
|
|
1095
|
+
* check `null !== null`, i.e. false, so the guard never fired and any
|
|
1096
|
+
* caller with a valid org API credential could mint a driver token for
|
|
1097
|
+
* any driver in the install without a code at all;
|
|
1098
|
+
* - the app must not be in production, so a code left set in a deployed
|
|
1099
|
+
* .env cannot be used against a live fleet;
|
|
1100
|
+
* - the comparison is constant-time.
|
|
1101
|
+
*
|
|
1102
|
+
* Shared with Internal\v1\DriverController so both verify-code paths cannot
|
|
1103
|
+
* drift apart.
|
|
1104
|
+
*/
|
|
1105
|
+
public static function verificationBypassMatches(?string $identity, ?string $code): bool
|
|
1106
|
+
{
|
|
1107
|
+
return static::reviewAccountBypassMatches(
|
|
1108
|
+
'fleetops.navigator.bypass_verification_code',
|
|
1109
|
+
'fleetops.navigator.review_accounts',
|
|
1110
|
+
$identity,
|
|
1111
|
+
$code,
|
|
1112
|
+
'navigator'
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1038
1116
|
/**
|
|
1039
1117
|
* Patches phone number with international code.
|
|
1040
1118
|
*/
|
|
@@ -1122,4 +1200,198 @@ class DriverController extends Controller
|
|
|
1122
1200
|
}
|
|
1123
1201
|
}
|
|
1124
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
|
+
}
|
|
1125
1397
|
}
|
|
@@ -4,6 +4,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
|
|
|
4
4
|
|
|
5
5
|
use Fleetbase\FleetOps\Models\GeofenceEventLog;
|
|
6
6
|
use Fleetbase\FleetOps\Support\Utils;
|
|
7
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
7
8
|
use Fleetbase\Http\Controllers\Controller;
|
|
8
9
|
use Illuminate\Http\JsonResponse;
|
|
9
10
|
use Illuminate\Http\Request;
|
|
@@ -201,13 +202,22 @@ class GeofenceController extends Controller
|
|
|
201
202
|
*
|
|
202
203
|
* Returns the geofence event history for a specific driver.
|
|
203
204
|
*/
|
|
204
|
-
public function driverHistory(Request $request, string $
|
|
205
|
+
public function driverHistory(Request $request, string $driverId): JsonResponse
|
|
205
206
|
{
|
|
206
207
|
$companyUuid = session('company');
|
|
207
208
|
$perPage = min((int) $request->input('per_page', 50), 200);
|
|
208
209
|
|
|
210
|
+
// Addressed by public_id, like every other public endpoint. The route parameter
|
|
211
|
+
// was named driverUuid and matched against driver_uuid directly, so this was the
|
|
212
|
+
// one place in the public API asking a caller for an internal identifier — which
|
|
213
|
+
// the API does not hand out in the first place, making it unusable.
|
|
214
|
+
$driver = $this->findDriverForHistory($driverId);
|
|
215
|
+
if (!$driver) {
|
|
216
|
+
return response()->json(['error' => 'Driver resource not found.'], 404);
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
$events = $this->geofenceEventLogQuery($companyUuid)
|
|
210
|
-
->where('driver_uuid', $
|
|
220
|
+
->where('driver_uuid', $driver->uuid)
|
|
211
221
|
->with(['driver.vehicle', 'vehicle', 'order'])
|
|
212
222
|
->orderBy('occurred_at', 'desc')
|
|
213
223
|
->paginate($perPage);
|
|
@@ -217,6 +227,28 @@ class GeofenceController extends Controller
|
|
|
217
227
|
return response()->json($events);
|
|
218
228
|
}
|
|
219
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the driver a history request addresses.
|
|
232
|
+
*
|
|
233
|
+
* Public callers use the public_id, like every other v1 endpoint. The console reaches
|
|
234
|
+
* the same method through the internal route with a uuid, so that is accepted there
|
|
235
|
+
* and ONLY there — the public contract stays public_id-only rather than quietly
|
|
236
|
+
* taking either.
|
|
237
|
+
*
|
|
238
|
+
* A seam, like geofenceEventLogQuery above, so the contract tests can drive this
|
|
239
|
+
* without a database.
|
|
240
|
+
*/
|
|
241
|
+
protected function findDriverForHistory(string $driverId): ?Driver
|
|
242
|
+
{
|
|
243
|
+
$driver = Driver::where('public_id', $driverId)->first();
|
|
244
|
+
|
|
245
|
+
if (!$driver && \Fleetbase\Support\Http::isInternalRequest()) {
|
|
246
|
+
$driver = Driver::where('uuid', $driverId)->first();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return $driver;
|
|
250
|
+
}
|
|
251
|
+
|
|
220
252
|
protected function serializeEvent(GeofenceEventLog $event): array
|
|
221
253
|
{
|
|
222
254
|
$driver = $event->driver;
|
|
@@ -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
|
+
}
|
|
@@ -10,25 +10,21 @@ use Illuminate\Http\JsonResponse;
|
|
|
10
10
|
class NavigatorController extends Controller
|
|
11
11
|
{
|
|
12
12
|
/**
|
|
13
|
-
* Retrieve the driver onboard settings.
|
|
13
|
+
* Retrieve the driver onboard settings for an organization.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* then fetches the saved driver onboard settings. If settings for the current company are found, they are returned,
|
|
18
|
-
* otherwise, default settings are provided.
|
|
19
|
-
*
|
|
20
|
-
* @return JsonResponse
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Retrieve driver onboard settings.
|
|
15
|
+
* The organization is resolved from its public id. An unknown public id is a client
|
|
16
|
+
* error, not a server error, so it answers 404 rather than dereferencing a null company.
|
|
25
17
|
*
|
|
26
18
|
* @return JsonResponse
|
|
27
19
|
*/
|
|
28
20
|
public function getDriverOnboardSettings($companyId)
|
|
29
21
|
{
|
|
30
|
-
$company
|
|
31
|
-
|
|
22
|
+
$company = $this->findCompanyByPublicId($companyId);
|
|
23
|
+
if (!$company) {
|
|
24
|
+
return $this->errorResponse('Organization not found.', 404);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
$driverOnboardSettings = $this->driverOnboardSetting($company->uuid);
|
|
32
28
|
if (!$driverOnboardSettings) {
|
|
33
29
|
$driverOnboardSettings = [];
|
|
34
30
|
}
|
|
@@ -50,4 +46,9 @@ class NavigatorController extends Controller
|
|
|
50
46
|
{
|
|
51
47
|
return response()->json($payload);
|
|
52
48
|
}
|
|
49
|
+
|
|
50
|
+
protected function errorResponse(string $message, int $statusCode): JsonResponse
|
|
51
|
+
{
|
|
52
|
+
return response()->error($message, $statusCode);
|
|
53
|
+
}
|
|
53
54
|
}
|