@fleetbase/fleetops-engine 0.6.49 → 0.6.50

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 (142) hide show
  1. package/addon/components/customer/create-order-form.hbs +2 -2
  2. package/addon/components/customer/form.hbs +2 -2
  3. package/addon/components/customer/order-form.hbs +2 -2
  4. package/addon/components/customer/orders.hbs +2 -2
  5. package/addon/components/display-place.hbs +2 -2
  6. package/addon/components/map/google-live-map.js +6 -2
  7. package/addon/components/map/leaflet-live-map.hbs +48 -14
  8. package/addon/components/map/leaflet-live-map.js +184 -20
  9. package/addon/components/modals/order-change-destination.hbs +46 -0
  10. package/addon/components/modals/order-change-destination.js +106 -0
  11. package/addon/components/modals/stop-activity.hbs +28 -0
  12. package/addon/components/modals/stop-activity.js +52 -0
  13. package/addon/components/modals/update-order-activity.hbs +45 -3
  14. package/addon/components/modals/update-order-activity.js +143 -5
  15. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  16. package/addon/components/orchestrator/resource-panel.js +2 -4
  17. package/addon/components/order/activity-list.hbs +2 -2
  18. package/addon/components/order/activity-timeline.hbs +31 -29
  19. package/addon/components/order/details/activity.hbs +3 -3
  20. package/addon/components/order/details/activity.js +11 -0
  21. package/addon/components/order/details/documents.js +2 -0
  22. package/addon/components/order/details/purchase-rate.hbs +2 -2
  23. package/addon/components/order/details/route.hbs +11 -3
  24. package/addon/components/order/details/route.js +91 -14
  25. package/addon/components/order/details/tracking.hbs +101 -72
  26. package/addon/components/order/details/tracking.js +149 -2
  27. package/addon/components/order/form/details.hbs +3 -3
  28. package/addon/components/order/form/documents.js +11 -4
  29. package/addon/components/order/form/route.hbs +108 -104
  30. package/addon/components/order/form/route.js +37 -1
  31. package/addon/components/order/form/service-rate.hbs +2 -2
  32. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  33. package/addon/components/order-config-manager/activity-flow.js +178 -12
  34. package/addon/components/place/address.js +1 -1
  35. package/addon/components/route-list.hbs +17 -17
  36. package/addon/components/route-list.js +42 -10
  37. package/addon/components/service-rate/details.hbs +41 -0
  38. package/addon/components/service-rate/form.hbs +118 -0
  39. package/addon/components/service-rate/form.js +55 -0
  40. package/addon/components/vehicle/form.js +1 -1
  41. package/addon/components/vehicle/pill.hbs +1 -1
  42. package/addon/controllers/management/vehicles/index/new.js +1 -1
  43. package/addon/controllers/operations/orders/index/details.js +4 -4
  44. package/addon/controllers/operations/orders/index.js +14 -0
  45. package/addon/helpers/place-address-html.js +2 -2
  46. package/addon/routes/operations/service-rates/index.js +1 -1
  47. package/addon/services/geofence.js +163 -19
  48. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  49. package/addon/services/vehicle-actions.js +1 -1
  50. package/addon/styles/fleetops-engine.css +91 -1
  51. package/addon/utils/fleet-ops-options.js +2 -1
  52. package/addon/utils/normalize-order-config-flow.js +41 -0
  53. package/addon/utils/place-address-html.js +3 -2
  54. package/addon/utils/route-visualization.js +54 -7
  55. package/app/components/modals/order-change-destination.js +1 -0
  56. package/app/components/modals/stop-activity.js +1 -0
  57. package/composer.json +1 -1
  58. package/extension.json +1 -1
  59. package/package.json +3 -3
  60. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  61. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  62. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  63. package/server/resources/views/labels/default.php +57 -49
  64. package/server/resources/views/labels/waypoint-label.php +27 -20
  65. package/server/seeders/Testing/ConnectivitySeeder.php +1 -0
  66. package/server/seeders/Testing/FleetSeeder.php +22 -22
  67. package/server/seeders/Testing/OrdersSeeder.php +6 -22
  68. package/server/seeders/Testing/VroomOrchestrationSeeder.php +21 -21
  69. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  70. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  71. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  72. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  73. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  74. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  75. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  76. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  77. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  78. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  79. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  80. package/server/src/Http/Controllers/Internal/v1/OrderController.php +429 -26
  81. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  82. package/server/src/Http/Controllers/Internal/v1/SettingController.php +2 -2
  83. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  84. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  85. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  86. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  87. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  88. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  89. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  90. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  91. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  92. package/server/src/Http/Resources/v1/Customer.php +79 -0
  93. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  94. package/server/src/Http/Resources/v1/Payload.php +23 -20
  95. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  96. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  97. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  98. package/server/src/Models/Contact.php +92 -15
  99. package/server/src/Models/Customer.php +64 -0
  100. package/server/src/Models/Driver.php +12 -3
  101. package/server/src/Models/Entity.php +2 -2
  102. package/server/src/Models/Order.php +29 -4
  103. package/server/src/Models/Payload.php +17 -1
  104. package/server/src/Models/ServiceRate.php +273 -1
  105. package/server/src/Models/ServiceRateFee.php +39 -9
  106. package/server/src/Models/TrackingNumber.php +8 -2
  107. package/server/src/Models/Vehicle.php +18 -2
  108. package/server/src/Models/Waypoint.php +2 -2
  109. package/server/src/Notifications/LateDeparture.php +2 -2
  110. package/server/src/Notifications/ProlongedStoppage.php +2 -2
  111. package/server/src/Notifications/RouteDeviation.php +2 -2
  112. package/server/src/Observers/ContactObserver.php +2 -0
  113. package/server/src/Observers/ServiceRateObserver.php +6 -6
  114. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +1 -1
  115. package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
  116. package/server/src/Support/Analytics/AbstractAnalytics.php +6 -6
  117. package/server/src/Support/Analytics/FuelEfficiency.php +2 -2
  118. package/server/src/Support/Analytics/OrdersByStatus.php +1 -1
  119. package/server/src/Support/Analytics/RevenueTrend.php +0 -1
  120. package/server/src/Support/Analytics/TopDrivers.php +1 -1
  121. package/server/src/Support/CustomerAuth.php +98 -0
  122. package/server/src/Support/LabelPdf.php +52 -0
  123. package/server/src/Support/Metrics/AbstractMetric.php +4 -4
  124. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  125. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  126. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  127. package/server/src/Tracking/TrackingStop.php +13 -11
  128. package/server/src/routes.php +43 -9
  129. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  130. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  131. package/server/tests/CustomerEndpointTest.php +199 -0
  132. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  133. package/server/tests/LabelPdfRenderingTest.php +23 -0
  134. package/server/tests/LiveControllerViewportTest.php +60 -0
  135. package/server/tests/MetricsRegistryTest.php +4 -8
  136. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  137. package/server/tests/OrderConfigEndpointTest.php +86 -0
  138. package/server/tests/OrderFileAttachmentTest.php +20 -0
  139. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  140. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  141. package/translations/en-us.yaml +1 -0
  142. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Cache;
