@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.
Files changed (91) hide show
  1. package/RELEASE.md +25 -0
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -200,7 +200,16 @@ class TrackingNumberController extends Controller
200
200
  }
201
201
  }
202
202
 
203
- return Utils::findModel($tables, $where);
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 !== config('fleetops.navigator.bypass_verification_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
- 'code' => 'required|exists:verification_codes,code',
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' => ['required'],
24
- 'odometer' => ['required'],
25
- 'volume' => ['required'],
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
- 'vendor' => 'nullable|exists:vendors,public_id',
50
- 'driver' => 'nullable|exists:drivers,public_id',
51
- 'location' => ['nullable', new ResolvablePoint()],
52
- 'latitude' => ['nullable', 'required_with:longitude'],
53
- 'longitude' => ['nullable', 'required_with:latitude'],
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 an ordered list of activities, keeping only
45
- * the public-safe per-activity fields. `code` and `status` (the label)
46
- * are the contract; `complete`, `color`, `details`, `pod_method`, and
47
- * `require_pod` are useful UI hints that can ride along.
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
  }