@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
@@ -3,9 +3,13 @@
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\Exceptions\FleetbaseRequestValidationException;
6
+ use Fleetbase\FleetOps\Events\EntityActivityChanged;
7
+ use Fleetbase\FleetOps\Events\EntityCompleted;
6
8
  use Fleetbase\FleetOps\Events\OrderDispatchFailed;
7
9
  use Fleetbase\FleetOps\Events\OrderReady;
8
10
  use Fleetbase\FleetOps\Events\OrderStarted;
11
+ use Fleetbase\FleetOps\Events\WaypointActivityChanged;
12
+ use Fleetbase\FleetOps\Events\WaypointCompleted;
9
13
  use Fleetbase\FleetOps\Exports\OrderExport;
10
14
  use Fleetbase\FleetOps\Flow\Activity;
11
15
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
@@ -27,6 +31,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote;
27
31
  use Fleetbase\FleetOps\Models\TrackingStatus;
28
32
  use Fleetbase\FleetOps\Models\Waypoint;
29
33
  use Fleetbase\FleetOps\Notifications\OrderPing;
34
+ use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops;
30
35
  use Fleetbase\FleetOps\Support\Utils;
31
36
  use Fleetbase\Http\Requests\ExportRequest;
32
37
  use Fleetbase\Http\Requests\Internal\BulkActionRequest;
@@ -37,13 +42,18 @@ use Fleetbase\Support\TemplateString;
37
42
  use Illuminate\Database\Eloquent\ModelNotFoundException;
38
43
  use Illuminate\Database\QueryException;
39
44
  use Illuminate\Http\Request;
45
+ use Illuminate\Http\UploadedFile;
40
46
  use Illuminate\Support\Facades\DB;
47
+ use Illuminate\Support\Facades\Storage;
41
48
  use Illuminate\Support\Facades\Validator;
42
49
  use Illuminate\Support\Str;
50
+ use Illuminate\Validation\ValidationException;
43
51
  use Maatwebsite\Excel\Facades\Excel;
44
52
 
45
53
  class OrderController extends FleetOpsController