8
8
 
9
9
  class TrackingIntelligenceService
10
10
  {
11
+ protected const TERMINAL_STATUSES = ['completed', 'canceled'];
12
+
11
13
  public function __construct(
12
14
  protected TrackingContextBuilder $contextBuilder,
13
15
  protected TrackingProviderManager $providerManager,
@@ -34,19 +36,28 @@ class TrackingIntelligenceService
34
36
  'completion_seconds' => data_get($tracker, 'eta.completion_seconds'),
35
37
  'active_stop_at' => data_get($tracker, 'eta.active_stop_at'),
36
38
  'completion_at' => data_get($tracker, 'eta.completion_at'),
39
+ 'start_seconds' => data_get($tracker, 'eta.start_seconds'),
40
+ 'start_at' => data_get($tracker, 'eta.start_at'),
37
41
  'provider' => data_get($tracker, 'provider'),
38
42
  'confidence' => data_get($tracker, 'confidence'),
43
+ 'lifecycle' => data_get($tracker, 'lifecycle'),
39
44
  'warnings' => data_get($tracker, 'warnings', []),
40
45
  ];
41
46
  }
42
47
 
43
48
  protected function buildResult(TrackingContext $context, TrackingProviderResult $providerResult, TrackingOptions $options): array
44
49
  {
45
- $now = now();
46
- $completionSeconds = $providerResult->durationInTrafficSeconds ?? $providerResult->durationSeconds;
47
- $activeStopSeconds = data_get($providerResult->legs, '0.duration_in_traffic_s', data_get($providerResult->legs, '0.duration_s', $completionSeconds));
48
- $progress = $this->progress($context, $providerResult);
49
- $warnings = array_values(array_unique([...$context->warnings, ...$providerResult->warnings]));
50
+ $now = now();
51
+ $rawCompletionSeconds = $providerResult->durationInTrafficSeconds ?? $providerResult->durationSeconds;
52
+ $rawActiveStopSeconds = data_get($providerResult->legs, '0.duration_in_traffic_s', data_get($providerResult->legs, '0.duration_s', $rawCompletionSeconds));
53
+ $lifecycle = $this->lifecycle($context, $rawActiveStopSeconds, $now);
54
+ $showsLiveEta = (bool) data_get($lifecycle, 'show_live_eta');
55
+ $showsStartEta = (bool) data_get($lifecycle, 'show_start_eta');
56
+ $completionSeconds = $showsLiveEta ? $rawCompletionSeconds : null;
57
+ $activeStopSeconds = $showsLiveEta ? $rawActiveStopSeconds : null;
58
+ $startSeconds = $showsStartEta ? $rawActiveStopSeconds : null;
59
+ $progress = $this->progress($context, $providerResult);
60
+ $warnings = array_values(array_unique([...$context->warnings, ...$providerResult->warnings]));
50
61
 
51
62
  if ($providerResult->confidence === 'low') {
52
63
  $warnings[] = 'low_confidence_eta';
@@ -64,6 +75,7 @@ class TrackingIntelligenceService
64
75
  'online' => (bool) data_get($context->driver, 'online', false),
65
76
  ],
66
77
  'progress' => $progress,
78
+ 'lifecycle' => $lifecycle,
67
79
  'stops' => $context->stops->map(fn (TrackingStop $stop) => $stop->toArray())->values()->all(),
68
80
  'active_stop' => $context->activeStop?->toArray(),
69
81
  'next_stop' => $context->nextStop?->toArray(),
@@ -73,13 +85,15 @@ class TrackingIntelligenceService
73
85
  'duration_in_traffic_s' => $providerResult->durationInTrafficSeconds,
74
86
  'polyline' => $providerResult->polyline,
75
87
  'coordinates' => $providerResult->coordinates,
76
- 'legs' => $this->legs($context, $providerResult, $now),
88
+ 'legs' => $this->legs($context, $providerResult, $now, $showsLiveEta),
77
89
  ],
