@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
@@ -50,7 +50,7 @@ class DriverController extends Controller
50
50
  $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
51
51
 
52
52
  // Add default status
53
- $input['status'] = 'active';
53
+ $input['status'] = $request->input('status', 'available');
54
54
 
55
55
  // get user details for driver
56
56
  $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']);
@@ -0,0 +1,54 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
+
5
+ use Fleetbase\FleetOps\Http\Resources\v1\OrderConfig as OrderConfigResource;
6
+ use Fleetbase\FleetOps\Models\OrderConfig;
7
+ use Fleetbase\Http\Controllers\Controller;
8
+ use Illuminate\Database\Eloquent\ModelNotFoundException;
9
+ use Illuminate\Http\Request;
10
+
11
+ /**
12
+ * Public, read-only API surface for OrderConfigs.
13
+ *
14
+ * The internal admin console manages the full OrderConfig lifecycle through
15
+ * `/int/v1/order-configs`. This public endpoint exposes the projected,
16
+ * non-sensitive subset (flow activities + metadata) that consumers like a
17
+ * customer portal need to render status filter chips, activity labels, and
18
+ * other UI driven by the active flow.
19
+ */
20
+ class OrderConfigController extends Controller
21
+ {
22
+ /**
23
+ * List the OrderConfigs for the company resolved from the API credential.
24
+ *
25
+ * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
26
+ */
27
+ public function query(Request $request)
28
+ {
29
+ $results = OrderConfig::queryWithRequest($request);
30
+
31
+ return OrderConfigResource::collection($results);
32
+ }
33
+
34
+ /**
35
+ * Find a single OrderConfig. The `{id}` segment accepts any identifier
36
+ * supported by {@see OrderConfig::resolveFromIdentifier} — `uuid`,
37
+ * `public_id`, `namespace`, or short `key` (e.g. `transport`).
38
+ *
39
+ * @return OrderConfigResource|\Illuminate\Http\JsonResponse
40
+ */
41
+ public function find(string $id)
42
+ {
43
+ $orderConfig = OrderConfig::resolveFromIdentifier($id);
44
+ if (!$orderConfig) {
45
+ try {
46
+ $orderConfig = OrderConfig::findRecordOrFail($id);
47
+ } catch (ModelNotFoundException $e) {
48
+ return response()->apiError('Order config not found.', 404);
49
+ }
50
+ }
51
+
52
+ return new OrderConfigResource($orderConfig);
53
+ }
54
+ }
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
5
5
  use Fleetbase\FleetOps\Events\OrderDispatchFailed;
6
6
  use Fleetbase\FleetOps\Events\OrderReady;
7
7
  use Fleetbase\FleetOps\Events\OrderStarted;
8
+ use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
8
9
  use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
9
10
  use Fleetbase\FleetOps\Flow\Activity;
10
11
  use Fleetbase\FleetOps\Http\Requests\CreateOrderRequest;
@@ -23,6 +24,7 @@ use Fleetbase\FleetOps\Models\Place;
23
24
  use Fleetbase\FleetOps\Models\Proof;
24
25
  use Fleetbase\FleetOps\Models\ServiceQuote;
25
26
  use Fleetbase\FleetOps\Models\Waypoint;
27
+ use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops;
26
28
  use Fleetbase\FleetOps\Support\Utils;
27
29
  use Fleetbase\Http\Controllers\Controller;
28
30
  use Fleetbase\Http\Resources\Comment as CommentResource;
@@ -43,6 +45,8 @@ use Illuminate\Validation\ValidationException;
43
45
 
44
46
  class OrderController extends Controller
