@fleetbase/fleetops-engine 0.6.43 → 0.6.45

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 (82) hide show
  1. package/addon/components/entity/form.hbs +24 -0
  2. package/addon/components/entity/form.js +30 -0
  3. package/addon/components/leaflet-draw-control.js +1 -1
  4. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  5. package/addon/components/map/drawer/geofence-event-listing.js +103 -0
  6. package/addon/components/map/drawer.js +1 -0
  7. package/addon/components/map/leaflet-live-map.js +83 -0
  8. package/addon/components/modals/orchestrator-import.js +2 -78
  9. package/addon/components/order/form/service-rate.hbs +1 -1
  10. package/addon/components/service-area/form.hbs +59 -0
  11. package/addon/components/service-rate/details.hbs +6 -6
  12. package/addon/components/service-rate/form.hbs +28 -15
  13. package/addon/components/zone/form.hbs +60 -1
  14. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  15. package/addon/controllers/operations/orders/index/details.js +27 -2
  16. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  17. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  18. package/addon/helpers/f-to-int.js +6 -0
  19. package/addon/routes/application.js +3 -3
  20. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  21. package/addon/routes/operations/orders/index/details.js +19 -3
  22. package/addon/services/equipment-actions.js +4 -6
  23. package/addon/services/geofence-event-bus.js +223 -0
  24. package/addon/services/leaflet-map-manager.js +4 -0
  25. package/addon/services/maintenance-actions.js +4 -6
  26. package/addon/services/part-actions.js +4 -6
  27. package/addon/services/service-rate-actions.js +8 -3
  28. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  29. package/addon/utils/fleet-ops-options.js +33 -0
  30. package/addon/utils/serialize-payload.js +1 -1
  31. package/app/components/map/drawer/geofence-event-listing.js +1 -0
  32. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  33. package/app/helpers/f-to-int.js +1 -0
  34. package/app/services/geofence-event-bus.js +1 -0
  35. package/composer.json +1 -1
  36. package/extension.json +1 -1
  37. package/package.json +2 -2
  38. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  39. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  40. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  41. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  42. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  43. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  44. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  45. package/server/src/Events/GeofenceDwelled.php +208 -0
  46. package/server/src/Events/GeofenceEntered.php +207 -0
  47. package/server/src/Events/GeofenceExited.php +213 -0
  48. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  49. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  50. package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
  51. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  52. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  53. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
  54. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  55. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  56. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  57. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  58. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  59. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  60. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  61. package/server/src/Http/Resources/v1/Order.php +0 -1
  62. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  63. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  64. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  65. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  66. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  67. package/server/src/Models/Entity.php +7 -0
  68. package/server/src/Models/GeofenceEventLog.php +155 -0
  69. package/server/src/Models/Order.php +39 -7
  70. package/server/src/Models/ServiceArea.php +21 -2
  71. package/server/src/Models/ServiceQuote.php +3 -1
  72. package/server/src/Models/ServiceRate.php +122 -30
  73. package/server/src/Models/Zone.php +20 -2
  74. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  75. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  76. package/server/src/Providers/EventServiceProvider.php +19 -0
  77. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  78. package/server/src/Rules/ComputableAlgo.php +1 -3
  79. package/server/src/Support/Algo.php +87 -1
  80. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  81. package/server/src/Support/Utils.php +35 -0
  82. package/server/src/routes.php +16 -0
@@ -369,11 +369,37 @@ class ServiceRate extends Model
369
369
  return $this;
370
370
  }
371
371
 
372
+ // Normalize duplicate parcel rows by fee shape and keep the latest submitted value.
373
+ $serviceRateParcelFees = collect($serviceRateParcelFees)
374
+ ->filter(fn ($fee) => is_array($fee))
375
+ ->map(function ($fee) {
376
+ return collect($fee)->except(['created_at', 'updated_at'])->toArray();
377
+ })
378
+ ->keyBy(function ($fee) {
379
+ return implode(':', [
380
+ data_get($fee, 'size'),
381
+ data_get($fee, 'length'),
382
+ data_get($fee, 'width'),
383
+ data_get($fee, 'height'),
384
+ data_get($fee, 'dimensions_unit'),
385
+ data_get($fee, 'weight'),
386
+ data_get($fee, 'weight_unit'),
387
+ ]);
388
+ })
389
+ ->values()
390
+ ->toArray();
391
+
392
+ $submittedUuids = collect($serviceRateParcelFees)
393
+ ->pluck('uuid')
394
+ ->filter()
395
+ ->values()
396
+ ->all();
397
+
372
398
  $iterate = count($serviceRateParcelFees);