78
90
  'eta' => [
79
91
  'active_stop_seconds' => $activeStopSeconds,
80
92
  'completion_seconds' => $completionSeconds,
81
93
  'active_stop_at' => $this->addSeconds($now, $activeStopSeconds),
82
94
  'completion_at' => $this->addSeconds($now, $completionSeconds),
95
+ 'start_seconds' => $startSeconds,
96
+ 'start_at' => $this->addSeconds($now, $startSeconds),
83
97
  ],
84
98
  'insights' => [
85
99
  'is_delayed' => false,
@@ -107,17 +121,17 @@ class TrackingIntelligenceService
107
121
  ];
108
122
  }
109
123
 
110
- protected function legs(TrackingContext $context, TrackingProviderResult $providerResult, Carbon $now): array
124
+ protected function legs(TrackingContext $context, TrackingProviderResult $providerResult, Carbon $now, bool $showsLiveEta = true): array
111
125
  {
112
126
  $elapsedSeconds = 0;
113
127
 
114
- return collect($providerResult->legs)->map(function ($leg, $index) use ($context, $now, &$elapsedSeconds) {
128
+ return collect($providerResult->legs)->map(function ($leg, $index) use ($context, $now, $showsLiveEta, &$elapsedSeconds) {
115
129
  $stop = $context->remainingStops->values()->get($index);
116
130
  $legSeconds = data_get($leg, 'duration_in_traffic_s', data_get($leg, 'duration_s'));
117
131
  $etaSeconds = null;
118
132
  $etaAt = null;
119
133
 
120
- if ($legSeconds !== null) {
134
+ if ($showsLiveEta && $stop instanceof TrackingStop && $legSeconds !== null) {
121
135
  $elapsedSeconds += (float) $legSeconds;
122
136
  $etaSeconds = $elapsedSeconds;
123
137
  $etaAt = $this->addSeconds($now, $etaSeconds);
@@ -131,6 +145,92 @@ class TrackingIntelligenceService
131
145
  })->all();
132
146
  }
133
147
 
148
+ protected function lifecycle(TrackingContext $context, ?float $startSeconds, Carbon $now): array
149
+ {
150
+ $order = $context->order;
151
+ $status = strtolower((string) ($order->status ?? 'created'));
152
+ $hasAssignedDriver = !empty($order->driver_assigned_uuid);
153
+ $hasStarted = $this->hasOrderStarted($order);
154
+ $isTerminal = in_array($status, self::TERMINAL_STATUSES, true);
155
+ $isDispatched = $status === 'dispatched' || (bool) $order->dispatched;
156
+
157
+ if ($isTerminal) {
158
+ return [
159
+ 'status' => $status,
160
+ 'mode' => $status,
161
+ 'message' => $status === 'completed' ? 'Order has been completed.' : 'Order has been canceled.',
162
+ 'is_terminal' => true,
163
+ 'has_started' => $hasStarted,
164
+ 'show_live_eta' => false,
165
+ 'show_start_eta' => false,
166
+ ];
167
+ }
168
+
169
+ if (!$hasAssignedDriver) {
170
+ return [
171
+ 'status' => $status,
172
+ 'mode' => 'unassigned',
173
+ 'message' => 'Assign a driver to start live tracking and improve ETA accuracy.',
174
+ 'is_terminal' => false,
175
+ 'has_started' => $hasStarted,
176
+ 'show_live_eta' => false,
177
+ 'show_start_eta' => false,
178
+ ];
179
+ }
180
+
181
+ if (!$hasStarted && $isDispatched) {
182
+ return [
183
+ 'status' => $status,
184
+ 'mode' => 'dispatched',
185
+ 'message' => 'Order has been dispatched. Estimated start is based on the assigned driver route to the first stop.',
186
+ 'is_terminal' => false,
187
+ 'has_started' => false,
188
+ 'show_live_eta' => false,
189
+ 'show_start_eta' => $startSeconds !== null,
190
+ 'start_at' => $this->addSeconds($now, $startSeconds),
191
+ ];
192
+ }
193
+
194
+ if (!$hasStarted) {
195
+ return [
196
+ 'status' => $status,
197
+ 'mode' => 'pre_start',
198
+ 'message' => 'Live ETA will begin once the order is started.',
199
+ 'is_terminal' => false,
200
+ 'has_started' => false,
201
+ 'show_live_eta' => false,
202
+ 'show_start_eta' => false,
203
+ ];
204
+ }
205
+
206
+ return [
207
+ 'status' => $status,
208
+ 'mode' => 'active',
209
+ 'message' => null,
210
+ 'is_terminal' => false,
211
+ 'has_started' => true,
212
+ 'show_live_eta' => true,
213
+ 'show_start_eta' => false,
214
+ ];
215
+ }
216
+
217
+ protected function hasOrderStarted(Order $order): bool
218
+ {
219
+ if ((bool) $order->started || $order->started_at || strtolower((string) $order->status) === 'started') {
220
+ return true;
221
+ }
222
+
223
+ try {
224
+ if ($order->relationLoaded('trackingStatuses')) {
225
+ return $order->trackingStatuses->contains(fn ($status) => strtolower((string) data_get($status, 'code')) === 'started');
226
+ }
227
+
228
+ return $order->trackingStatuses()->whereRaw('LOWER(code) = ?', ['started'])->exists();
229
+ } catch (\Throwable) {
230
+ return false;
231
+ }
232
+ }
233
+
134
234
  protected function capabilitiesFor(string $provider): array
135
235
  {
136
236
  $registry = app(TrackingProviderRegistry::class);
@@ -18,6 +18,7 @@ class TrackingStop implements \JsonSerializable
18
18
  public ?Waypoint $waypoint = null,
19
19
  public bool $completed = false,
20
20
  public ?int $sequence = null,
21
+ public ?string $trackingNumberUuid = null,
21
22
  ) {
22
23
  }
23
24
 
@@ -35,20 +36,21 @@ class TrackingStop implements \JsonSerializable
35
36
  $point = $this->point();
36
37
 
37
38
  return [
38
- 'uuid' => $this->uuid,
39
- 'public_id' => $this->publicId,
40
- 'type' => $this->type,
41
- 'status' => $this->status,
42
- 'completed' => $this->completed,
43
- 'sequence' => $this->sequence,
44
- 'address' => data_get($this->place, 'address'),
45
- 'name' => data_get($this->place, 'name'),
46
- 'location' => $point ? [
39
+ 'uuid' => $this->uuid,
40
+ 'public_id' => $this->publicId,
41
+ 'type' => $this->type,
42
+ 'status' => $this->status,
43
+ 'completed' => $this->completed,
44
+ 'sequence' => $this->sequence,
45
+ 'tracking_number_uuid' => $this->trackingNumberUuid,
46
+ 'address' => data_get($this->place, 'address'),
47
+ 'name' => data_get($this->place, 'name'),
48
+ 'location' => $point ? [
47
49
  'type' => 'Point',
48
50
  'coordinates' => [$point->getLng(), $point->getLat()],
49
51
  ] : null,
50
- 'latitude' => $point ? $point->getLat() : null,
51
- 'longitude' => $point ? $point->getLng() : null,
52
+ 'latitude' => $point ? $point->getLat() : null,
53
+ 'longitude' => $point ? $point->getLng() : null,
52
54
  ];
53
55
  }
54
56
 
@@ -49,6 +49,38 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
49
49
  $router->put('{id}', 'ContactController@update');
50
50
  $router->delete('{id}', 'ContactController@delete');
51
51
  });
52
+ // order-configs — read-only public projection of the OrderConfig flow.
53
+ $router->group(['prefix' => 'order-configs'], function () use ($router) {
54
+ $router->get('/', 'OrderConfigController@query');
55
+ $router->get('{id}', 'OrderConfigController@find');
56
+ });
57
+ // customers routes — public B2C customer auth + customer-scoped orders.
58
+ // - Public endpoints: API key only (resolves company from credential)
59
+ // - Authenticated endpoints: API key + `Customer-Token` header (Sanctum)
60
+ $router->group(['prefix' => 'customers', 'middleware' => []], function () use ($router) {
61
+ // Public auth flows
62
+ $router->post('request-creation-code', 'CustomerController@requestCreationCode');
63
+ $router->post('/', 'CustomerController@create');
64
+ $router->post('login', 'CustomerController@login');
65
+ $router->post('login-with-sms', 'CustomerController@loginWithPhone');
66
+ $router->post('verify-code', 'CustomerController@verifyCode');
67
+ $router->post('forgot-password', 'CustomerController@forgotPassword');
68
+ $router->post('reset-password', 'CustomerController@resetPassword');
69
+
70
+ // Authenticated (require Customer-Token)
71
+ $router->group(['middleware' => [Fleetbase\FleetOps\Http\Middleware\AuthenticateCustomerToken::class]], function () use ($router) {
72
+ $router->get('me', 'CustomerController@me');
73
+ $router->put('me', 'CustomerController@updateMe');
74
+ $router->match(['post', 'patch'], 'me', 'CustomerController@updateMe');
75
+ $router->post('logout', 'CustomerController@logout');
76
+ $router->post('logout-all', 'CustomerController@logoutAll');
77
+ $router->post('register-device', 'CustomerController@registerDevice');
78
+ $router->get('places', 'CustomerController@places');
79
+ $router->get('orders', 'CustomerController@orders');
80
+ $router->post('orders', 'CustomerController@createOrder');
81
+ $router->get('orders/{id}', 'CustomerController@findOrder');
82
+ });
83
+ });
52
84
  // vendors routes
53
85
  $router->group(['prefix' => 'vendors'], function () use ($router) {
54
86
  $router->post('/', 'VendorController@create');
@@ -346,8 +378,10 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
346
378
  $router->get('{id}/tracker', 'OrderController@trackerInfo');
347
379
  $router->get('{id}/eta', 'OrderController@waypointEtas');
348
380
  $router->post('{id}/ping-driver', $controller('pingDriver'));
381
+ $router->post('{id}/capture-photo/{subjectId?}', $controller('capturePhoto'));
349
382
  $router->post('process-imports', $controller('importFromFiles'));
350
383
  $router->patch('route/{id}', $controller('editOrderRoute'));
384
+ $router->patch('set-destination/{id}/{placeId}', $controller('setDestination'));
351
385
  $router->patch('update-activity/{id}', $controller('updateActivity'));
352
386
  $router->get('{id}/proofs/{subjectId?}', $controller('proofs'));
353
387
  $router->patch('bulk-assign-driver', $controller('bulkAssignDriver'));
@@ -579,16 +613,16 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
579
613
  $router->group(
580
614
  ['prefix' => 'analytics'],
581
615
  function ($router) {
582
- $router->get('operations-pulse', 'AnalyticsController@operationsPulse');
583
- $router->get('revenue-trend', 'AnalyticsController@revenueTrend');
584
- $router->get('orders-by-status', 'AnalyticsController@ordersByStatus');
585
- $router->get('on-time-delivery', 'AnalyticsController@onTimeDelivery');
586
- $router->get('top-drivers', 'AnalyticsController@topDrivers');
587
- $router->get('fuel-efficiency', 'AnalyticsController@fuelEfficiency');
588
- $router->get('issues-insights', 'AnalyticsController@issuesInsights');
616
+ $router->get('operations-pulse', 'AnalyticsController@operationsPulse');
617
+ $router->get('revenue-trend', 'AnalyticsController@revenueTrend');
618
+ $router->get('orders-by-status', 'AnalyticsController@ordersByStatus');
619
+ $router->get('on-time-delivery', 'AnalyticsController@onTimeDelivery');
620
+ $router->get('top-drivers', 'AnalyticsController@topDrivers');
621
+ $router->get('fuel-efficiency', 'AnalyticsController@fuelEfficiency');
622
+ $router->get('issues-insights', 'AnalyticsController@issuesInsights');
589
623
  $router->get('maintenance-overview', 'AnalyticsController@maintenanceOverview');
590
- $router->get('geofence-violations', 'AnalyticsController@geofenceViolations');
591
- $router->get('live-fleet', 'AnalyticsController@liveFleet');
624
+ $router->get('geofence-violations', 'AnalyticsController@geofenceViolations');
625
+ $router->get('live-fleet', 'AnalyticsController@liveFleet');
592
626
  }
593
627
  );
594
628
  $router->group(
@@ -16,8 +16,8 @@ function capacityTestOrder(string $publicId, float $weightKg, float $volumeLitre
16
16
  public float $weight;
17
17
  public string $weight_unit = 'kg';
18
18
  public float $length;
19
- public float $width = 1;
20
- public float $height = 1;
19
+ public float $width = 1;
20
+ public float $height = 1;
21
21
  public string $dimensions_unit = 'm';
22
22
 
23
23
  public function __construct(float $weightKg, float $volumeLitres)
@@ -30,8 +30,8 @@ function capacityTestOrder(string $publicId, float $weightKg, float $volumeLitre
30
30
  }
31
31
  };
32
32
 
33
- $order = new Order();
34
- $order->public_id = $publicId;
33
+ $order = new Order();
34
+ $order->public_id = $publicId;
35
35
  $order->required_skills = $skills;
36
36
  $order->setRelation('payload', $payload);
37
37
 
@@ -40,14 +40,14 @@ function capacityTestOrder(string $publicId, float $weightKg, float $volumeLitre
40
40
 
41
41
  function capacityTestVehicle(string $publicId, float $weightKg, float $volumeM3 = 0, int $maxTasks = 0, array $skills = []): Vehicle
42
42
  {
43
- $vehicle = new Vehicle();
44
- $vehicle->public_id = $publicId;
45
- $vehicle->payload_capacity = $weightKg;
46
- $vehicle->payload_capacity_volume = $volumeM3;
43
+ $vehicle = new Vehicle();
44
+ $vehicle->public_id = $publicId;
45
+ $vehicle->payload_capacity = $weightKg;
46
+ $vehicle->payload_capacity_volume = $volumeM3;
47
47
  $vehicle->payload_capacity_pallets = 0;
48
48
  $vehicle->payload_capacity_parcels = 100;
49
- $vehicle->max_tasks = $maxTasks;
50
- $vehicle->skills = $skills;
49
+ $vehicle->max_tasks = $maxTasks;
50
+ $vehicle->skills = $skills;
51
51
  $vehicle->setRelation('driver', null);
52
52
 
53
53
  return $vehicle;
@@ -0,0 +1,54 @@
1
+ <?php
2
+
3
+ test('customer contacts reject existing non-customer users by identity', function () {
4
+ $contactModel = file_get_contents(__DIR__ . '/../src/Models/Contact.php');
5
+ $conflictClass = file_get_contents(__DIR__ . '/../src/Exceptions/CustomerUserConflictException.php');
6
+ $observer = file_get_contents(__DIR__ . '/../src/Observers/ContactObserver.php');
7
+
8
+ expect($contactModel)
9
+ ->toContain('assertCustomerIdentityIsAvailable')
10
+ ->toContain('assertCustomerUserCanBeAssigned')
11
+ ->toContain("orWhereHas('companyUsers'")
12
+ ->toContain("\$user->type === 'customer'")
13
+ ->toContain('cannot be used for a customer account');
14
+
15
+ expect($conflictClass)->toContain('class CustomerUserConflictException extends UserAlreadyExistsException');
16
+
17
+ expect($observer)
18
+ ->toContain('$contact->assertCustomerIdentityIsAvailable();')
19
+ ->toContain('$contact->normalizeCustomerUser();');
20
+ });
21
+
22
+ test('customer user assignment no longer promotes staff users', function () {
23
+ $contactModel = file_get_contents(__DIR__ . '/../src/Models/Contact.php');
24
+
25
+ expect($contactModel)
26
+ ->toContain('public function assignUser(User $user, bool $sendInvite = false): self')
27
+ ->toContain('$this->assertCustomerUserCanBeAssigned($user);')
28
+ ->not->toContain("if (\$this->isCustomer() && \$user->type !== 'customer') {\n \$user->setType('customer');");
29
+ });
30
+
31
+ test('customer creation entrypoints run the customer identity guard', function () {
32
+ $apiContact = file_get_contents(__DIR__ . '/../src/Http/Controllers/Api/v1/ContactController.php');
33
+ $internalContact = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/ContactController.php');
34
+ $orderController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Api/v1/OrderController.php');
35
+ $customerForm = file_get_contents(__DIR__ . '/../../addon/components/customer/form.hbs');
36
+
37
+ expect($apiContact)->toContain('$contactCandidate->assertCustomerIdentityIsAvailable();');
38
+ expect($internalContact)->toContain('$this->assertCustomerIdentityIsAvailable($input');
39
+ expect($orderController)->toContain('$customerCandidate->assertCustomerIdentityIsAvailable();');
40
+ expect($customerForm)->toContain('is_customer=true');
41
+ });
42
+
43
+ test('customer conflict audit command is registered and read only', function () {
44
+ $provider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
45
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/AuditCustomerUserConflicts.php');
46
+
47
+ expect($provider)->toContain('AuditCustomerUserConflicts::class');
48
+ expect($command)
49
+ ->toContain('fleetops:audit-customer-user-conflicts')
50
+ ->toContain('Reports customer contacts linked to users')
51
+ ->not->toContain('->save(')
52
+ ->not->toContain('->update(')
53
+ ->not->toContain('forceFill');
54
+ });
@@ -0,0 +1,199 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\CustomerController;
4
+ use Fleetbase\FleetOps\Http\Middleware\AuthenticateCustomerToken;
5
+ use Fleetbase\FleetOps\Http\Requests\CreateCustomerOrderRequest;
6
+ use Fleetbase\FleetOps\Http\Requests\CreateCustomerRequest;
7
+ use Fleetbase\FleetOps\Http\Requests\VerifyCreateCustomerRequest;
8
+ use Fleetbase\FleetOps\Http\Resources\v1\Customer as CustomerResource;
9
+ use Fleetbase\FleetOps\Models\Customer as CustomerModel;
10
+ use Fleetbase\FleetOps\Support\CustomerAuth;
11
+
12
+ /*
13
+ |--------------------------------------------------------------------------
14
+ | Customer API surface — static shape checks.
15
+ |
16
+ | These mirror the lightweight static checks used elsewhere in this package
17
+ | (see PingDriverEndpointTest.php). They verify that the customer endpoints,
18
+ | controller methods, middleware, and supporting classes exist and are wired
19
+ | through `Api/v1/CustomerController` without the Storefront layer.
20
+ |
21
+ | End-to-end HTTP tests against a running app belong in the parent `api/`
22
+ | project's test harness; this file ensures the package surface is correct.
23
+ |--------------------------------------------------------------------------
24
+ */
25
+
26
+ test('customers route group is registered inside the consumable v1 namespace', function () {
27
+ $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
28
+
29
+ expect($routes)
30
+ ->toContain("\$router->group(['prefix' => 'customers', 'middleware' => []]")
31
+ ->and($routes)->toContain('CustomerController@requestCreationCode')
32
+ ->and($routes)->toContain('CustomerController@create')
33
+ ->and($routes)->toContain('CustomerController@login')
34
+ ->and($routes)->toContain('CustomerController@loginWithPhone')
35
+ ->and($routes)->toContain('CustomerController@verifyCode')
36
+ ->and($routes)->toContain('CustomerController@forgotPassword')
37
+ ->and($routes)->toContain('CustomerController@resetPassword');
38
+ });
39
+
40
+ test('authenticated customer routes are gated by AuthenticateCustomerToken middleware', function () {
41
+ $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
42
+
43
+ expect($routes)
44
+ ->toContain('AuthenticateCustomerToken::class')
45
+ ->and($routes)->toContain('CustomerController@me')
46
+ ->and($routes)->toContain('CustomerController@updateMe')
47
+ ->and($routes)->toContain('CustomerController@logout')
48
+ ->and($routes)->toContain('CustomerController@logoutAll')
49
+ ->and($routes)->toContain('CustomerController@orders')
50
+ ->and($routes)->toContain('CustomerController@createOrder')
51
+ ->and($routes)->toContain('CustomerController@findOrder')
52
+ ->and($routes)->toContain('CustomerController@places')
53
+ ->and($routes)->toContain('CustomerController@registerDevice');
54
+ });
55
+
56
+ test('customer controller exposes the documented method surface', function () {
57
+ $expected = [
58
+ 'requestCreationCode', 'create',
59
+ 'login', 'loginWithPhone', 'verifyCode',
60
+ 'forgotPassword', 'resetPassword',
61
+ 'me', 'updateMe', 'logout', 'logoutAll',
62
+ 'orders', 'createOrder', 'findOrder',
63
+ 'places', 'registerDevice',
64
+ ];
65
+
66
+ foreach ($expected as $method) {
67
+ expect(method_exists(CustomerController::class, $method))->toBeTrue("CustomerController::{$method} missing");
68
+ }
69
+ });
70
+
71
+ test('customer controller does not reference Storefront concerns', function () {
72
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/CustomerController.php');
73
+
74
+ expect($source)
75
+ ->not->toContain('Storefront::about')
76
+ ->not->toContain("session('storefront_key')")
77
+ ->not->toContain("session('storefront_store')")
78
+ ->not->toContain("session('storefront_network')")
79
+ ->not->toContain('storefront_id')
80
+ ->not->toContain('createStripeCustomerForContact');
81
+ });
82
+
83
+ test('customer controller does not introduce client-portal field aliases', function () {
84
+ // Public Fleetbase APIs must accept canonical model field names only. No
85
+ // aliasing like line1/state/zip → street1/province/postal_code, no portal-
86
+ // specific tagging like meta.origin, no top-level item/weight/value/mode
87
+ // aliases on order create — clients conform to the API, not vice versa.
88
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/CustomerController.php');
89
+
90
+ expect($source)
91
+ ->not->toContain("'line1'")
92
+ ->not->toContain("'line2'")
93
+ ->not->toContain("'state'")
94
+ ->not->toContain("'zip'")
95
+ ->not->toContain("'origin' => 'fleetops_customer_portal'")
96
+ ->not->toContain('fleetops_customer_portal')
97
+ ->not->toContain("\$request->input('item'")
98
+ ->not->toContain("\$request->input('value'")
99
+ ->not->toContain("\$request->input('delivery'")
100
+ ->not->toContain("\$request->input('category'");
101
+ });
102
+
103
+ test('createOrder mirrors the canonical Fleet-Ops order shape', function () {
104
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/CustomerController.php');
105
+
106
+ expect($source)
107
+ ->toContain('OrderConfig::resolveFromIdentifier')
108
+ ->toContain('buildPayloadFromInput')
109
+ ->toContain('Payload')
110
+ ->toContain('setPickup')
111
+ ->toContain('setDropoff')
112
+ ->toContain('setEntities')
113
+ ->toContain("'customer_uuid'");
114
+ });
115
+
116
+ test('verification code slugs are FleetOps-namespaced, not Storefront', function () {
117
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/CustomerController.php');
118
+
119
+ expect($source)
120
+ ->toContain("'fleetops_create_customer'")
121
+ ->toContain("'fleetops_customer_login'")
122
+ ->toContain("'fleetops_customer_password_reset'")
123
+ ->not->toContain("'storefront_create_customer'")
124
+ ->not->toContain("'storefront_login'");
125
+ });
126
+
127
+ test('AuthenticateCustomerToken enforces a customer-token + company cross-check', function () {
128
+ expect(class_exists(AuthenticateCustomerToken::class))->toBeTrue();
129
+
130
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Middleware/AuthenticateCustomerToken.php');
131
+ expect($source)
132
+ ->toContain('Customer token is missing or invalid')
133
+ ->toContain('Customer does not belong to this company')
134
+ ->toContain('CustomerAuth::resolveFromHeader')
135
+ ->toContain('CustomerAuth::setCurrent');
136
+ });
137
+
138
+ test('CustomerAuth resolves tokens by contact UUID with company-preferred fallback', function () {
139
+ expect(class_exists(CustomerAuth::class))->toBeTrue()
140
+ ->and(method_exists(CustomerAuth::class, 'resolveFromHeader'))->toBeTrue()
141
+ ->and(method_exists(CustomerAuth::class, 'current'))->toBeTrue()
142
+ ->and(method_exists(CustomerAuth::class, 'setCurrent'))->toBeTrue()
143
+ ->and(CustomerAuth::HEADER)->toBe('Customer-Token');
144
+
145
+ $source = file_get_contents(dirname(__DIR__) . '/src/Support/CustomerAuth.php');
146
+ expect($source)
147
+ ->toContain('PersonalAccessToken::findToken')
148
+ ->toContain("->where('type', 'customer')")
149
+ ->toContain("->where('company_uuid'");
150
+ });
151
+
152
+ test('Customer model extends Contact with a type=customer global scope', function () {
153
+ expect(is_subclass_of(CustomerModel::class, Fleetbase\FleetOps\Models\Contact::class))->toBeTrue();
154
+
155
+ $source = file_get_contents(dirname(__DIR__) . '/src/Models/Customer.php');
156
+ expect($source)
157
+ ->toContain("\$model->type = 'customer'")
158
+ ->toContain("->where('type', 'customer')");
159
+ });
160
+
161
+ test('Customer API resource exposes token and orders_count for the consumable shape', function () {
162
+ expect(class_exists(CustomerResource::class))->toBeTrue();
163
+
164
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Resources/v1/Customer.php');
165
+ expect($source)
166
+ ->toContain("'token'")
167
+ ->toContain("'orders_count'")
168
+ ->toContain("Str::replaceFirst('contact', 'customer'");
169
+ });
170
+
171
+ test('Customer API resource includes a company sub-object resolved from the API credential', function () {
172
+ $source = file_get_contents(dirname(__DIR__) . '/src/Http/Resources/v1/Customer.php');
173
+
174
+ expect($source)
175
+ // The sub-object lives on `company`, keyed off the customer's company_uuid.
176
+ ->toContain("'company'")
177
+ ->toContain('buildCompanyPayload')
178
+ // Currency must be resolved through the existing canonical helper —
179
+ // never hardcoded, never aliased.
180
+ ->toContain('Utils::getCompanyTransactionCurrency')
181
+ // Public-safe projection only: id, name, currency, country, phone.
182
+ ->toContain("'currency'")
183
+ ->toContain("'country'")
184
+ ->toContain("'phone'");
185
+ });
186
+
187
+ test('FormRequest validators are present and authorize via api credential', function () {
188
+ expect(class_exists(CreateCustomerRequest::class))->toBeTrue()
189
+ ->and(class_exists(VerifyCreateCustomerRequest::class))->toBeTrue()
190
+ ->and(class_exists(CreateCustomerOrderRequest::class))->toBeTrue();
191
+
192
+ $create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php');
193
+ $verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php');
194
+
195
+ expect($create)
196
+ ->toContain("'code' => 'required|exists:verification_codes,code'")
197
+ ->toContain("'password' => 'required|string|min:8'")
198
+ ->and($verify)->toContain("'mode' => 'required|in:email,sms'");
199
+ });
@@ -0,0 +1,39 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Driver;
4
+ use Fleetbase\FleetOps\Models\Vehicle;
5
+
6
+ test('driver and vehicle statuses default to available', function () {
7
+ expect((new Driver())->status)
8
+ ->toBe('available')
9
+ ->and((new Vehicle())->status)
10
+ ->toBe('available');
11
+ });
12
+
13
+ test('legacy active and null driver and vehicle statuses normalize to available', function () {
14
+ $driver = new Driver();
15
+ $driver->status = 'active';
16
+ expect($driver->status)->toBe('available');
17
+
18
+ $driver->status = null;
19
+ expect($driver->status)->toBe('available');
20
+
21
+ $vehicle = new Vehicle();
22
+ $vehicle->status = 'active';
23
+ expect($vehicle->status)->toBe('available');
24
+
25
+ $vehicle->status = null;
26
+ expect($vehicle->status)->toBe('available');
27
+ });
28
+
29
+ test('dispatch driver availability queries use available status', function () {
30
+ $listener = file_get_contents(dirname(__DIR__) . '/src/Listeners/HandleOrderDispatched.php');
31
+ $order = file_get_contents(dirname(__DIR__) . '/src/Models/Order.php');
32
+
33
+ expect($listener)
34
+ ->toContain("'status' => 'available'")
35
+ ->not->toContain("Driver::where(['status' => 'active'")
36
+ ->and($order)
37
+ ->toContain("'status' => 'available'")
38
+ ->not->toContain("Driver::where(['status' => 'active'");
39
+ });