46
54
  {
55
+ use ResolvesOrderServiceStops;
56
+
47
57
  /**
48
58
  * The resource to query.
49
59
  *
@@ -63,6 +73,11 @@ class OrderController extends FleetOpsController
63
73
  */
64
74
  public function onAfterUpdate($request, $order)
65
75
  {
76
+ $uploads = $request->array('order.files');
77
+ if ($uploads) {
78
+ $order->attachFiles($uploads);
79
+ }
80
+
66
81
  $waypoints = $request->array('order.payload.waypoints');
67
82
  if ($waypoints) {
68
83
  $order->loadMissing('payload');
@@ -165,14 +180,7 @@ class OrderController extends FleetOpsController
165
180
  ->insertEntities($entities);
166
181
 
167
182
  // If order creation includes files assosciate each to this order
168
- if ($uploads) {
169
- $ids = collect($uploads)->pluck('uuid');
170
- $files = File::whereIn('uuid', $ids)->get();
171
-
172
- foreach ($files as $file) {
173
- $file->setKey($order);
174
- }
175
- }
183
+ $order->attachFiles($uploads);
176
184
 
177
185
  // save custom field values
178
186
  if (is_array($customFieldValues)) {
@@ -706,11 +714,22 @@ class OrderController extends FleetOpsController
706
714
  */
707
715
  public function updateActivity(string $id, Request $request)
708
716
  {
709
- $order = Order::findById($id, ['driverAssigned', 'payload.entities']);
717
+ $order = Order::findById($id, [
718
+ 'driverAssigned',
719
+ 'payload.entities',
720
+ 'payload.pickup',
721
+ 'payload.dropoff',
722
+ 'payload.return',
723
+ 'payload.currentWaypoint',
724
+ 'payload.waypoints',
725
+ 'payload.waypointMarkers.place',
726
+ 'payload.waypointMarkers.trackingNumber.status',
727
+ ]);
710
728
  if (!$order) {
711
729
  return response()->error('No order found.');
712
730
  }
713
731
 
732
+ $proof = $request->input('proof');
714
733
  $activity = $request->array('activity');
715
734
  $activity = new Activity($activity, $order->getConfigFlow());
716
735
 
@@ -731,29 +750,77 @@ class OrderController extends FleetOpsController
731
750
  /**
732
751
  * @var \Fleetbase\LaravelMysqlSpatial\Types\Point
733
752
  */
734
- $location = $order->getLastLocation();
735
- $order->setStatus($activity->code);
736
- $order->insertActivity($activity, $location);
753
+ $location = $order->getLastLocation();
754
+ $isMultipleWaypointOrder = $this->payloadHasWaypoints($order->payload);
755
+
756
+ if (!$isMultipleWaypointOrder) {
757
+ $order->updateActivity($activity, $proof);
737
758
 
738
- // also update for each order entities if not multiple drop order
739
- // all entities will share the same activity status as is one drop order
740
- if (!$order->payload->isMultipleDropOrder) {
759
+ // also update for each order entities if not multiple drop order
760
+ // all entities will share the same activity status as is one drop order
741
761
  foreach ($order->payload->entities as $entity) {
742
- $entity->insertActivity($activity, $location);
762
+ $entity->insertActivity($activity, $location, $proof);
763
+ }
764
+
765
+ // Handle order completed
766
+ if (Utils::isActivity($activity) && $activity->completesOrder() && $order->driverAssigned) {
767
+ // unset from driver current job
768
+ $order->driverAssigned->unassignCurrentOrder();
769
+ $order->complete($this->resolveProof($proof));
743
770
  }
771
+
772
+ return new OrderResource($order->refresh());
773
+ }
774
+
775
+ if ($activity->is('dispatched')) {
776
+ $order->updateActivity($activity, $proof);
777
+
778
+ return new OrderResource($order->refresh());
779
+ }
780
+
781
+ if ($activity->is('started')) {
782
+ $order->updateActivity($activity, $proof);
783
+
784
+ $this->ensurePayloadCurrentServiceStop($order->payload);
785
+ if (!$this->payloadHasCurrentServiceStopActivity($order->payload, $activity)) {
786
+ $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
787
+ }
788
+
789
+ return new OrderResource($order->refresh());
790
+ }
791
+
792
+ if (!$order->started && in_array($order->status, ['created', 'dispatched'])) {
793
+ return response()->error('Order must be started before waypoint activity can be updated.', 422);
744
794
  }
745
795
 
746
- // Handle order completed
747
- if (Utils::isActivity($activity) && $activity->completesOrder() && $order->driverAssigned) {
748
- // unset from driver current job
749
- $order->driverAssigned->unassignCurrentOrder();
750
- $order->complete();
796
+ $currentStop = $this->ensurePayloadCurrentServiceStop($order->payload);
797
+ $isCompletingCurrentStop = Utils::isActivity($activity) && $activity->complete();
798
+
799
+ $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
800
+
801
+ if (Utils::isActivity($activity) && $activity->is('canceled')) {
802
+ if ($order->driverAssigned) {
803
+ $order->driverAssigned->unassignCurrentOrder();
804
+ }
805
+
806
+ $order->cancel();
807
+
808
+ return new OrderResource($order->refresh());
751
809
  }
752
810
 
753
- // Fire activity events
754
- $activity->fireEvents($order);
811
+ if (Utils::isActivity($activity) && $activity->completesOrder()) {
812
+ $nextStop = $isCompletingCurrentStop ? $this->advanceCurrentServiceStopDestination($order, $order->payload) : null;
813
+
814
+ if (!$nextStop || !$currentStop) {
815
+ if ($order->driverAssigned) {
816
+ $order->driverAssigned->unassignCurrentOrder();
817
+ }
818
+
819
+ $order->complete($this->resolveProof($proof));
820
+ }
821
+ }
755
822
 
756
- return response()->json(['status' => 'ok']);
823
+ return new OrderResource($order->refresh());
757
824
  }
758
825
 
759
826
  /**
@@ -769,12 +836,24 @@ class OrderController extends FleetOpsController
769
836
  return response()->error('No order found.');
770
837
  }
771
838
 
772
- $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
839
+ $canUpdateWaypointActivity = $order->started || $order->started_at || !in_array($order->status, ['created', 'dispatched']);
840
+ $stop = null;
841
+ if ($request->filled('waypoint') && $canUpdateWaypointActivity) {
842
+ $order->loadMissing([
843
+ 'payload.pickup',
844
+ 'payload.dropoff',
845
+ 'payload.return',
846
+ 'payload.waypoints',
847
+ 'payload.waypointMarkers.place',
848
+ 'payload.waypointMarkers.trackingNumber.status',
849
+ ]);
850
+ $stop = $this->resolveServiceStopFromKey($order->payload, $request->input('waypoint'));
851
+ }
773
852
  $orderConfig = $order->ensureOrderConfig();
774
853
  if (!$orderConfig) {
775
854
  return response()->error('No order config found for order.');
776
855
  }
777
- $activities = $orderConfig->nextActivity($waypoint);
856
+ $activities = $stop ? $this->nextActivitiesForServiceStop($order, $order->payload, $stop) : $orderConfig->nextActivity();
778
857
 
779
858
  // If activity is to complete order add proof of delivery properties if required
780
859
  // This is a temporary fix until activity is updated to handle POD on it's own
@@ -794,6 +873,330 @@ class OrderController extends FleetOpsController
794
873
  return response()->json($activities);
795
874
  }
796
875
 
876
+ /**
877
+ * Mark a waypoint as the current destination for a multi-waypoint order.
878
+ *
879
+ * @return \Fleetbase\Http\Resources\v1\Order
880
+ */
881
+ public function setDestination(string $id, string $placeId)
882
+ {
883
+ $order = Order::findById($id, [
884
+ 'payload.pickup',
885
+ 'payload.dropoff',
886
+ 'payload.return',
887
+ 'payload.waypoints',
888
+ 'payload.waypointMarkers.place',
889
+ 'payload.waypointMarkers.trackingNumber.status',
890
+ 'driverAssigned',
891
+ 'vehicleAssigned',
892
+ 'customer',
893
+ 'facilitator',
894
+ ]);
895
+ if (!$order) {
896
+ return response()->error('No order found.');
897
+ }
898
+
899
+ if (!$this->payloadHasWaypoints($order->payload)) {
900
+ return response()->error('Destination can only be changed for multi-waypoint orders.', 422);
901
+ }
902
+
903
+ $stop = $this->resolveServiceStopFromKey($order->payload, $placeId);
904
+ if (!$stop) {
905
+ return response()->error('Place resource is not a valid route destination.', 422);
906
+ }
907
+
908
+ $this->setPayloadCurrentServiceStop($order->payload, $stop);
909
+
910
+ return new OrderResource($order->refresh());
911
+ }
912
+
913
+ /**
914
+ * Capture one or more photos for order/waypoint proof of delivery.
915
+ *
916
+ * @return ProofResource|\Illuminate\Http\Response
917
+ */
918
+ public function capturePhoto(Request $request, string $id, ?string $subjectId = null)
919
+ {
920
+ try {
921
+ $request->validate([
922
+ 'photos' => 'required|array|min:1',
923
+ 'photos.*' => [
924
+ function ($attribute, $value, $fail) {
925
+ if ($value instanceof UploadedFile) {
926
+ if (
927
+ !$value->isValid()
928
+ || !in_array($value->extension(), ['jpg', 'jpeg', 'png', 'gif', 'webp'])
929
+ || $value->getSize() > 10 * 1024 * 1024
930
+ ) {
931
+ $fail("{$attribute} must be a valid image file <= 10 MB.");
932
+ }
933
+
934
+ return;
935
+ }
936
+
937
+ if (is_string($value) && base64_decode($value, true) !== false) {
938
+ return;
939
+ }
940
+
941
+ $fail("{$attribute} must be an image file or a valid Base64 string.");
942
+ },
943
+ ],
944
+ 'remarks' => 'sometimes|string|max:255',
945
+ 'data' => 'sometimes|array',
946
+ ]);
947
+ } catch (ValidationException $e) {
948
+ $errorMessage = collect($e->errors())->flatten()->first();
949
+
950
+ return response()->error($errorMessage, 422);
951
+ }
952
+
953
+ $order = Order::findById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
954
+ if (!$order) {
955
+ return response()->error('No order found.');
956
+ }
957
+
958
+ $subject = $this->resolveProofSubject($order, $subjectId);
959
+ if (!$subject) {
960
+ return response()->error('Unable to capture photo as proof.', 422);
961
+ }
962
+
963
+ $disk = $request->input('disk', config('filesystems.default'));
964
+ $bucket = $request->input("filesystems.disks.{$disk}.bucket", config('filesystems.disks.s3.bucket'));
965
+ $rawInputs = $request->file('photos', []);
966
+ $base64Inputs = array_filter($request->input('photos', []), function ($value) {
967
+ return is_string($value) && base64_decode($value, true) !== false;
968
+ });
969
+ $incoming = array_merge($rawInputs, $base64Inputs);
970
+ $remarks = $request->input('remarks', 'Verified by Photo');
971
+ $data = $request->input('data', []);
972
+
973
+ if (empty($incoming)) {
974
+ return response()->error('No photo data to capture.', 422);
975
+ }
976
+
977
+ foreach ($incoming as $item) {
978
+ $proof = Proof::create([
979
+ 'company_uuid' => session('company'),
980
+ 'order_uuid' => $order->uuid,
981
+ 'subject_uuid' => $subject->uuid,
982
+ 'subject_type' => Utils::getModelClassName($subject),
983
+ 'remarks' => $remarks,
984
+ 'raw_data' => $item instanceof UploadedFile ? null : $item,
985
+ 'data' => $data,
986
+ ]);
987
+
988
+ $file = $this->storeProofPhoto($proof, $item, $disk, $bucket);
989
+ $proof->update(['file_uuid' => $file->uuid]);
990
+ }
991
+
992
+ return new ProofResource($proof);
993
+ }
994
+
995
+ /**
996
+ * Store a proof photo and create its file record.
997
+ */
998
+ protected function storeProofPhoto(Proof $proof, UploadedFile|string $photo, string $disk, string $bucket): File
999
+ {
1000
+ $isFile = $photo instanceof UploadedFile;
1001
+ $contents = $isFile ? file_get_contents($photo->getRealPath()) : base64_decode($photo);
1002
+ $extension = $isFile ? $photo->getClientOriginalExtension() : 'png';
1003
+ $contentType = $isFile ? $photo->getClientMimeType() : 'image/png';
1004
+ $company = session('company');
1005
+ $path = "uploads/{$company}/photos/{$proof->public_id}.{$extension}";
1006
+
1007
+ Storage::disk($disk)->put($path, $contents);
1008
+
1009
+ return File::create([
1010
+ 'company_uuid' => $company,
1011
+ 'uploader_uuid' => session('user'),
1012
+ 'name' => basename($path),
1013
+ 'original_filename' => basename($path),
1014
+ 'extension' => $extension,
1015
+ 'content_type' => $contentType,
1016
+ 'path' => $path,
1017
+ 'bucket' => $bucket,
1018
+ 'type' => 'photo',
1019
+ 'size' => strlen($contents),
1020
+ ])->setKey($proof);
1021
+ }
1022
+
1023
+ /**
1024
+ * Resolve the subject that proof belongs to.
1025
+ */
1026
+ protected function resolveProofSubject(Order $order, ?string $subjectId = null): Order|Waypoint|Place|null
1027
+ {
1028
+ if (!$subjectId) {
1029
+ return $order;
1030
+ }
1031
+
1032
+ $stop = $this->resolveServiceStopFromKey($order->payload, $subjectId);
1033
+ if ($stop) {
1034
+ return ($stop['waypoint'] ?? null) instanceof Waypoint ? $stop['waypoint'] : ($stop['place'] ?? $order);
1035
+ }
1036
+
1037
+ return Waypoint::withoutGlobalScopes()
1038
+ ->where('payload_uuid', $order->payload_uuid)
1039
+ ->where(function ($query) use ($subjectId) {
1040
+ $query
1041
+ ->where('public_id', $subjectId)
1042
+ ->orWhere('uuid', $subjectId)
1043
+ ->orWhereHas('place', function ($query) use ($subjectId) {
1044
+ $query->where('public_id', $subjectId)->orWhere('uuid', $subjectId);
1045
+ });
1046
+ })
1047
+ ->first();
1048
+ }
1049
+
1050
+ /**
1051
+ * Resolve proof to the model expected by order completion.
1052
+ */
1053
+ protected function resolveProof($proof): ?Proof
1054
+ {
1055
+ if ($proof instanceof Proof) {
1056
+ return $proof;
1057
+ }
1058
+
1059
+ if (is_string($proof)) {
1060
+ return Proof::where('public_id', $proof)->orWhere('uuid', $proof)->first();
1061
+ }
1062
+
1063
+ return null;
1064
+ }
1065
+
1066
+ /**
1067
+ * Determine whether the payload should use waypoint-scoped activity.
1068
+ */
1069
+ protected function payloadHasWaypoints(?Payload $payload): bool
1070
+ {
1071
+ if (!$payload) {
1072
+ return false;
1073
+ }
1074
+
1075
+ $payload->loadMissing('waypoints');
1076
+
1077
+ return $payload->waypoints->isNotEmpty();
1078
+ }
1079
+
1080
+ /**
1081
+ * Insert an activity on the payload's current waypoint and its destination entities.
1082
+ */
1083
+ protected function updateCurrentWaypointActivity(?Payload $payload, Activity $activity, $location = null, $proof = null): ?Waypoint
1084
+ {
1085
+ if (!$payload || !Utils::isActivity($activity) || !$location) {
1086
+ return null;
1087
+ }
1088
+
1089
+ $payload->loadMissing(['order', 'waypointMarkers', 'entities']);
1090
+ $currentWaypoint = $payload->waypointMarkers->firstWhere('place_uuid', $payload->current_waypoint_uuid);
1091
+ if (!$currentWaypoint) {
1092
+ return null;
1093
+ }
1094
+
1095
+ $currentWaypoint->insertActivity($activity, $location, $proof);
1096
+ $activity->fireEvents($payload->order, $currentWaypoint);
1097
+
1098
+ $entities = $payload->entities->where('destination_uuid', $payload->current_waypoint_uuid);
1099
+ foreach ($entities as $entity) {
1100
+ $entity->insertActivity($activity, $location, $proof);
1101
+ if ($activity->complete()) {
1102
+ event(new EntityCompleted($entity, $activity));
1103
+ } else {
1104
+ event(new EntityActivityChanged($entity, $activity));
1105
+ }
1106
+ }
1107
+
1108
+ if ($activity->complete()) {
1109
+ event(new WaypointCompleted($currentWaypoint, $activity));
1110
+ } else {
1111
+ event(new WaypointActivityChanged($currentWaypoint, $activity));
1112
+ }
1113
+
1114
+ return $currentWaypoint;
1115
+ }
1116
+
1117
+ /**
1118
+ * Reload waypoint marker status and decide whether every stop is complete.
1119
+ */
1120
+ protected function allWaypointMarkersComplete(?Payload $payload): bool
1121
+ {
1122
+ if (!$payload) {
1123
+ return false;
1124
+ }
1125
+
1126
+ $waypointMarkers = $this->freshWaypointMarkers($payload);
1127
+
1128
+ return $waypointMarkers->isNotEmpty() && $waypointMarkers->every(fn (Waypoint $waypoint) => $this->waypointMarkerIsComplete($waypoint));
1129
+ }
1130
+
1131
+ /**
1132
+ * Move the current destination to the next incomplete waypoint.
1133
+ */
1134
+ protected function advanceCurrentWaypointDestination(?Payload $payload): ?Waypoint
1135
+ {
1136
+ if (!$payload) {
1137
+ return null;
1138
+ }
1139
+
1140
+ $nextWaypoint = $this->freshWaypointMarkers($payload)
1141
+ ->sortBy('order')
1142
+ ->first(function (Waypoint $waypoint) use ($payload) {
1143
+ return !$this->waypointMarkerIsComplete($waypoint) && $waypoint->place_uuid !== $payload->current_waypoint_uuid;
1144
+ });
1145
+
1146
+ if (!$nextWaypoint) {
1147
+ return null;
1148
+ }
1149
+
1150
+ $payload->setCurrentWaypoint($nextWaypoint);
1151
+ $payload->setRelation('currentWaypoint', $nextWaypoint->getPlace());
1152
+ $payload->setRelation('currentWaypointMarker', $nextWaypoint);
1153
+
1154
+ return $nextWaypoint;
1155
+ }
1156
+
1157
+ /**
1158
+ * Reload waypoint markers with current tracking status.
1159
+ */
1160
+ protected function freshWaypointMarkers(Payload $payload)
1161
+ {
1162
+ $payload->unsetRelation('waypointMarkers');
1163
+ $payload->load(['waypointMarkers.trackingNumber.status']);
1164
+
1165
+ return $payload->waypointMarkers;
1166
+ }
1167
+
1168
+ /**
1169
+ * Determine whether a waypoint marker's latest status is complete.
1170
+ */
1171
+ protected function waypointMarkerIsComplete(Waypoint $waypoint): bool
1172
+ {
1173
+ if (!$waypoint->tracking_number_uuid) {
1174
+ return false;
1175
+ }
1176
+
1177
+ return (bool) data_get($this->trackingNumberStatus($waypoint->tracking_number_uuid), 'complete', false);
1178
+ }
1179
+
1180
+ /**
1181
+ * Determine whether the payload's current waypoint already has an activity row.
1182
+ */
1183
+ protected function payloadHasCurrentWaypointActivity(?Payload $payload, Activity $activity): bool
1184
+ {
1185
+ if (!$payload || !$payload->current_waypoint_uuid) {
1186
+ return false;
1187
+ }
1188
+
1189
+ $payload->loadMissing('waypointMarkers');
1190
+ $currentWaypoint = $payload->waypointMarkers->firstWhere('place_uuid', $payload->current_waypoint_uuid);
1191
+ if (!$currentWaypoint || !$currentWaypoint->tracking_number_uuid) {
1192
+ return false;
1193
+ }
1194
+
1195
+ return TrackingStatus::where('tracking_number_uuid', $currentWaypoint->tracking_number_uuid)
1196
+ ->where('code', TrackingStatus::prepareCode($activity->code))
1197
+ ->exists();
1198
+ }
1199
+
797
1200
  /**
798
1201
  * Finds and responds with the orders next activity update based on the orders configuration.
799
1202
  *
@@ -188,7 +188,7 @@ class ServiceQuoteController extends FleetOpsController
188
188
  $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all`
189
189
  $serviceType = $request->input('service_type'); // the specific type of service rate to query
190
190
  $isCashOnDelivery = $request->has('cod');
191
- $currency = $request->has('currency');
191
+ $currency = $request->input('currency');
192
192
  $totalDistance = $request->input('distance');
193
193
  $totalTime = $request->input('time');
194
194
  $pickup = $request->or(['payload.pickup', 'payload.pickup_uuid', 'pickup']);
@@ -523,7 +523,7 @@ class SettingController extends Controller
523
523
  $config = config('fleetops.tracking', []);
524
524
  $systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
525
525
 
526
- $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []);
526
+ $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []);
527
527
  $defaults['alerts'] = $this->normalizeTrackingAlertSettings(data_get($defaults, 'alerts', []));
528
528
 
529
529
  return $defaults;
@@ -541,7 +541,7 @@ class SettingController extends Controller
541
541
  'distance_threshold_meters' => 500,
542
542
  ],
543
543
  'prolonged_stoppages' => [
544
- 'enabled' => true,
544
+ 'enabled' => true,
545
545
  'duration_threshold_minutes' => 30,
546
546
  ],
547
547
  ];
@@ -5,10 +5,12 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
5
5
  use Fleetbase\FleetOps\Exports\VehicleExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
7
  use Fleetbase\FleetOps\Imports\VehicleImport;
8
+ use Fleetbase\FleetOps\Models\Driver;
8
9
  use Fleetbase\FleetOps\Models\Vehicle;
9
10
  use Fleetbase\Http\Requests\ExportRequest;
10
11
  use Fleetbase\Http\Requests\ImportRequest;
11
12
  use Illuminate\Http\Request;
13
+ use Illuminate\Support\Arr;
12
14
  use Illuminate\Support\Facades\DB;
13
15
  use Illuminate\Support\Str;
14
16
  use Maatwebsite\Excel\Facades\Excel;
@@ -25,14 +27,65 @@ class VehicleController extends FleetOpsController
25
27
  /**
26
28
  * Handle post save transactions.
27
29
  */
28
- public function afterSave(Request $request, Vehicle $vehicle)
30
+ public function afterSave(Request $request, Vehicle $vehicle, array $input = [])
29
31
  {
32
+ if ($this->hasDriverInput($request)) {
33
+ $this->syncDriverAssignment($vehicle, $this->driverIdentifierFromRequest($request));
34
+ }
35
+
30
36
  $customFieldValues = $request->array('vehicle.custom_field_values');
31
37
  if ($customFieldValues) {
32
38
  $vehicle->syncCustomFieldValues($customFieldValues);
33
39
  }
34
40
  }
35
41
 
42
+ protected function hasDriverInput(Request $request): bool
43
+ {
44
+ $payload = $request->all();
45
+
46
+ return Arr::has($payload, 'vehicle.driver_uuid')
47
+ || Arr::has($payload, 'vehicle.driver.uuid')
48
+ || Arr::has($payload, 'vehicle.driver.id')
49
+ || Arr::has($payload, 'driver_uuid')
50
+ || Arr::has($payload, 'driver');
51
+ }
52
+
53
+ protected function driverIdentifierFromRequest(Request $request): ?string
54
+ {
55
+ $payload = $request->all();
56
+ $driver = data_get($payload, 'vehicle.driver') ?? data_get($payload, 'driver');
57
+
58
+ return data_get($payload, 'vehicle.driver_uuid')
59
+ ?? data_get($payload, 'vehicle.driver.uuid')
60
+ ?? data_get($payload, 'vehicle.driver.id')
61
+ ?? (is_string($driver) ? $driver : null)
62
+ ?? data_get($payload, 'driver_uuid')
63
+ ?? data_get($payload, 'driver.uuid')
64
+ ?? data_get($payload, 'driver.id');
65
+ }
66
+
67
+ protected function syncDriverAssignment(Vehicle $vehicle, ?string $identifier): void
68
+ {
69
+ if (empty($identifier)) {
70
+ $vehicle->unassignDriver();
71
+
72
+ return;
73
+ }
74
+
75
+ $driver = Driver::withoutGlobalScopes()
76
+ ->whereNull('deleted_at')
77
+ ->where(function ($query) use ($identifier) {
78
+ $query->where('uuid', $identifier)->orWhere('public_id', $identifier);
79
+ })
80
+ ->when(session('company'), fn ($query, $company) => $query->where('company_uuid', $company))
81
+ ->first();
82
+
83
+ if ($driver) {
84
+ $vehicle->assignDriver($driver);
85
+ $vehicle->setRelation('driver', $driver);
86
+ }
87
+ }
88
+
36
89
  /**
37
90
  * Get all status options for an vehicle.
38
91
  *
@@ -0,0 +1,34 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Middleware;
4
+
5
+ use Fleetbase\FleetOps\Support\CustomerAuth;
6
+ use Illuminate\Http\Request;
7
+
8
+ /**
9
+ * Enforce that a Customer-Token header is present and resolves to a
10
+ * customer-type Contact that belongs to the same company as the request's
11
+ * API credential.
12
+ *
13
+ * Apply this middleware to authenticated `/v1/customers/...` routes. The
14
+ * public sign-up / login routes do NOT use it.
15
+ */
16
+ class AuthenticateCustomerToken
17
+ {
18
+ public function handle(Request $request, \Closure $next)
19
+ {
20
+ $customer = CustomerAuth::resolveFromHeader($request);
21
+ if (!$customer) {
22
+ return response()->apiError('Customer token is missing or invalid.', 401);
23
+ }
24
+
25
+ $sessionCompany = session('company');
26
+ if ($sessionCompany && $customer->company_uuid !== $sessionCompany) {
27
+ return response()->apiError('Customer does not belong to this company.', 403);
28
+ }
29
+
30
+ CustomerAuth::setCurrent($customer);
31
+
32
+ return $next($request);
33
+ }
34
+ }