@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
|
@@ -200,7 +200,16 @@ class TrackingNumberController extends Controller
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
// No raw-row fallback. Utils::findModel() was called with the ARRAY of table names
|
|
204
|
+
// and, when nothing matched, passed that array to DB::table() — which stringified
|
|
205
|
+
// it to the literal table name "Array" and threw SQLSTATE[42S02]. Every
|
|
206
|
+
// unresolvable code became a 500 instead of the 400 this method's caller already
|
|
207
|
+
// handles.
|
|
208
|
+
//
|
|
209
|
+
// The fallback could not have helped anyway: qrModelResource() serializes through
|
|
210
|
+
// a typed resource keyed on the model class, and a raw stdClass row has none. The
|
|
211
|
+
// mapped models above are the endpoint's whole contract.
|
|
212
|
+
return null;
|
|
204
213
|
}
|
|
205
214
|
|
|
206
215
|
protected function qrModelResource($model)
|
|
@@ -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',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Controllers\Concerns;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Log;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accepts a fixed verification code, but only for explicitly designated accounts.
|
|
9
|
+
*
|
|
10
|
+
* App store reviewers cannot receive our SMS or email, so a fixed code has to keep
|
|
11
|
+
* working for them — including in production, which is where a review build is
|
|
12
|
+
* tested. Refusing the bypass outside production made review impossible; refusing
|
|
13
|
+
* it for everyone except named accounts keeps review working while removing the
|
|
14
|
+
* real hazard, which was that anyone holding the code could authenticate as any
|
|
15
|
+
* driver or customer.
|
|
16
|
+
*/
|
|
17
|
+
trait ResolvesReviewAccountBypass
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* @param string $codeKey config key holding the bypass code
|
|
21
|
+
* @param string $accountsKey config key holding the allowlisted identities
|
|
22
|
+
* @param string|null $identity identity being authenticated, already normalised
|
|
23
|
+
* @param string|null $code submitted verification code
|
|
24
|
+
* @param string $channel label for the audit log entry
|
|
25
|
+
*/
|
|
26
|
+
protected static function reviewAccountBypassMatches(
|
|
27
|
+
string $codeKey,
|
|
28
|
+
string $accountsKey,
|
|
29
|
+
?string $identity,
|
|
30
|
+
?string $code,
|
|
31
|
+
string $channel,
|
|
32
|
+
): bool {
|
|
33
|
+
$bypassCode = config($codeKey);
|
|
34
|
+
|
|
35
|
+
// `!== null && !== ''` rather than `!empty()`: `!empty('0')` is false, so a
|
|
36
|
+
// configured code of "0" would otherwise be silently ignored.
|
|
37
|
+
if ($bypassCode === null || $bypassCode === '' || $code === null || $code === '' || $identity === null || $identity === '') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
$accounts = array_map(
|
|
42
|
+
static fn ($account) => strtolower(trim((string) $account)),
|
|
43
|
+
(array) config($accountsKey, [])
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!in_array(strtolower(trim($identity)), $accounts, true)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Constant-time, so a wrong code cannot be recovered by timing the response.
|
|
51
|
+
if (!hash_equals((string) $bypassCode, (string) $code)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [
|
|
56
|
+
'channel' => $channel,
|
|
57
|
+
'identity' => $identity,
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -665,7 +665,7 @@ class DriverController extends FleetOpsController
|
|
|
665
665
|
|
|
666
666
|
// Find and verify code
|
|
667
667
|
$verificationCode = static::verificationCodeExists($user, $code, $for);
|
|
668
|
-
if (!$verificationCode && $code
|
|
668
|
+
if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) {
|
|
669
669
|
return response()->error('Invalid verification code!');
|
|
670
670
|
}
|
|
671
671
|
|
|
@@ -328,6 +328,8 @@ class SettingController extends Controller
|
|
|
328
328
|
{
|
|
329
329
|
$defaults = [
|
|
330
330
|
'mapProvider' => 'leaflet',
|
|
331
|
+
'leafletTileUrl' => '',
|
|
332
|
+
'leafletDarkTileUrl' => '',
|
|
331
333
|
'googleMapsMapType' => 'roadmap',
|
|
332
334
|
'showGoogleMapsTrafficLayer' => false,
|
|
333
335
|
'showGoogleMapsTransitLayer' => false,
|
|
@@ -418,12 +420,32 @@ class SettingController extends Controller
|
|
|
418
420
|
return [
|
|
419
421
|
...$settings,
|
|
420
422
|
'mapProvider' => $mapProvider,
|
|
423
|
+
'leafletTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletTileUrl', '')),
|
|
424
|
+
'leafletDarkTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletDarkTileUrl', '')),
|
|
421
425
|
'googleMapsMapType' => $googleMapsMapType,
|
|
422
426
|
'showGoogleMapsTrafficLayer' => filter_var(data_get($settings, 'showGoogleMapsTrafficLayer', false), FILTER_VALIDATE_BOOLEAN),
|
|
423
427
|
'showGoogleMapsTransitLayer' => filter_var(data_get($settings, 'showGoogleMapsTransitLayer', false), FILTER_VALIDATE_BOOLEAN),
|
|
424
428
|
];
|
|
425
429
|
}
|
|
426
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Sanitize a Leaflet XYZ tile URL template — only http(s) URLs are accepted,
|
|
433
|
+
* anything else is discarded so the frontend falls back to the default tiles.
|
|
434
|
+
*/
|
|
435
|
+
protected function sanitizeTileUrl($url): string
|
|
436
|
+
{
|
|
437
|
+
if (!is_string($url)) {
|
|
438
|
+
return '';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
$url = trim($url);
|
|
442
|
+
if ($url === '' || !preg_match('/^https?:\/\//i', $url)) {
|
|
443
|
+
return '';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return $url;
|
|
447
|
+
}
|
|
448
|
+
|
|
427
449
|
/**
|
|
428
450
|
* Retrieve driver scheduling settings for the current company.
|
|
429
451
|
*
|
|
@@ -86,6 +86,35 @@ class FuelReportFilter extends Filter
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Aliases for {@see driver()}.
|
|
91
|
+
*
|
|
92
|
+
* The base filter resolves a query parameter to a method of the same name
|
|
93
|
+
* and **silently ignores anything it cannot match**. So a client scoping a
|
|
94
|
+
* list with `driver_uuid` — the column's own name, and the name every other
|
|
95
|
+
* part of the payload uses — matched nothing, the filter was dropped, and
|
|
96
|
+
* the response came back scoped only by company: every driver's records,
|
|
97
|
+
* with a 200 and no indication that the request had been narrowed at all.
|
|
98
|
+
*
|
|
99
|
+
* For a driver app that is a disclosure, not a nuisance: a driver asking
|
|
100
|
+
* for their own fuel reports received the whole company's.
|
|
101
|
+
*/
|
|
102
|
+
public function driverUuid(?string $driver)
|
|
103
|
+
{
|
|
104
|
+
$this->driver($driver);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public function driverAssigned(?string $driver)
|
|
108
|
+
{
|
|
109
|
+
$this->driver($driver);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Alias for {@see vehicle()}, for the same reason. */
|
|
113
|
+
public function vehicleUuid(?string $vehicle)
|
|
114
|
+
{
|
|
115
|
+
$this->vehicle($vehicle);
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
public function vehicle(?string $vehicle)
|
|
90
119
|
{
|
|
91
120
|
$this->builder->whereHas('vehicle', function ($q) use ($vehicle) {
|
|
@@ -88,6 +88,35 @@ class IssueFilter extends Filter
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Aliases for {@see driver()}.
|
|
93
|
+
*
|
|
94
|
+
* The base filter resolves a query parameter to a method of the same name
|
|
95
|
+
* and **silently ignores anything it cannot match**. So a client scoping a
|
|
96
|
+
* list with `driver_uuid` — the column's own name, and the name every other
|
|
97
|
+
* part of the payload uses — matched nothing, the filter was dropped, and
|
|
98
|
+
* the response came back scoped only by company: every driver's records,
|
|
99
|
+
* with a 200 and no indication that the request had been narrowed at all.
|
|
100
|
+
*
|
|
101
|
+
* For a driver app that is a disclosure, not a nuisance: a driver asking
|
|
102
|
+
* for their own fuel reports received the whole company's.
|
|
103
|
+
*/
|
|
104
|
+
public function driverUuid(?string $driver)
|
|
105
|
+
{
|
|
106
|
+
$this->driver($driver);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public function driverAssigned(?string $driver)
|
|
110
|
+
{
|
|
111
|
+
$this->driver($driver);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Alias for {@see vehicle()}, for the same reason. */
|
|
115
|
+
public function vehicleUuid(?string $vehicle)
|
|
116
|
+
{
|
|
117
|
+
$this->vehicle($vehicle);
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
public function vehicle(?string $vehicle)
|
|
92
121
|
{
|
|
93
122
|
$this->builder->whereHas('vehicle', function ($q) use ($vehicle) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Filter;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Support\Utils;
|
|
6
|
+
use Fleetbase\Http\Filter\Filter;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
|
|
9
|
+
class MaintenanceScheduleFilter extends Filter
|
|
10
|
+
{
|
|
11
|
+
public function queryForInternal(): void
|
|
12
|
+
{
|
|
13
|
+
$this->builder->where('company_uuid', $this->session->get('company'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public function queryForPublic(): void
|
|
17
|
+
{
|
|
18
|
+
$this->queryForInternal();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public function subjectType(?string $subjectType): void
|
|
22
|
+
{
|
|
23
|
+
if (!$subjectType) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!Str::contains($subjectType, ['\\', ':'])) {
|
|
28
|
+
$subjectType = 'fleet-ops:' . $subjectType;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
$this->builder->where('subject_type', Utils::getMutationType($subjectType));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function subjectUuid(?string $subjectUuid): void
|
|
35
|
+
{
|
|
36
|
+
if ($subjectUuid) {
|
|
37
|
+
$this->builder->where('subject_uuid', $subjectUuid);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -23,7 +23,15 @@ class CreateCustomerRequest extends FleetbaseRequest
|
|
|
23
23
|
{
|
|
24
24
|
return [
|
|
25
25
|
'identity' => 'required|string',
|
|
26
|
-
|
|
26
|
+
// Presence only — authorizing the code is the controller's job, and its
|
|
27
|
+
// check is strictly stronger: it matches code + for + meta->identity,
|
|
28
|
+
// whereas `exists:verification_codes,code` accepts any live code issued
|
|
29
|
+
// for any purpose to any user. The rule is also already unenforced on the
|
|
30
|
+
// proxy path, since verifyCode() with for=fleetops_create_customer calls
|
|
31
|
+
// create(CreateCustomerRequest::createFrom($request)), which never runs
|
|
32
|
+
// validateResolved(). Keeping it here only blocks the configured
|
|
33
|
+
// non-production testing bypass (fleetops.customers.verification_bypass_code).
|
|
34
|
+
'code' => 'required|string',
|
|
27
35
|
'name' => 'required|string',
|
|
28
36
|
'password' => 'required|string|min:8',
|
|
29
37
|
'email' => [
|
|
@@ -19,10 +19,21 @@ class CreateFuelReportRequest extends FleetbaseRequest
|
|
|
19
19
|
*/
|
|
20
20
|
public function rules(): array
|
|
21
21
|
{
|
|
22
|
+
// requiredIf(POST), not required: UpdateFuelReportRequest extends this class, so a
|
|
23
|
+
// flat `required` made every partial update fail — PUT /v1/fuel-reports/{id} with
|
|
24
|
+
// just {"status": "approved"} answered "The driver field is required."
|
|
25
|
+
//
|
|
26
|
+
// `driver` is the clearest case: the update action's $request->only() list does not
|
|
27
|
+
// even include it, so validation demanded a field the endpoint then discarded.
|
|
28
|
+
// CreateFuelTransactionRequest solves the same create/update inheritance with
|
|
29
|
+
// Rule::requiredIf(isMethod('POST')); a plain conditional is used here because
|
|
30
|
+
// illuminate/validation is not autoloadable in the package's test harness.
|
|
31
|
+
$requiredOnCreate = $this->isMethod('POST') ? ['required'] : ['sometimes'];
|
|
32
|
+
|
|
22
33
|
return [
|
|
23
|
-
'driver' =>
|
|
24
|
-
'odometer' =>
|
|
25
|
-
'volume' =>
|
|
34
|
+
'driver' => $requiredOnCreate,
|
|
35
|
+
'odometer' => $requiredOnCreate,
|
|
36
|
+
'volume' => $requiredOnCreate,
|
|
26
37
|
'metric_unit' => ['nullable'],
|
|
27
38
|
'location' => ['nullable'],
|
|
28
39
|
'amount' => ['nullable'],
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Http\Requests;
|
|
4
4
|
|
|
5
|
+
use Fleetbase\FleetOps\Models\FuelProviderTransaction;
|
|
5
6
|
use Fleetbase\Http\Requests\FleetbaseRequest;
|
|
6
7
|
use Illuminate\Validation\Rule;
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ class CreateFuelTransactionRequest extends FleetbaseRequest
|
|
|
16
17
|
{
|
|
17
18
|
return [
|
|
18
19
|
'provider' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
|
|
19
|
-
'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
|
|
20
|
+
'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string', $this->uniqueProviderTransactionIdRule()],
|
|
20
21
|
'connection' => ['nullable', 'string'],
|
|
21
22
|
'fuel_report' => ['nullable', 'string'],
|
|
22
23
|
'vehicle' => ['nullable', 'string'],
|
|
@@ -47,4 +48,66 @@ class CreateFuelTransactionRequest extends FleetbaseRequest
|
|
|
47
48
|
'meta' => ['nullable', 'array'],
|
|
48
49
|
];
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
public function messages(): array
|
|
53
|
+
{
|
|
54
|
+
return [
|
|
55
|
+
'provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.',
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirrors the fuel_provider_txn_company_provider_unique index.
|
|
61
|
+
*
|
|
62
|
+
* Provider transaction ids are natural idempotency keys, so a re-sent batch
|
|
63
|
+
* used to raise an unhandled UniqueConstraintViolationException — an HTTP 500
|
|
64
|
+
* rather than a duplicate signal the client could act on.
|
|
65
|
+
*
|
|
66
|
+
* @return \Illuminate\Validation\Rules\Unique
|
|
67
|
+
*/
|
|
68
|
+
protected function uniqueProviderTransactionIdRule()
|
|
69
|
+
{
|
|
70
|
+
$provider = $this->resolveProvider();
|
|
71
|
+
|
|
72
|
+
$rule = Rule::unique('fuel_provider_transactions', 'provider_transaction_id')
|
|
73
|
+
->where(function ($query) use ($provider) {
|
|
74
|
+
$query->where('company_uuid', session('company'));
|
|
75
|
+
$query->where('provider', $provider);
|
|
76
|
+
|
|
77
|
+
return $query->whereNull('deleted_at');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// On PUT the record re-sends its own provider_transaction_id. The v1 route
|
|
81
|
+
// parameter is a public_id (fuel_provider_transaction_xxxxx), not a uuid.
|
|
82
|
+
$id = $this->route('id');
|
|
83
|
+
if (!$this->isMethod('POST') && filled($id)) {
|
|
84
|
+
$rule->ignore($id, 'public_id');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return $rule;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The uniqueness scope is per provider, but `provider` is only required on
|
|
92
|
+
* POST — a PUT may change the transaction id while leaving the provider out of
|
|
93
|
+
* the body. Fall back to the stored value so the scope is never null, which
|
|
94
|
+
* would otherwise compare against the wrong set of rows.
|
|
95
|
+
*/
|
|
96
|
+
protected function resolveProvider(): ?string
|
|
97
|
+
{
|
|
98
|
+
if ($this->filled('provider')) {
|
|
99
|
+
return $this->input('provider');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
$id = $this->route('id');
|
|
103
|
+
if (blank($id)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return FuelProviderTransaction::where('company_uuid', session('company'))
|
|
108
|
+
->where(function ($query) use ($id) {
|
|
109
|
+
$query->where('public_id', $id)->orWhere('uuid', $id);
|
|
110
|
+
})
|
|
111
|
+
->value('provider');
|
|
112
|
+
}
|
|
50
113
|
}
|
|
@@ -15,7 +15,7 @@ class CreatePartRequest extends FleetbaseRequest
|
|
|
15
15
|
public function rules(): array
|
|
16
16
|
{
|
|
17
17
|
return [
|
|
18
|
-
'sku' => ['nullable', 'string'],
|
|
18
|
+
'sku' => ['nullable', 'string', $this->uniqueSkuRule()],
|
|
19
19
|
'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
|
|
20
20
|
'manufacturer' => ['nullable', 'string'],
|
|
21
21
|
'model' => ['nullable', 'string'],
|
|
@@ -37,4 +37,42 @@ class CreatePartRequest extends FleetbaseRequest
|
|
|
37
37
|
'meta' => ['nullable', 'array'],
|
|
38
38
|
];
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
public function messages(): array
|
|
42
|
+
{
|
|
43
|
+
return [
|
|
44
|
+
'sku.unique' => 'A part with this SKU already exists.',
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mirrors the parts_company_uuid_active_sku_unique index.
|
|
50
|
+
*
|
|
51
|
+
* Without this rule a duplicate SKU reached the driver and came back as an
|
|
52
|
+
* unhandled UniqueConstraintViolationException — an HTTP 500 with no field
|
|
53
|
+
* attribution, since public v1 controllers have no QueryException catch.
|
|
54
|
+
*
|
|
55
|
+
* `nullable` runs first, so this never fires on a null SKU; that matches the
|
|
56
|
+
* nullable column and MySQL's tolerance of repeated NULLs in a unique index.
|
|
57
|
+
*
|
|
58
|
+
* @return \Illuminate\Validation\Rules\Unique
|
|
59
|
+
*/
|
|
60
|
+
protected function uniqueSkuRule()
|
|
61
|
+
{
|
|
62
|
+
$rule = Rule::unique('parts', 'sku')
|
|
63
|
+
->where(function ($query) {
|
|
64
|
+
$query->where('company_uuid', session('company'));
|
|
65
|
+
|
|
66
|
+
return $query->whereNull('deleted_at');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// On PUT the record re-sends its own SKU, so it must not collide with
|
|
70
|
+
// itself. The v1 route parameter is a public_id (part_xxxxx), not a uuid.
|
|
71
|
+
$id = $this->route('id');
|
|
72
|
+
if (!$this->isMethod('POST') && filled($id)) {
|
|
73
|
+
$rule->ignore($id, 'public_id');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return $rule;
|
|
77
|
+
}
|
|
40
78
|
}
|
|
@@ -46,11 +46,16 @@ class CreateVehicleRequest extends FleetbaseRequest
|
|
|
46
46
|
'decommissioned',
|
|
47
47
|
]),
|
|
48
48
|
],
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
'
|
|
49
|
+
// Validated rather than merely accepted: the model casts odometer
|
|
50
|
+
// to an integer, so an unchecked string would be stored as 0 — a
|
|
51
|
+
// vehicle reporting zero miles rather than an error.
|
|
52
|
+
'odometer' => 'nullable|numeric|min:0',
|
|
53
|
+
'odometer_unit' => 'nullable|string|max:12',
|
|
54
|
+
'vendor' => 'nullable|exists:vendors,public_id',
|
|
55
|
+
'driver' => 'nullable|exists:drivers,public_id',
|
|
56
|
+
'location' => ['nullable', new ResolvablePoint()],
|
|
57
|
+
'latitude' => ['nullable', 'required_with:longitude'],
|
|
58
|
+
'longitude' => ['nullable', 'required_with:latitude'],
|
|
54
59
|
];
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Resources\v1;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Http\Resources\FleetbaseResource;
|
|
6
|
+
use Fleetbase\Support\Http;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Public projection of a Manifest — a driver's route for a day.
|
|
10
|
+
*
|
|
11
|
+
* A manifest is order-agnostic: it is a sequence of stops, which may belong to
|
|
12
|
+
* several orders or to none the driver has ever seen as an order. That is why
|
|
13
|
+
* this exposes the stops directly rather than making a consumer assemble them
|
|
14
|
+
* from orders.
|
|
15
|
+
*
|
|
16
|
+
* Totals ride along because a driver wants to know what the day looks like
|
|
17
|
+
* before starting it, and computing them client-side from the stop list gives a
|
|
18
|
+
* different answer than the server's own.
|
|
19
|
+
*/
|
|
20
|
+
class Manifest extends FleetbaseResource
|
|
21
|
+
{
|
|
22
|
+
/**
|
|
23
|
+
* Transform the resource into an array.
|
|
24
|
+
*
|
|
25
|
+
* @param \Illuminate\Http\Request $request
|
|
26
|
+
*/
|
|
27
|
+
public function toArray($request): array
|
|
28
|
+
{
|
|
29
|
+
return [
|
|
30
|
+
'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
|
|
31
|
+
'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
|
|
32
|
+
'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
|
|
33
|
+
'status' => $this->status,
|
|
34
|
+
'scheduled_date' => $this->scheduled_date,
|
|
35
|
+
'started_at' => $this->started_at,
|
|
36
|
+
'completed_at' => $this->completed_at,
|
|
37
|
+
'total_distance_m' => $this->total_distance_m,
|
|
38
|
+
'total_duration_s' => $this->total_duration_s,
|
|
39
|
+
'stop_count' => $this->stop_count,
|
|
40
|
+
'completed_stops' => $this->completed_stops,
|
|
41
|
+
'pending_stops' => $this->pending_stops,
|
|
42
|
+
'driver_name' => $this->driver_name,
|
|
43
|
+
'vehicle_name' => $this->vehicle_name,
|
|
44
|
+
'notes' => $this->notes,
|
|
45
|
+
// Present only when the manifest was loaded with them, so a list
|
|
46
|
+
// endpoint stays a list.
|
|
47
|
+
'stops' => ManifestStop::collection($this->whenLoaded('stops')),
|
|
48
|
+
'updated_at' => $this->updated_at,
|
|
49
|
+
'created_at' => $this->created_at,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Resources\v1;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Http\Resources\FleetbaseResource;
|
|
6
|
+
use Fleetbase\Support\Http;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Public projection of a ManifestStop.
|
|
10
|
+
*
|
|
11
|
+
* Carries the place inline rather than as an id. A driver app rendering a route
|
|
12
|
+
* needs a name and a coordinate for every stop, and making it resolve each one
|
|
13
|
+
* separately turns a route of twenty stops into twenty-one requests.
|
|
14
|
+
*
|
|
15
|
+
* `sequence` is the order to drive them in, and is the field a re-sequence
|
|
16
|
+
* rewrites.
|
|
17
|
+
*/
|
|
18
|
+
class ManifestStop extends FleetbaseResource
|
|
19
|
+
{
|
|
20
|
+
/**
|
|
21
|
+
* Transform the resource into an array.
|
|
22
|
+
*
|
|
23
|
+
* @param \Illuminate\Http\Request $request
|
|
24
|
+
*/
|
|
25
|
+
public function toArray($request): array
|
|
26
|
+
{
|
|
27
|
+
return [
|
|
28
|
+
'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
|
|
29
|
+
'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
|
|
30
|
+
'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
|
|
31
|
+
'status' => $this->status,
|
|
32
|
+
'sequence' => $this->sequence,
|
|
33
|
+
'estimated_arrival' => $this->estimated_arrival,
|
|
34
|
+
'actual_arrival' => $this->actual_arrival,
|
|
35
|
+
'distance_from_prev_m' => $this->distance_from_prev_m,
|
|
36
|
+
'duration_from_prev_s' => $this->duration_from_prev_s,
|
|
37
|
+
'place' => new Place($this->whenLoaded('place')),
|
|
38
|
+
'order' => $this->when($this->relationLoaded('order') && $this->order, fn () => [
|
|
39
|
+
'id' => data_get($this->order, 'public_id'),
|
|
40
|
+
'tracking_number' => data_get($this->order, 'trackingNumber.tracking_number'),
|
|
41
|
+
'status' => data_get($this->order, 'status'),
|
|
42
|
+
]),
|
|
43
|
+
'updated_at' => $this->updated_at,
|
|
44
|
+
'created_at' => $this->created_at,
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -41,10 +41,25 @@ class OrderConfig extends FleetbaseResource
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Project the flow JSON into
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
44
|
+
* Project the flow JSON into a list of activities, keeping the public-safe
|
|
45
|
+
* per-activity fields.
|
|
46
|
+
*
|
|
47
|
+
* `code` and `status` (the label) are the contract; `complete`, `color`,
|
|
48
|
+
* `details`, `pod_method` and `require_pod` are useful UI hints that ride
|
|
49
|
+
* along.
|
|
50
|
+
*
|
|
51
|
+
* `activities`, `sequence` and `logic` describe the flow's *shape*, and are
|
|
52
|
+
* published because without them the list cannot be sequenced. The stored
|
|
53
|
+
* flow is a directed graph — `activities` names the codes an activity can
|
|
54
|
+
* transition to, `sequence` orders activities reachable from the same
|
|
55
|
+
* parent, and `logic` gates availability — which is precisely what
|
|
56
|
+
* `OrderConfig::nextActivity()` walks server-side. Projecting them away
|
|
57
|
+
* left consumers with an unordered set: a client rendering progress from
|
|
58
|
+
* array position marks a dispatched order complete whenever `completed`
|
|
59
|
+
* happens to be declared earlier, and can offer no next step at all.
|
|
60
|
+
*
|
|
61
|
+
* These are descriptions of the configured workflow, not internal state, so
|
|
62
|
+
* there is nothing here a consumer of the config should not see.
|
|
48
63
|
*/
|
|
49
64
|
protected function projectFlow(): array
|
|
50
65
|
{
|
|
@@ -66,9 +81,41 @@ class OrderConfig extends FleetbaseResource
|
|
|
66
81
|
'complete' => (bool) ($activity['complete'] ?? false),
|
|
67
82
|
'pod_method' => $activity['pod_method'] ?? null,
|
|
68
83
|
'require_pod' => (bool) ($activity['require_pod'] ?? false),
|
|
84
|
+
'sequence' => isset($activity['sequence']) ? (int) $activity['sequence'] : null,
|
|
85
|
+
'activities' => static::projectTransitions($activity),
|
|
86
|
+
'logic' => $activity['logic'] ?? null,
|
|
69
87
|
];
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
return $activities;
|
|
73
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The codes an activity can transition to, as a list of strings.
|
|
95
|
+
*
|
|
96
|
+
* Stored flows have been written both ways over time: a bare list of codes,
|
|
97
|
+
* and a list of objects carrying a `code`. Normalising here means consumers
|
|
98
|
+
* do not each have to guess which one they were handed.
|
|
99
|
+
*/
|
|
100
|
+
protected static function projectTransitions(array $activity): array
|
|
101
|
+
{
|
|
102
|
+
$transitions = $activity['activities'] ?? [];
|
|
103
|
+
if (!is_array($transitions)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$codes = [];
|
|
108
|
+
foreach ($transitions as $transition) {
|
|
109
|
+
if (is_string($transition)) {
|
|
110
|
+
$codes[] = $transition;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (is_array($transition) && isset($transition['code']) && is_string($transition['code'])) {
|
|
115
|
+
$codes[] = $transition['code'];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return $codes;
|
|
120
|
+
}
|
|
74
121
|
}
|