373
399
 
374
400
  for ($i = 0; $i < $iterate; $i++) {
375
401
  // if already has uuid then we just update the record and remove from insert array
376
- if (isset($serviceRateParcelFees[$i]['uuid'])) {
402
+ if (!empty($serviceRateParcelFees[$i]['uuid'])) {
377
403
  $id = $serviceRateParcelFees[$i]['uuid'];
378
404
  $updateableAttributes = collect($serviceRateParcelFees[$i])->except(['uuid', 'created_at', 'updated_at'])->toArray();
379
405
 
@@ -391,7 +417,18 @@ class ServiceRate extends Model
391
417
  }
392
418
 
393
419
  $serviceRateParcelFees = collect($serviceRateParcelFees)->filter()->values()->toArray();
394
- ServiceRateParcelFee::bulkInsert($serviceRateParcelFees);
420
+
421
+ $existingParcelFeesQuery = ServiceRateParcelFee::where('service_rate_uuid', $this->uuid);
422
+
423
+ if (!empty($submittedUuids)) {
424
+ $existingParcelFeesQuery->whereNotIn('uuid', $submittedUuids)->delete();
425
+ } else {
426
+ $existingParcelFeesQuery->delete();
427
+ }
428
+
429
+ if (!empty($serviceRateParcelFees)) {
430
+ ServiceRateParcelFee::bulkInsert($serviceRateParcelFees);
431
+ }
395
432
 
396
433
  return $this;
397
434
  }
@@ -688,13 +725,13 @@ class ServiceRate extends Model
688
725
  }
689
726
 
690
727
  if ($this->isPerMeter()) {
691
- $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance;
692
- $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee;
728
+ $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit);
729
+ $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee);
693
730
  $subTotal += $rateFee;
694
731
 
695
732
  $lines->push([
696
733
  'details' => 'Service Fee',
697
- 'amount' => Utils::numbersOnly($rateFee),
734
+ 'amount' => $rateFee,
698
735
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
699
736
  'currency' => $this->currency,
700
737
  'code' => 'BASE_FEE',
@@ -702,20 +739,23 @@ class ServiceRate extends Model
702
739
  }
703
740
 
704
741
  if ($this->isAlgorithm()) {
705
- $rateFee = Algo::exec(
742
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
706
743
  $this->algorithm,
707
- [
708
- 'distance' => $totalDistance,
709
- 'time' => $totalTime,
710
- ],
744
+ $this->buildAlgorithmVariables(
745
+ $entities,
746
+ $waypoints,
747
+ $totalDistance,
748
+ $totalTime,
749
+ $this->inferEndpointCountFromStops($waypoints)
750
+ ),
711
751
  true
712
- );
752
+ ));
713
753
 
714
- $subTotal += Utils::numbersOnly($rateFee);
754
+ $subTotal += $rateFee;
715
755
 