45
47
  {
48
+ use ResolvesOrderServiceStops;
49
+
46
50
  /**
47
51
  * Creates a new Fleetbase Order resource.
48
52
  *
@@ -251,6 +255,13 @@ class OrderController extends Controller
251
255
  $customer = Arr::only($customer, ['internal_id', 'name', 'title', 'email', 'phone', 'meta']);
252
256
 
253
257
  try {
258
+ $customerCandidate = new Contact([
259
+ ...$customer,
260
+ 'company_uuid' => session('company'),
261
+ 'type' => 'customer',
262
+ ]);
263
+ $customerCandidate->assertCustomerIdentityIsAvailable();
264
+
254
265
  $customer = Contact::firstOrCreate(
255
266
  [
256
267
  'company_uuid' => session('company'),
@@ -263,19 +274,12 @@ class OrderController extends Controller
263
274
  'type' => 'customer',
264
275
  ]
265
276
  );
277
+ } catch (CustomerUserConflictException $e) {
278
+ return response()->apiError($e->getMessage());
279
+ } catch (UserAlreadyExistsException $e) {
280
+ return response()->apiError($e->getMessage());
266
281
  } catch (\Exception $e) {
267
282
  return response()->apiError('Failed to find or create customer for order.');
268
- } catch (UserAlreadyExistsException $e) {
269
- try {
270
- // If user already exist then assign user to this customer and the company
271
- $existingUser = $e->getUser();
272
- // Assign user to customer
273
- if ($existingUser && $customer) {
274
- $customer->assignUser($existingUser);
275
- }
276
- } catch (\Exception $e) {
277
- return response()->apiError('Failed to find or create customer for order.');
278
- }
279
283
  }
280
284
 
281
285
  if (Str::isUuid($customer)) {
@@ -1043,7 +1047,17 @@ class OrderController extends Controller
1043
1047
  // if string $id
1044
1048
  if (!$order) {
1045
1049
  try {
1046
- $order = Order::findRecordOrFail($id, ['driverAssigned', 'payload.entities', 'payload.currentWaypoint', 'payload.waypoints']);
1050
+ $order = Order::findRecordOrFail($id, [
1051
+ 'driverAssigned',
1052
+ 'payload.entities',
1053
+ 'payload.pickup',
1054
+ 'payload.dropoff',
1055
+ 'payload.return',
1056
+ 'payload.currentWaypoint',
1057
+ 'payload.waypoints',
1058
+ 'payload.waypointMarkers.place',
1059
+ 'payload.waypointMarkers.trackingNumber.status',
1060
+ ]);
1047
1061
  } catch (ModelNotFoundException $exception) {
1048
1062
  return response()->apiError('Order resource not found.', 404);
1049
1063
  }
@@ -1094,37 +1108,8 @@ class OrderController extends Controller
1094
1108
  /** @var \Fleetbase\LaravelMysqlSpatial\Types\Point */
1095
1109
  $location = $order->getLastLocation();
1096
1110
 
1097
- // Check if multiple waypoint order to update activity for
1098
- $isMultipleWaypointOrder = (bool) $order->payload->isMultipleDropOrder;
1099
-
1100
- // if is multi drop order and no current destination set it
1101
- if ($isMultipleWaypointOrder && !$order->payload->current_waypoint_uuid) {
1102
- $order->payload->setFirstWaypoint($activity, $location);
1103
- }
1104
-
1105
- // Handle multiple dropoff waypoint activity
1106
- if (Utils::isActivity($activity) && $activity->completesOrder() && $isMultipleWaypointOrder) {
1107
- // Check if every waypoint is completed
1108
- $isCompleted = $order->payload->waypointMarkers->every(function ($waypoint) {
1109
- return $waypoint->complete;
1110
- });
1111
-
1112
- // only update activity for waypoint
1113
- if (!$isCompleted) {
1114
- $order->payload->updateWaypointActivity($activity, $location, $proof);
1115
- $order->payload->setNextWaypointDestination();
1116
- $order->payload->load('waypointMarkers');
1117
-
1118
- // recheck if order is completed
1119
- $isFullyCompleted = $order->payload->waypointMarkers->every(function ($waypoint) {
1120
- return $waypoint->complete;
1121
- });
1122
-
1123
- if (!$isFullyCompleted) {
1124
- return new OrderResource($order);
1125
- }
1126
- }
1127
- }
1111
+ // Check if multiple waypoint order to update activity for.
1112
+ $isMultipleWaypointOrder = $this->payloadHasWaypoints($order->payload);
1128
1113
 
1129
1114
  // also update for each order entities if not multiple drop order
1130
1115
  // all entities will share the same activity status as is one drop order
@@ -1136,23 +1121,48 @@ class OrderController extends Controller
1136
1121
  foreach ($order->payload->entities as $entity) {
1137
1122
  $entity->insertActivity($activity, $location, $proof);
1138
1123
  }
1139
- } else {
1140
- // Update parent order when status is `dispatched` or `started`
1141
- if (in_array($activity->code, ['started', 'dispatched'])) {
1142
- $order->updateActivity($activity, $proof);
1124
+ // Handle order completion
1125
+ if (Utils::isActivity($activity) && $activity->completesOrder()) {
1126
+ $order->driverAssigned?->unassignCurrentJob();
1127
+ $order->complete($this->resolveProof($proof));
1143
1128
  }
1144
1129
 
1145
- $order->payload->updateWaypointActivity($activity, $location);
1130
+ return new OrderResource($order);
1131
+ }
1132
+
1133
+ if ($activity->is('started')) {
1134
+ $order->updateActivity($activity, $proof);
1135
+
1136
+ $this->ensurePayloadCurrentServiceStop($order->payload);
1137
+ if (!$this->payloadHasCurrentServiceStopActivity($order->payload, $activity)) {
1138
+ $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
1139
+ }
1140
+
1141
+ return new OrderResource($order->refresh());
1142
+ }
1143
+
1144
+ $currentStop = $this->ensurePayloadCurrentServiceStop($order->payload);
1145
+ $isCompletingCurrentStop = Utils::isActivity($activity) && $activity->complete();
1146
+
1147
+ $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
1148
+
1149
+ if (Utils::isActivity($activity) && $activity->is('canceled')) {
1150
+ $order->driverAssigned?->unassignCurrentJob();
1151
+ $order->cancel();
1152
+
1153
+ return new OrderResource($order->refresh());
1146
1154
  }
1147
1155
 
1148
- // Handle order completion
1149
1156
  if (Utils::isActivity($activity) && $activity->completesOrder()) {
1150
- // unset from driver current job
1151
- $order->driverAssigned->unassignCurrentJob();
1152
- $order->complete();
1157
+ $nextStop = $isCompletingCurrentStop ? $this->advanceCurrentServiceStopDestination($order, $order->payload) : null;
1158
+
1159
+ if (!$nextStop || !$currentStop) {
1160
+ $order->driverAssigned?->unassignCurrentJob();
1161
+ $order->complete($this->resolveProof($proof));
1162
+ }
1153
1163
  }
1154
1164
 
1155
- return new OrderResource($order);
1165
+ return new OrderResource($order->refresh());
1156
1166
  }
1157
1167
 
1158
1168
  /**
@@ -1165,7 +1175,14 @@ class OrderController extends Controller
1165
1175
  $waypointId = $request->input('waypoint');
1166
1176
 
1167
1177
  try {
1168
- $order = Order::findRecordOrFail($id, ['payload']);
1178
+ $order = Order::findRecordOrFail($id, [
1179
+ 'payload.pickup',
1180
+ 'payload.dropoff',
1181
+ 'payload.return',
1182
+ 'payload.waypoints',
1183
+ 'payload.waypointMarkers.place',
1184
+ 'payload.waypointMarkers.trackingNumber.status',
1185
+ ]);
1169
1186
  } catch (ModelNotFoundException $exception) {
1170
1187
  return response()->json(
1171
1188
  [
@@ -1175,19 +1192,16 @@ class OrderController extends Controller
1175
1192
  );
1176
1193
  }
1177
1194
 
1178
- // Get waypoint record if available
1179
- $waypoint = null;
1195
+ $stop = null;
1180
1196
  if ($waypointId) {
1181
- $waypoint = Waypoint::where('payload_uuid', $order->payload_uuid)->whereHas('place', function ($query) use ($waypointId) {
1182
- $query->where('public_id', $waypointId);
1183
- })->first();
1197
+ $stop = $this->resolveServiceStopFromKey($order->payload, $waypointId);
1184
1198
  }
1185
1199
 
1186
1200
  $orderConfig = $order->ensureOrderConfig();
1187
1201
  if (!$orderConfig) {
1188
1202
  return response()->apiError('No order config found for order.');
1189
1203
  }
1190
- $activities = $orderConfig->nextActivity($waypoint);
1204
+ $activities = $stop ? $this->nextActivitiesForServiceStop($order, $order->payload, $stop) : $orderConfig->nextActivity();
1191
1205
 
1192
1206
  // If activity is to complete order add proof of delivery properties if required
1193
1207
  // This is a temporary fix until activity is updated to handle POD on it's own
@@ -1282,7 +1296,18 @@ class OrderController extends Controller
1282
1296
  public function setDestination(string $id, string $placeId)
1283
1297
  {
1284
1298
  try {
1285
- $order = Order::findRecordOrFail($id, ['payload.waypoints', 'payload.pickup', 'payload.dropoff', 'driverAssigned', 'vehicleAssigned', 'customer', 'facilitator']);
1299
+ $order = Order::findRecordOrFail($id, [
1300
+ 'payload.pickup',
1301
+ 'payload.dropoff',
1302
+ 'payload.return',
1303
+ 'payload.waypoints',
1304
+ 'payload.waypointMarkers.place',
1305
+ 'payload.waypointMarkers.trackingNumber.status',
1306
+ 'driverAssigned',
1307
+ 'vehicleAssigned',
1308
+ 'customer',
1309
+ 'facilitator',
1310
+ ]);
1286
1311
  } catch (ModelNotFoundException $exception) {
1287
1312
  return response()->apiError('Order resource not found.', 404);
1288
1313
  }
@@ -1290,17 +1315,13 @@ class OrderController extends Controller
1290
1315
  // Get the order payload
1291
1316
  $payload = $order->payload;
1292
1317
 
1293
- // Resolve the place
1294
- $place = $payload->waypoints->firstWhere('public_id', $placeId)
1295
- ?? (($payload->pickup && $payload->pickup->public_id === $placeId) ? $payload->pickup : null)
1296
- ?? (($payload->dropoff && $payload->dropoff->public_id === $placeId) ? $payload->dropoff : null);
1297
-
1298
- if (!$place) {
1318
+ $stop = $this->resolveServiceStopFromKey($payload, $placeId);
1319
+ if (!$stop) {
1299
1320
  return response()->apiError('Place resource is not a valid destination.', 422);
1300
1321
  }
1301
1322
 
1302
1323
  // Persist destination choice
1303
- $payload->setCurrentWaypoint($place);
1324
+ $this->setPayloadCurrentServiceStop($payload, $stop);
1304
1325
 
1305
1326
  return new OrderResource($order->refresh());
1306
1327
  }
@@ -1385,7 +1406,7 @@ class OrderController extends Controller
1385
1406
 
1386
1407
  // Load Order
1387
1408
  try {
1388
- $order = Order::findRecordOrFail($id);
1409
+ $order = Order::findRecordOrFail($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
1389
1410
  } catch (ModelNotFoundException $e) {
1390
1411
  return response()->apiError('Order resource not found.', 404);
1391
1412
  }
@@ -1580,7 +1601,7 @@ class OrderController extends Controller
1580
1601
 
1581
1602
  // Load Order
1582
1603
  try {
1583
- $order = Order::findRecordOrFail($id);
1604
+ $order = Order::findRecordOrFail($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
1584
1605
  } catch (ModelNotFoundException $e) {
1585
1606
  return response()->apiError('Order resource not found.', 404);
1586
1607
  }
@@ -1678,6 +1699,13 @@ class OrderController extends Controller
1678
1699
  return $order;
1679
1700
  }
1680
1701
 
1702
+ if (in_array($type, ['place', 'waypoint'], true)) {
1703
+ $stop = $this->resolveServiceStopFromKey($order->payload, $subjectId);
1704
+ if ($stop) {
1705
+ return ($stop['waypoint'] ?? null) instanceof Waypoint ? $stop['waypoint'] : ($stop['place'] ?? $order);
1706
+ }
1707
+ }
1708
+
1681
1709
  return match ($type) {
1682
1710
  'place', 'waypoint' => Waypoint::withoutGlobalScopes()
1683
1711
  ->where('payload_uuid', $order->payload_uuid)
@@ -24,7 +24,7 @@ class ServiceAreaController extends Controller
24
24
  public function create(CreateServiceAreaRequest $request)
25
25
  {
26
26
  // get request input
27
- $input = $request->only(['name', 'type', 'status', 'country', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
27
+ $input = $request->only(['name', 'type', 'status', 'country', 'border', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
28
28
 
29
29
  // get radius for creating service area border - default to 500 meters
30
30
  $radius = (int) $request->input('radius', 500);
@@ -99,7 +99,7 @@ class ServiceAreaController extends Controller
99
99
  }
100
100
 
101
101
  // get request input
102
- $input = $request->only(['name', 'type', 'status', 'country', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
102
+ $input = $request->only(['name', 'type', 'status', 'country', 'border', 'color', 'stroke_color', 'trigger_on_entry', 'trigger_on_exit', 'dwell_threshold_minutes', 'speed_limit_kmh']);
103
103
 
104
104
  // get radius for creating service area border - default to 500 meters
105
105
  $radius = $request->input('radius', 500);
@@ -67,7 +67,7 @@ class VehicleController extends Controller
67
67
  $vehicle = Vehicle::create($input);
68
68
 
69
69
  // driver assignment
70
- if ($request->has('driver')) {
70
+ if ($request->exists('driver') && !empty($request->input('driver'))) {
71
71
  // set this vehicle to the driver
72
72
  try {
73
73
  $driver = Driver::findRecordOrFail($request->input('driver'));
@@ -80,8 +80,7 @@ class VehicleController extends Controller
80
80
  );
81
81
  }
82
82
 
83
- $driver->vehicle_uuid = $vehicle->uuid;
84
- $driver->save();
83
+ $vehicle->assignDriver($driver);
85
84
  }
86
85
 
87
86
  // response the driver resource
@@ -150,6 +149,24 @@ class VehicleController extends Controller
150
149
  // save the update
151
150
  $vehicle->save();
152
151
 
152
+ if ($request->exists('driver')) {
153
+ if (empty($request->input('driver'))) {
154
+ $vehicle->unassignDriver();
155
+ } else {
156
+ try {
157
+ $driver = Driver::findRecordOrFail($request->input('driver'));
158
+ $vehicle->assignDriver($driver);
159
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) {
160
+ return response()->json(
161
+ [
162
+ 'error' => 'The driver attempted to assign this vehicle was not found.',
163
+ ],
164
+ 404
165
+ );
166
+ }
167
+ }
168
+ }
169
+
153
170
  // get udpated vehicle
154
171
  $vehicle = $vehicle->refresh();
155
172
 
@@ -28,6 +28,7 @@ class ContactController extends FleetOpsController
28
28
  public function onBeforeCreate(Request $request, array &$input)
29
29
  {
30
30
  $this->resolveUserInput($request, $input);
31
+ $this->assertCustomerIdentityIsAvailable($input);
31
32
  }
32
33
 
33
34
  /**
@@ -40,6 +41,7 @@ class ContactController extends FleetOpsController
40
41
  }
41
42
 
42
43
  $this->resolveUserInput($request, $input);
44
+ $this->assertCustomerIdentityIsAvailable($input, $contact);
43
45
  }
44
46
 
45
47
  /**
@@ -146,4 +148,21 @@ class ContactController extends FleetOpsController
146
148
  $input['user_uuid'] = User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user;
147
149
  unset($input['user']);
148
150
  }
151
+
152
+ private function assertCustomerIdentityIsAvailable(array $input, ?Contact $contact = null): void
153
+ {
154
+ $type = data_get($input, 'type', $contact?->type);
155
+ if ($type !== 'customer') {
156
+ return;
157
+ }
158
+
159
+ $customer = $contact ? $contact->replicate() : new Contact();
160
+ if ($contact) {
161
+ $customer->forceFill($contact->getAttributes());
162
+ $customer->exists = $contact->exists;
163
+ }
164
+
165
+ $customer->forceFill($input);
166
+ $customer->assertCustomerIdentityIsAvailable();
167
+ }
149
168
  }
@@ -22,6 +22,10 @@ use Illuminate\Http\Request;
22
22
  */