716
756
  $lines->push([
717
757
  'details' => 'Service Fee',
718
- 'amount' => Utils::numbersOnly($rateFee),
758
+ 'amount' => $rateFee,
719
759
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
720
760
  'currency' => $this->currency,
721
761
  'code' => 'BASE_FEE',
@@ -758,9 +798,10 @@ class ServiceRate extends Model
758
798
  }
759
799
 
760
800
  $subTotal += $serviceParcelFee->fee;
801
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
761
802
 
762
803
  $lines->push([
763
- 'details' => $serviceParcelFee->name . ' parcel fee',
804
+ 'details' => $parcelFeeName . ' parcel fee',
764
805
  'amount' => Utils::numbersOnly($serviceParcelFee->fee),
765
806
  'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
766
807
  'currency' => $this->currency,
@@ -777,7 +818,7 @@ class ServiceRate extends Model
777
818
  if ($this->hasCodFlatFee()) {
778
819
  $subTotal += $codFee = $this->cod_flat_fee;
779
820
  } elseif ($this->hasCodPercentageFee()) {
780
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
821
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
781
822
  }
782
823
 
783
824
  $lines->push([
@@ -794,7 +835,7 @@ class ServiceRate extends Model
794
835
  if ($this->hasPeakHoursFlatFee()) {
795
836
  $subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
796
837
  } elseif ($this->hasPeakHoursPercentageFee()) {
797
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
838
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
798
839
  }
799
840
 
800
841
  $lines->push([
@@ -872,13 +913,13 @@ class ServiceRate extends Model
872
913
  }
873
914
 
874
915
  if ($this->isPerMeter()) {
875
- $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance;
876
- $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee;
916
+ $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit);
917
+ $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee);
877
918
  $subTotal += $rateFee;
878
919
 
879
920
  $lines->push([
880
921
  'details' => 'Service Fee',
881
- 'amount' => Utils::numbersOnly($rateFee),
922
+ 'amount' => $rateFee,
882
923
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
883
924
  'currency' => $this->currency,
884
925
  'code' => 'BASE_FEE',
@@ -886,20 +927,23 @@ class ServiceRate extends Model
886
927
  }
887
928
 
888
929
  if ($this->isAlgorithm()) {
889
- $rateFee = Algo::exec(
930
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
890
931
  $this->algorithm,
891
- [
892
- 'distance' => $totalDistance,
893
- 'time' => $totalTime,
894
- ],
932
+ $this->buildAlgorithmVariables(
933
+ $payload->entities->all(),
934
+ $waypoints->all(),
935
+ $totalDistance,
936
+ $totalTime,
937
+ (int) ($payload->pickup ? 1 : 0) + (int) ($payload->dropoff ? 1 : 0)
938
+ ),
895
939
  true
896
- );
940
+ ));
897
941
 
898
- $subTotal += Utils::numbersOnly($rateFee);
942
+ $subTotal += $rateFee;
899
943
 
900
944
  $lines->push([
901
945
  'details' => 'Service Fee',
902
- 'amount' => Utils::numbersOnly($rateFee),
946
+ 'amount' => $rateFee,
903
947
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
904
948
  'currency' => $this->currency,
905
949
  'code' => 'BASE_FEE',
@@ -942,9 +986,10 @@ class ServiceRate extends Model
942
986
  }
943
987
 
944
988
  $subTotal += $serviceParcelFee->fee;
989
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
945
990
 
946
991
  $lines->push([
947
- 'details' => $serviceParcelFee->name . ' parcel fee',
992
+ 'details' => $parcelFeeName . ' parcel fee',
948
993
  'amount' => Utils::numbersOnly($serviceParcelFee->fee),
949
994
  'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
950
995
  'currency' => $this->currency,
@@ -961,7 +1006,7 @@ class ServiceRate extends Model
961
1006
  if ($this->hasCodFlatFee()) {
962
1007
  $subTotal += $codFee = $this->cod_flat_fee;
963
1008
  } elseif ($this->hasCodPercentageFee()) {
964
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
1009
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
965
1010
  }
966
1011
 
967
1012
  $lines->push([
@@ -978,7 +1023,7 @@ class ServiceRate extends Model
978
1023
  if ($this->hasPeakHoursFlatFee()) {
979
1024
  $subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
980
1025
  } elseif ($this->hasPeakHoursPercentageFee()) {
981
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
1026
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
982
1027
  }
983
1028
 
984
1029
  $lines->push([
@@ -993,6 +1038,53 @@ class ServiceRate extends Model
993
1038
  return [$subTotal, $lines];
994
1039
  }
995
1040
 
1041
+ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string $unit = 'm'): float|int
1042
+ {
1043
+ $distanceInMeters = (float) ($distanceInMeters ?? 0);
1044
+
1045
+ return match ($unit) {
1046
+ 'km' => $distanceInMeters / 1000,
1047
+ 'ft' => $distanceInMeters / 0.3048,
1048
+ 'yd' => $distanceInMeters / 0.9144,
1049
+ 'mi' => $distanceInMeters / 1609.344,
1050
+ default => $distanceInMeters,
1051
+ };
1052
+ }
1053
+
1054
+ protected function normalizeCalculatedMoney($amount = 0): int
1055
+ {
1056
+ return (int) round((float) ($amount ?? 0));
1057
+ }
1058
+
1059
+ protected function buildAlgorithmVariables($entities = [], $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array
1060
+ {
1061
+ $entityCollection = collect($entities)->filter();
1062
+ $stopCount = collect($stops)->filter()->count();
1063
+ $endpointCount = max(0, min($endpointCount, $stopCount));
1064
+ $waypointCount = max($stopCount - $endpointCount, 0);
1065
+
1066
+ return Algo::normalizeVariables([
1067
+ 'distance_m' => $totalDistance ?? 0,
1068
+ 'time_s' => $totalTime ?? 0,
1069
+ 'stops' => $stopCount,
1070
+ 'waypoints' => $waypointCount,
1071
+ 'parcels' => $entityCollection->where('type', 'parcel')->count(),
1072
+ 'entities' => $entityCollection->count(),
1073
+ 'base_fee' => Utils::numbersOnly($this->base_fee ?? 0),
1074
+ ]);
1075
+ }
1076
+
1077
+ protected function inferEndpointCountFromStops($stops = []): int
1078
+ {
1079
+ $stopCount = collect($stops)->filter()->count();
1080
+
1081
+ if ($stopCount <= 1) {
1082
+ return $stopCount;
1083
+ }
1084
+
1085
+ return 2;
1086
+ }
1087
+
996
1088
  /**
997
1089
  * Find the ServiceRateFee based on the total distance.
998
1090
  *
@@ -62,7 +62,21 @@ class Zone extends Model
62
62
  *
63
63
  * @var array
64
64
  */
65
- protected $fillable = ['_key', 'company_uuid', 'service_area_uuid', 'name', 'description', 'border', 'color', 'stroke_color', 'status'];
65
+ protected $fillable = [
66
+ '_key',
67
+ 'company_uuid',
68
+ 'service_area_uuid',
69
+ 'name',
70
+ 'description',
71
+ 'border',
72
+ 'color',
73
+ 'stroke_color',
74
+ 'status',
75
+ 'trigger_on_entry',
76
+ 'trigger_on_exit',
77
+ 'dwell_threshold_minutes',
78
+ 'speed_limit_kmh',
79
+ ];
66
80
 
67
81
  /**
68
82
  * The attributes that should be cast to native types.
@@ -70,7 +84,11 @@ class Zone extends Model
70
84
  * @var array
71
85
  */
72
86
  protected $casts = [
73
- 'border' => PolygonCast::class,
87
+ 'border' => PolygonCast::class,
88
+ 'trigger_on_entry' => 'boolean',
89
+ 'trigger_on_exit' => 'boolean',
90
+ 'dwell_threshold_minutes' => 'integer',
91
+ 'speed_limit_kmh' => 'integer',
74
92
  ];
75
93
 
76
94
  /**
@@ -0,0 +1,86 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Notifications;
4
+
5
+ use Fleetbase\FleetOps\Models\Order;
6
+ use Illuminate\Bus\Queueable;
7
+ use Illuminate\Notifications\Messages\MailMessage;
8
+ use Illuminate\Notifications\Notification;
9
+
10
+ /**
11
+ * DriverArrivedAtGeofence.
12
+ *
13
+ * Sent to the order customer when a driver enters the geofence associated
14
+ * with the order's current destination waypoint.
15
+ *
16
+ * Delivery channels: mail and database.
17
+ */
18
+ class DriverArrivedAtGeofence extends Notification
19
+ {
20
+ use Queueable;
21
+
22
+ /**
23
+ * The order associated with the arrival.
24
+ */
25
+ protected Order $order;
26
+
27
+ /**
28
+ * The geofence that was entered (Zone or ServiceArea).
29
+ */
30
+ protected $geofence;
31
+
32
+ /**
33
+ * Create a new DriverArrivedAtGeofence notification.
34
+ *
35
+ * @param mixed $geofence Zone or ServiceArea
36
+ */
37
+ public function __construct(Order $order, $geofence)
38
+ {
39
+ $this->order = $order;
40
+ $this->geofence = $geofence;
41
+ }
42
+
43
+ /**
44
+ * Get the notification's delivery channels.
45
+ */
46
+ public function via($notifiable): array
47
+ {
48
+ return ['mail', 'database'];
49
+ }
50
+
51
+ /**
52
+ * Get the mail representation of the notification.
53
+ */
54
+ public function toMail($notifiable): MailMessage
55
+ {
56
+ $geofenceName = $this->geofence->name ?? 'your location';
57
+ $orderId = $this->order->public_id ?? $this->order->uuid;
58
+
59
+ return (new MailMessage())
60
+ ->subject("Your driver has arrived — Order #{$orderId}")
61
+ ->greeting('Good news!')
62
+ ->line("Your driver has arrived at {$geofenceName} for order #{$orderId}.")
63
+ ->line('Please be ready to receive your delivery.')
64
+ ->action('Track Your Order', url("/tracking/{$this->order->tracking_number}"))
65
+ ->line('Thank you for using our service.');
66
+ }
67
+
68
+ /**
69
+ * Get the array representation of the notification (database channel).
70
+ */
71
+ public function toArray($notifiable): array
72
+ {
73
+ return [
74
+ 'event' => 'driver.arrived_at_geofence',
75
+ 'order_id' => $this->order->public_id,
76
+ 'order_uuid' => $this->order->uuid,
77
+ 'geofence_id' => $this->geofence->public_id ?? null,
78
+ 'geofence_name' => $this->geofence->name ?? null,
79
+ 'message' => sprintf(
80
+ 'Your driver has arrived at %s for order #%s.',
81
+ $this->geofence->name ?? 'your location',
82
+ $this->order->public_id
83
+ ),
84
+ ];
85
+ }
86
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Observers;
4
4
 
5
- use Fleetbase\FleetOps\Models\Order;
6
5
  use Fleetbase\FleetOps\Models\PurchaseRate;
7
6
  use Fleetbase\FleetOps\Support\Utils;
8
7
  use Fleetbase\Models\Company;
@@ -25,7 +24,8 @@ class PurchaseRateObserver
25
24
  $company = Company::where('uuid', session('company', $purchaseRate->company_uuid))->first();
26
25
 
27
26
  // get currency to use
28
- $currency = data_get($purchaseRate, 'serviceQuote.currency', $company->country ? Utils::getCurrenyFromCountryCode($company->country) : 'SGD');
27
+ $currency = data_get($purchaseRate, 'serviceQuote.currency')
28
+ ?: Utils::getCompanyTransactionCurrency($company ?? $purchaseRate->company_uuid);
29
29
 
30
30
  // create transaction and transaction items
31
31
  $transaction = Transaction::create([
@@ -41,9 +41,6 @@ class PurchaseRateObserver
41
41
  'status' => 'success',
42
42
  ]);
43
43
 
44
- // Update order with transaction id
45
- Order::where('payload_uuid', $purchaseRate->payload_uuid)->update(['transaction_uuid' => $transaction->uuid]);
46
-
47
44
  if (isset($purchaseRate->serviceQuote)) {
48
45
  $purchaseRate->serviceQuote->items->each(function ($serviceQuoteItem) use ($transaction, $currency) {
49
46
  TransactionItem::create([
@@ -57,5 +54,6 @@ class PurchaseRateObserver
57
54
  }
58
55
 
59
56
  $purchaseRate->transaction_uuid = $transaction->uuid;
57
+ $purchaseRate->status = $purchaseRate->status ?: Transaction::STATUS_SUCCESS;
60
58
  }
61
59
  }
@@ -23,6 +23,25 @@ class EventServiceProvider extends ServiceProvider
23
23
  \Fleetbase\FleetOps\Events\OrderFailed::class => [\Fleetbase\Listeners\SendResourceLifecycleWebhook::class, \Fleetbase\FleetOps\Listeners\NotifyOrderEvent::class],
24
24
  \Fleetbase\FleetOps\Events\OrderReady::class => [\Fleetbase\FleetOps\Listeners\HandleOrderReady::class],
25
25
 
26
+ /*
27
+ * Geofence Events
28
+ *
29
+ * Each event is handled by a domain listener (business logic, event log)
30
+ * and the generic SendResourceLifecycleWebhook listener (webhook delivery).
31
+ */
32
+ \Fleetbase\FleetOps\Events\GeofenceEntered::class => [
33
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceEntered::class,
34
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
35
+ ],
36
+ \Fleetbase\FleetOps\Events\GeofenceExited::class => [
37
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceExited::class,
38
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
39
+ ],
40
+ \Fleetbase\FleetOps\Events\GeofenceDwelled::class => [
41
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceDwelled::class,
42
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
43
+ ],
44
+
26
45
  /*
27
46
  * Core Events
28
47
  */
@@ -61,6 +61,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
61
61
  \Fleetbase\FleetOps\Console\Commands\PurgeUnpurchasedServiceQuotes::class,
62
62
  \Fleetbase\FleetOps\Console\Commands\SendDriverNotification::class,
63
63
  \Fleetbase\FleetOps\Console\Commands\ReplayVehicleLocations::class,
64
+ \Fleetbase\FleetOps\Console\Commands\SimulateGeofenceEvents::class,
64
65
  \Fleetbase\FleetOps\Console\Commands\TestEmail::class,
65
66
  \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class,
66
67
  \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class,
@@ -84,6 +85,14 @@ class FleetOpsServiceProvider extends CoreServiceProvider
84
85
  $this->app->register(CoreServiceProvider::class);
85
86
  $this->app->register(ReportSchemaServiceProvider::class);
86
87
 
88
+ // Register the GeofenceIntersectionService as a singleton so that
89
+ // the same instance is reused across the request lifecycle, avoiding
90
+ // repeated instantiation on high-frequency location update calls.
91
+ $this->app->singleton(
92
+ \Fleetbase\FleetOps\Support\GeofenceIntersectionService::class,
93
+ fn () => new \Fleetbase\FleetOps\Support\GeofenceIntersectionService()
94
+ );
95
+
87
96
  // Register the OrchestrationEngineRegistry as a singleton so that engines
88
97
  // registered from any service provider share the same instance.
89
98
  $this->app->singleton(
@@ -155,6 +164,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
155
164
  \Fleetbase\FleetOps\Notifications\OrderPing::class,
156
165
  \Fleetbase\FleetOps\Notifications\OrderFailed::class,
157
166
  \Fleetbase\FleetOps\Notifications\OrderCompleted::class,
167
+ \Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence::class,
158
168
  ]);
159
169
 
160
170
  // Register Notifiables
@@ -16,9 +16,7 @@ class ComputableAlgo implements Rule
16
16
  */
17
17
  public function passes($attribute, $value)
18
18
  {
19
- $distanceAndTime = Algo::calculateDrivingDistanceAndTime('1.3506853', '103.87199110000006', '1.3621663', '103.88450490000002');
20
-
21
- return Algo::exec($value, $distanceAndTime) > 0;
19
+ return Algo::isComputable($value);
22
20
  }
23
21
 
24
22
  /**
@@ -6,6 +6,8 @@ use Webit\Util\EvalMath\EvalMath;
6
6
 
7
7
  class Algo
8
8
  {
9
+ protected const FUNCTION_PATTERN = '/\b(max|min|ceil|floor|round)\(([^()]*)\)/';
10
+
9
11
  /**
10
12
  * Execute an algorithm strig.
11
13
  *
@@ -16,11 +18,18 @@ class Algo
16
18
  $m = new EvalMath();
17
19
  $m->suppress_errors = true;
18
20
 
21
+ $variables = static::normalizeVariables($variables);
22
+
19
23
  foreach ($variables as $key => $value) {
20
24
  $algorithm = str_replace('{' . $key . '}', $value, $algorithm);
21
25
  }
22
26
 
23
- $result = $m->evaluate($algorithm);
27
+ $algorithm = static::evaluateFunctions($algorithm);
28
+ $result = $m->evaluate($algorithm);
29
+
30
+ if ($result === false || $result === null || !is_numeric($result)) {
31
+ return null;
32
+ }
24
33
 
25
34
  if ($round) {
26
35
  return round($result, 2); // precision 2 cuz most likely dealing with $
@@ -28,4 +37,81 @@ class Algo
28
37
 
29
38
  return $result;
30
39
  }
40
+
41
+ public static function isComputable($algorithm): bool
42
+ {
43
+ $result = static::exec($algorithm, static::validationFixture(), true);
44
+
45
+ return $result !== null && is_numeric($result);
46
+ }
47
+
48
+ public static function normalizeVariables($variables = []): array
49
+ {
50
+ $variables = collect($variables)->all();
51
+
52
+ $distanceM = (float) ($variables['distance_m'] ?? $variables['distance'] ?? 0);
53
+ $timeS = (float) ($variables['time_s'] ?? $variables['time'] ?? 0);
54
+
55
+ $normalized = [
56
+ 'distance_m' => $distanceM,
57
+ 'distance' => $distanceM,
58
+ 'distance_km'=> $distanceM / 1000,
59
+ 'distance_mi'=> $distanceM / 1609.344,
60
+ 'time_s' => $timeS,
61
+ 'time' => $timeS,
62
+ 'time_min' => $timeS / 60,
63
+ 'stops' => (int) ($variables['stops'] ?? 0),
64
+ 'waypoints' => (int) ($variables['waypoints'] ?? 0),
65
+ 'parcels' => (int) ($variables['parcels'] ?? 0),
66
+ 'entities' => (int) ($variables['entities'] ?? 0),
67
+ 'base_fee' => (float) ($variables['base_fee'] ?? 0),
68
+ ];
69
+
70
+ return array_merge($normalized, $variables);
71
+ }
72
+
73
+ protected static function validationFixture(): array
74
+ {
75
+ return static::normalizeVariables([
76
+ 'distance_m' => 25000,
77
+ 'time_s' => 5400,
78
+ 'stops' => 4,
79
+ 'waypoints' => 2,
80
+ 'parcels' => 3,
81
+ 'entities' => 5,
82
+ 'base_fee' => 100,
83
+ ]);
84
+ }
85
+
86
+ protected static function evaluateFunctions(string $algorithm): string
87
+ {
88
+ while (preg_match(static::FUNCTION_PATTERN, $algorithm)) {
89
+ $algorithm = preg_replace_callback(static::FUNCTION_PATTERN, function ($matches) {
90
+ $function = $matches[1];
91
+ $args = array_map('trim', explode(',', $matches[2]));
92
+ $m = new EvalMath();
93
+ $m->suppress_errors = true;
94
+ $numbers = array_map(function ($arg) use ($m) {
95
+ $result = $m->evaluate($arg);
96
+
97
+ if ($result === false || $result === null || !is_numeric($result)) {
98
+ return (float) $arg;
99
+ }
100
+
101
+ return (float) $result;
102
+ }, $args);
103
+
104
+ return match ($function) {
105
+ 'max' => (string) max($numbers[0] ?? 0, $numbers[1] ?? 0),
106
+ 'min' => (string) min($numbers[0] ?? 0, $numbers[1] ?? 0),
107
+ 'ceil' => (string) ceil($numbers[0] ?? 0),
108
+ 'floor' => (string) floor($numbers[0] ?? 0),
109
+ 'round' => (string) round($numbers[0] ?? 0, (int) ($numbers[1] ?? 0)),
110
+ default => $matches[0],
111
+ };
112
+ }, $algorithm);
113
+ }
114
+
115
+ return $algorithm;
116
+ }
31
117
  }