23
23
  class LiveController extends Controller
24
24
  {
25
+ protected const DEFAULT_VIEWPORT_LIMIT = 500;
26
+ protected const MAX_VIEWPORT_LIMIT = 1000;
27
+ protected const VIEWPORT_BOUNDS_PRECISION = 4;
28
+
25
29
  /**
26
30
  * Get coordinates for active orders.
27
31
  *
@@ -134,33 +138,19 @@ class LiveController extends Controller
134
138
  */
135
139
  public function drivers(Request $request)
136
140
  {
137
- $bounds = $request->input('bounds'); // Map viewport bounds: [south, west, north, east]
138
- $cacheParams = ['bounds' => $bounds];
141
+ $bounds = $this->normalizeLiveBounds($request);
142
+ $limit = $this->normalizeLiveLimit($request);
143
+ $cacheParams = ['bounds' => $bounds, 'limit' => $limit];
139
144
 
140
- return LiveCacheService::remember('drivers', $cacheParams, function () use ($bounds) {
145
+ return LiveCacheService::remember('drivers', $cacheParams, function () use ($bounds, $limit) {
141
146
  $query = Driver::where(['company_uuid' => session('company')])
142
147
  ->with(['user', 'vehicle'])
143
148
  ->applyDirectivesForPermissions('fleet-ops list driver');
144
149
 
145
- // Filter out drivers with invalid coordinates
146
- $query->whereNotNull('location')
147
- ->whereRaw('
148
- ST_Y(location) BETWEEN -90 AND 90
149
- AND ST_X(location) BETWEEN -180 AND 180
150
- AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
151
- ');
152
-
153
- // Apply spatial filtering if bounds are provided
154
- if ($bounds && is_array($bounds) && count($bounds) === 4) {
155
- [$south, $west, $north, $east] = $bounds;
156
-
157
- // Use MySQL spatial functions for POINT column
158
- // ST_Within checks if location is within the bounding box
159
- $query->whereRaw(
160
- 'ST_Within(location, ST_MakeEnvelope(POINT(?, ?), POINT(?, ?)))',
161
- [$west, $south, $east, $north]
162
- );
163
- }
150
+ $this->applyLiveLocationGuards($query);
151
+ $this->applyLiveViewportBounds($query, $bounds);
152
+
153
+ $query->orderByDesc('updated_at')->orderByDesc('id')->limit($limit);
164
154
 
165
155
  $drivers = $query->get();
166
156
 
@@ -175,34 +165,20 @@ class LiveController extends Controller
175
165
  */
176
166
  public function vehicles(Request $request)
177
167
  {
178
- $bounds = $request->input('bounds'); // Map viewport bounds: [south, west, north, east]
179
- $cacheParams = ['bounds' => $bounds];
168
+ $bounds = $this->normalizeLiveBounds($request);
169
+ $limit = $this->normalizeLiveLimit($request);
170
+ $cacheParams = ['bounds' => $bounds, 'limit' => $limit];
180
171
 
181
- return LiveCacheService::remember('vehicles', $cacheParams, function () use ($bounds) {
172
+ return LiveCacheService::remember('vehicles', $cacheParams, function () use ($bounds, $limit) {
182
173
  // Fetch vehicles that are online
183
174
  $query = Vehicle::where(['company_uuid' => session('company')])
184
175
  ->with(['devices', 'driver'])
185
176
  ->applyDirectivesForPermissions('fleet-ops list vehicle');
186
177
 
187
- // Filter out vehicles with invalid coordinates
188
- $query->whereNotNull('location')
189
- ->whereRaw('
190
- ST_Y(location) BETWEEN -90 AND 90
191
- AND ST_X(location) BETWEEN -180 AND 180
192
- AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
193
- ');
194
-
195
- // Apply spatial filtering if bounds are provided
196
- if ($bounds && is_array($bounds) && count($bounds) === 4) {
197
- [$south, $west, $north, $east] = $bounds;
198
-
199
- // Use MySQL spatial functions for POINT column
200
- // ST_Within checks if location is within the bounding box
201
- $query->whereRaw(
202
- 'ST_Within(location, ST_MakeEnvelope(POINT(?, ?), POINT(?, ?)))',
203
- [$west, $south, $east, $north]
204
- );
205
- }
178
+ $this->applyLiveLocationGuards($query);
179
+ $this->applyLiveViewportBounds($query, $bounds);
180
+
181
+ $query->orderByDesc('updated_at')->orderByDesc('id')->limit($limit);
206
182
 
207
183
  $vehicles = $query->get();
208
184
 
@@ -217,39 +193,98 @@ class LiveController extends Controller
217
193
  */
218
194
  public function places(Request $request)
219
195
  {
196
+ $bounds = $this->normalizeLiveBounds($request);
197
+ $limit = $this->normalizeLiveLimit($request);
198
+
220
199
  // Cache key includes filter parameters
221
- $cacheParams = $request->only(['query', 'type', 'country', 'limit', 'bounds']);
200
+ $cacheParams = array_merge($request->only(['query', 'type', 'country']), [
201
+ 'bounds' => $bounds,
202
+ 'limit' => $limit,
203
+ ]);
222
204
 
223
- return LiveCacheService::remember('places', $cacheParams, function () use ($request) {
205
+ return LiveCacheService::remember('places', $cacheParams, function () use ($request, $bounds, $limit) {
224
206
  // Query places based on filters
225
207
  $query = Place::where(['company_uuid' => session('company')])
226
208
  ->filter(new PlaceFilter($request))
227
209
  ->applyDirectivesForPermissions('fleet-ops list place');
228
210
 
229
- // Filter out places with invalid coordinates
230
- $query->whereNotNull('location')
231
- ->whereRaw('
232
- ST_Y(location) BETWEEN -90 AND 90
233
- AND ST_X(location) BETWEEN -180 AND 180
234
- AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
235
- ');
236
-
237
- // Apply spatial filtering if bounds are provided
238
- $bounds = $request->input('bounds');
239
- if ($bounds && is_array($bounds) && count($bounds) === 4) {
240
- [$south, $west, $north, $east] = $bounds;
241
-
242
- // Use MySQL spatial functions for POINT column
243
- // ST_Within checks if location is within the bounding box
244
- $query->whereRaw(
245
- 'ST_Within(location, ST_MakeEnvelope(POINT(?, ?), POINT(?, ?)))',
246
- [$west, $south, $east, $north]
247
- );
248
- }
211
+ $this->applyLiveLocationGuards($query);
212
+ $this->applyLiveViewportBounds($query, $bounds);
213
+
214
+ $query->orderByDesc('updated_at')->orderByDesc('id')->limit($limit);
249
215
 
250
216
  $places = $query->get();
251
217
 
252
218
  return PlaceIndexResource::collection($places);
253
219
  });
254
220
  }
221
+
222
+ protected function normalizeLiveBounds(Request $request): ?array
223
+ {
224
+ $bounds = $request->input('bounds');
225
+
226
+ if (!is_array($bounds) || count($bounds) !== 4) {
227
+ return null;
228
+ }
229
+
230
+ $bounds = array_values($bounds);
231
+
232
+ if (array_filter($bounds, fn ($value) => !is_numeric($value))) {
233
+ return null;
234
+ }
235
+
236
+ [$south, $west, $north, $east] = array_map('floatval', $bounds);
237
+
238
+ if ($south < -90 || $south > 90 || $north < -90 || $north > 90) {
239
+ return null;
240
+ }
241
+
242
+ if ($west < -180 || $west > 180 || $east < -180 || $east > 180) {
243
+ return null;
244
+ }
245
+
246
+ if ($south > $north || $west > $east) {
247
+ return null;
248
+ }
249
+
250
+ return array_map(
251
+ fn ($value) => round($value, static::VIEWPORT_BOUNDS_PRECISION),
252
+ [$south, $west, $north, $east]
253
+ );
254
+ }
255
+
256
+ protected function normalizeLiveLimit(Request $request): int
257
+ {
258
+ $limit = (int) $request->input('limit', static::DEFAULT_VIEWPORT_LIMIT);
259
+
260
+ if ($limit < 1) {
261
+ return static::DEFAULT_VIEWPORT_LIMIT;
262
+ }
263
+
264
+ return min($limit, static::MAX_VIEWPORT_LIMIT);
265
+ }
266
+
267
+ protected function applyLiveLocationGuards($query): void
268
+ {
269
+ $query->whereNotNull('location')
270
+ ->whereRaw('
271
+ ST_Y(location) BETWEEN -90 AND 90
272
+ AND ST_X(location) BETWEEN -180 AND 180
273
+ AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
274
+ ');
275
+ }
276
+
277
+ protected function applyLiveViewportBounds($query, ?array $bounds): void
278
+ {
279
+ if (!$bounds) {
280
+ return;
281
+ }
282
+
283
+ [$south, $west, $north, $east] = $bounds;
284
+
285
+ $query->whereRaw(
286
+ 'ST_Y(location) BETWEEN ? AND ? AND ST_X(location) BETWEEN ? AND ?',
287
+ [$south, $north, $west, $east]
288
+ );
289
+ }
255
290
  }