@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,6 +3,7 @@
3
3
  namespace Fleetbase\FleetOps\Models;
4
4
 
5
5
  use Fleetbase\Casts\Json;
6
+ use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
6
7
  use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
7
8
  use Fleetbase\FleetOps\Support\Utils;
8
9
  use Fleetbase\Models\CompanyUser;
@@ -319,7 +320,12 @@ class Contact extends Model
319
320
  // Check if user already exist with email or phone number
320
321
  $existingUser = null;
321
322
  if ($contact->email || $contact->phone) {
322
- $existingUser = User::where('company_uuid', $contact->company_uuid)
323
+ $existingUser = User::where(function ($query) use ($contact) {
324
+ $query->where('company_uuid', $contact->company_uuid)
325
+ ->orWhereHas('companyUsers', function ($query) use ($contact) {
326
+ $query->where('company_uuid', $contact->company_uuid);
327
+ });
328
+ })
323
329
  ->where(function ($query) use ($contact) {
324
330
  if ($contact->email) {
325
331
  $query->where('email', $contact->email);
@@ -335,15 +341,15 @@ class Contact extends Model
335
341
  }
336
342
 
337
343
  if ($existingUser) {
344
+ if ($contact->isCustomer()) {
345
+ $contact->assertCustomerUserCanBeAssigned($existingUser);
346
+ }
347
+
338
348
  // Check if existing user belongs to another contact
339
349
  $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first();
340
350
  if ($existingUserContact) {
341
351
  throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser);
342
352
  } else {
343
- if ($contact->isCustomer()) {
344
- $contact->normalizeCustomerUser($existingUser);
345
- }
346
-
347
353
  // Assign the user to this contact instead
348
354
  $contact->setAttribute('user_uuid', $existingUser->uuid);
349
355
  if ($update) {
@@ -423,13 +429,7 @@ class Contact extends Model
423
429
  return null;
424
430
  }
425
431
 
426
- if ($user->type !== 'customer') {
427
- if ($quiet) {
428
- $user->forceFill(['type' => 'customer'])->saveQuietly();
429
- } else {
430
- $user->setType('customer');
431
- }
432
- }
432
+ $this->assertCustomerUserCanBeAssigned($user);
433
433
 
434
434
  $this->loadMissing('company');
435
435
  if ($this->company) {
@@ -463,7 +463,7 @@ class Contact extends Model
463
463
  public function repairCustomerTypeInvariant(bool $quiet = false): ?User
464
464
  {
465
465
  if (!$this->isCustomer()) {
466
- $this->forceFill(['type' => 'customer'])->saveQuietly();
466
+ return $this->getUser();
467
467
  }
468
468
 
469
469
  $user = $this->getUser();
@@ -512,8 +512,17 @@ class Contact extends Model
512
512
  // Load company
513
513
  $this->loadMissing('company');
514
514
 
515
- if ($this->isCustomer() && $user->type !== 'customer') {
516
- $user->setType('customer');
515
+ if ($this->isCustomer()) {
516
+ $this->assertCustomerUserCanBeAssigned($user);
517
+
518
+ $existingUserContact = static::where(['user_uuid' => $user->uuid, 'company_uuid' => $this->company_uuid])
519
+ ->whereNot('uuid', $this->uuid)
520
+ ->whereHas('user')
521
+ ->first();
522
+
523
+ if ($existingUserContact) {
524
+ throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $user);
525
+ }
517
526
  }
518
527
 
519
528
  // Assing to company
@@ -551,6 +560,74 @@ class Contact extends Model
551
560
  return $this;
552
561
  }
553
562
 
563
+ public function assertCustomerIdentityIsAvailable(): void
564
+ {
565
+ if (!$this->isCustomer()) {
566
+ return;
567
+ }
568
+
569
+ $assignedUser = $this->getAssignedUserWithoutTypeScope();
570
+ if ($assignedUser) {
571
+ $this->assertCustomerUserCanBeAssigned($assignedUser);
572
+ }
573
+
574
+ $matchedUser = $this->findExistingUserByIdentity();
575
+ if ($matchedUser && $matchedUser->uuid !== $this->user_uuid) {
576
+ $this->assertCustomerUserCanBeAssigned($matchedUser);
577
+ }
578
+ }
579
+
580
+ public function assertCustomerUserCanBeAssigned(User $user): void
581
+ {
582
+ if ($user->type === 'customer') {
583
+ return;
584
+ }
585
+
586
+ throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user);
587
+ }
588
+
589
+ public static function customerUserConflictMessage(?User $user = null): string
590
+ {
591
+ $field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account');
592
+
593
+ return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.';
594
+ }
595
+
596
+ private function findExistingUserByIdentity(): ?User
597
+ {
598
+ if (!$this->email && !$this->phone) {
599
+ return null;
600
+ }
601
+
602
+ return User::where(function ($query) {
603
+ $query->where('company_uuid', $this->company_uuid)
604
+ ->orWhereHas('companyUsers', function ($query) {
605
+ $query->where('company_uuid', $this->company_uuid);
606
+ });
607
+ })
608
+ ->where(function ($query) {
609
+ if ($this->email) {
610
+ $query->where('email', $this->email);
611
+ }
612
+
613
+ if ($this->phone) {
614
+ $method = $this->email ? 'orWhere' : 'where';
615
+ $query->{$method}('phone', Utils::formatPhoneNumber($this->phone));
616
+ }
617
+ })
618
+ ->whereNull('deleted_at')
619
+ ->first();
620
+ }
621
+
622
+ private function getAssignedUserWithoutTypeScope(): ?User
623
+ {
624
+ if (!Str::isUuid($this->user_uuid)) {
625
+ return null;
626
+ }
627
+
628
+ return User::where('uuid', $this->user_uuid)->first();
629
+ }
630
+
554
631
  public function syncWithUser(): bool
555
632
  {
556
633
  $updates = [];
@@ -0,0 +1,64 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\Eloquent\Relations\HasMany;
7
+ use Illuminate\Support\Str;
8
+
9
+ /**
10
+ * Fleet-Ops customer.
11
+ *
12
+ * A thin specialization of {@see Contact} that is scoped to rows with
13
+ * `type='customer'` and forces that type on create. This is what the public
14
+ * customer API surface (`/v1/customers/...`) operates on.
15
+ *
16
+ * Mirrors {@see \Fleetbase\Storefront\Models\Customer} but lives directly in
17
+ * FleetOps so that customer auth + customer-scoped orders work without the
18
+ * Storefront publishable-key + Store/Network gating.
19
+ */
20
+ class Customer extends Contact
21
+ {
22
+ /**
23
+ * The key to use in the payload responses.
24
+ */
25
+ protected string $payloadKey = 'customer';
26
+
27
+ /**
28
+ * Boot the model.
29
+ */
30
+ protected static function boot()
31
+ {
32
+ parent::boot();
33
+
34
+ static::creating(function ($model) {
35
+ $model->type = 'customer';
36
+ });
37
+
38
+ static::addGlobalScope('type', function (Builder $builder) {
39
+ $builder->where('type', 'customer');
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Orders owned by this customer.
45
+ */
46
+ public function orders(): HasMany
47
+ {
48
+ return $this->hasMany(Order::class, 'customer_uuid')
49
+ ->whereNull('deleted_at')
50
+ ->withoutGlobalScopes();
51
+ }
52
+
53
+ /**
54
+ * Find a customer by either a `customer_xxx` or `contact_xxx` public id.
55
+ */
56
+ public static function findFromCustomerId(string $publicId): ?self
57
+ {
58
+ if (Str::startsWith($publicId, 'customer')) {
59
+ $publicId = Str::replaceFirst('customer', 'contact', $publicId);
60
+ }
61
+
62
+ return static::where('public_id', $publicId)->first();
63
+ }
64
+ }
@@ -119,6 +119,15 @@ class Driver extends Model
119
119
  'time_window_end',
120
120
  ];
121
121
 
122
+ /**
123
+ * Set attributes and defaults.
124
+ *
125
+ * @var array
126
+ */
127
+ protected $attributes = [
128
+ 'status' => 'available',
129
+ ];
130
+
122
131
  /**
123
132
  * The attributes that are guarded and not mass assignable.
124
133
  *
@@ -595,9 +604,9 @@ class Driver extends Model
595
604
  /**
596
605
  * Set the driver status attribute.
597
606
  */
598
- public function setStatusAttribute(?string $status = 'active'): void
607
+ public function setStatusAttribute(?string $status = 'available'): void
599
608
  {
600
- $this->attributes['status'] = $status;
609
+ $this->attributes['status'] = $status === null || $status === 'active' ? 'available' : $status;
601
610
  }
602
611
 
603
612
  /**
@@ -854,7 +863,7 @@ class Driver extends Model
854
863
  'user_uuid' => $user->uuid,
855
864
  'drivers_license_number' => $driversLicenseNumber,
856
865
  'country' => $country,
857
- 'status' => 'active',
866
+ 'status' => 'available',
858
867
  'location' => Utils::parsePointToWkt(new Point(0, 0)),
859
868
  ]);
860
869
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Models;
4
4
 
5
- use Barryvdh\DomPDF\Facade\Pdf;
6
5
  use Fleetbase\Casts\Json;
7
6
  use Fleetbase\Casts\PolymorphicType;
7
+ use Fleetbase\FleetOps\Support\LabelPdf;
8
8
  use Fleetbase\FleetOps\Support\Utils;
9
9
  use Fleetbase\FleetOps\Traits\HasTrackingNumber;
10
10
  use Fleetbase\FleetOps\Traits\PayloadAccessors;
@@ -142,7 +142,7 @@ class Entity extends Model
142
142
  */
143
143
  public function pdfLabel()
144
144
  {
145
- return Pdf::loadHTML($this->label());
145
+ return LabelPdf::fromHtml($this->label());
146
146
  }
147
147
 
148
148
  /**
@@ -2,7 +2,6 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Models;
4
4
 
5
- use Barryvdh\DomPDF\Facade\Pdf;
6
5
  use Fleetbase\Casts\Json;
7
6
  use Fleetbase\Casts\PolymorphicType;
8
7
  use Fleetbase\FleetOps\Events\OrderCanceled;
@@ -10,6 +9,7 @@ use Fleetbase\FleetOps\Events\OrderCompleted;
10
9
  use Fleetbase\FleetOps\Events\OrderDispatched;
11
10
  use Fleetbase\FleetOps\Events\OrderDriverAssigned;
12
11
  use Fleetbase\FleetOps\Flow\Activity;
12
+ use Fleetbase\FleetOps\Support\LabelPdf;
13
13
  use Fleetbase\FleetOps\Support\OrderTracker;
14
14
  use Fleetbase\FleetOps\Support\Utils;
15
15
  use Fleetbase\FleetOps\Traits\HasTrackingNumber;
@@ -275,7 +275,32 @@ class Order extends Model
275
275
  */
276
276
  public function pdfLabel()
277
277
  {
278
- return Pdf::loadHTML($this->label());
278
+ return LabelPdf::fromHtml($this->label());
279
+ }
280
+
281
+ public function attachFiles($uploads): self
282
+ {
283
+ $ids = collect($uploads)
284
+ ->map(function ($upload) {
285
+ if (is_string($upload)) {
286
+ return $upload;
287
+ }
288
+
289
+ return data_get($upload, 'uuid') ?? data_get($upload, 'id');
290
+ })
291
+ ->filter()
292
+ ->unique()
293
+ ->values();
294
+
295
+ if ($ids->isEmpty()) {
296
+ return $this;
297
+ }
298
+
299
+ \Fleetbase\Models\File::whereIn('uuid', $ids)->get()->each(function ($file) {
300
+ $file->setKey($this);
301
+ });
302
+
303
+ return $this;
279
304
  }
280
305
 
281
306
  /**
@@ -1991,11 +2016,11 @@ class Order extends Model
1991
2016
  return collect();
1992
2017
  }
1993
2018
 
1994
- $drivers = Driver::where(['status' => 'active', 'online' => 1])
2019
+ $drivers = Driver::where(['status' => 'available', 'online' => 1])
1995
2020
  ->whereHas('company', function ($q) {
1996
2021
  $q->whereHas('users', function ($q) {
1997
2022
  $q->whereHas('driver', function ($q) {
1998
- $q->where(['status' => 'active', 'online' => 1]);
2023
+ $q->where(['status' => 'available', 'online' => 1]);
1999
2024
  $q->whereNull('deleted_at');
2000
2025
  });
2001
2026
  });
@@ -61,7 +61,23 @@ class Payload extends Model
61
61
  *
62
62
  * @var array
63
63
  */
64
- protected $fillable = ['_key', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'return_uuid', 'current_waypoint_uuid', 'meta', 'payment_method', 'cod_amount', 'cod_currency', 'cod_payment_method', 'type'];
64
+ protected $fillable = [
65
+ '_key',
66
+ 'company_uuid',
67
+ 'pickup_uuid',
68
+ 'pickup_tracking_number_uuid',
69
+ 'dropoff_uuid',
70
+ 'dropoff_tracking_number_uuid',
71
+ 'return_uuid',
72
+ 'return_tracking_number_uuid',
73
+ 'current_waypoint_uuid',
74
+ 'meta',
75
+ 'payment_method',
76
+ 'cod_amount',
77
+ 'cod_currency',
78
+ 'cod_payment_method',
79
+ 'type',
80
+ ];
65
81
 
66
82
  /**
67
83
  * The attributes that should be cast to native types.
@@ -216,6 +216,14 @@ class ServiceRate extends Model
216
216
  return $this->rate_calculation_method === 'per_meter';
217
217
  }
218
218
 
219
+ /**
220
+ * Check if the rate calculation method is "multi_zone_distance".
221
+ */
222
+ public function isMultiZoneDistance(): bool
223
+ {
224
+ return $this->rate_calculation_method === 'multi_zone_distance';
225
+ }
226
+
219
227
  /**
220
228
  * Check if the rate calculation method is "per_drop".
221
229
  */
@@ -329,6 +337,16 @@ class ServiceRate extends Model
329
337
  return $this;
330
338
  }
331
339
 
340
+ $serviceRateFees = collect($serviceRateFees)
341
+ ->map(fn ($fee) => $this->normalizeServiceRateFeePayload($fee))
342
+ ->filter()
343
+ ->values()
344
+ ->toArray();
345
+
346
+ if (!$serviceRateFees) {
347
+ return $this;
348
+ }
349
+
332
350
  $iterate = count($serviceRateFees);
333
351
 
334
352
  for ($i = 0; $i < $iterate; $i++) {
@@ -356,6 +374,43 @@ class ServiceRate extends Model
356
374
  return $this;
357
375
  }
358
376
 
377
+ /**
378
+ * Normalize embedded service-rate fee payloads before persistence.
379
+ */
380
+ public function normalizeServiceRateFeePayload($fee): ?array
381
+ {
382
+ if (!is_array($fee)) {
383
+ return null;
384
+ }
385
+
386
+ $fee['service_area_uuid'] ??= data_get($fee, 'service_area.uuid');
387
+ $fee['zone_uuid'] ??= data_get($fee, 'zone.uuid');
388
+
389
+ if (Utils::castBoolean($fee['is_fallback'] ?? false)) {
390
+ $fee['service_area_uuid'] = null;
391
+ $fee['zone_uuid'] = null;
392
+ }
393
+
394
+ return collect($fee)
395
+ ->only([
396
+ 'uuid',
397
+ 'service_rate_uuid',
398
+ 'service_area_uuid',
399
+ 'zone_uuid',
400
+ 'label',
401
+ 'priority',
402
+ 'is_fallback',
403
+ 'distance',
404
+ 'distance_unit',
405
+ 'min',
406
+ 'max',
407
+ 'unit',
408
+ 'fee',
409
+ 'currency',
410
+ ])
411
+ ->toArray();
412
+ }
413
+
359
414
  /**
360
415
  * Set the service rate parcel fees for the current object.
361
416
  *
@@ -506,7 +561,7 @@ class ServiceRate extends Model
506
561
  public static function getServicableForPlaces($places = [], $service = null, $currency = null, ?\Closure $queryCallback = null): array
507
562
  {
508
563
  $reader = new GeoJSONReader();
509
- $serviceRatesQuery = static::with(['zone', 'serviceArea', 'rateFees', 'parcelFees']);
564
+ $serviceRatesQuery = static::with(['zone', 'serviceArea', 'rateFees.zone', 'rateFees.serviceArea', 'parcelFees']);
510
565
 
511
566
  if ($currency) {
512
567
  $serviceRatesQuery->whereRaw('lower(currency) = ?', [strtolower($currency)]);
@@ -738,6 +793,12 @@ class ServiceRate extends Model
738
793
  ]);
739
794
  }
740
795
 
796
+ if ($this->isMultiZoneDistance()) {
797
+ [$multiZoneFee, $multiZoneLines] = $this->quoteMultiZoneDistance($waypoints, $totalDistance);
798
+ $subTotal += $multiZoneFee;
799
+ $lines = $lines->merge($multiZoneLines);
800
+ }
801
+
741
802
  if ($this->isAlgorithm()) {
742
803
  $resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
743
804
  $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
@@ -927,6 +988,12 @@ class ServiceRate extends Model
927
988
  ]);
928
989
  }
929
990
 
991
+ if ($this->isMultiZoneDistance()) {
992
+ [$multiZoneFee, $multiZoneLines] = $this->quoteMultiZoneDistance($waypoints, $totalDistance);
993
+ $subTotal += $multiZoneFee;
994
+ $lines = $lines->merge($multiZoneLines);
995
+ }
996
+
930
997
  if ($this->isAlgorithm()) {
931
998
  $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
932
999
  $this->algorithm,
@@ -1086,6 +1153,211 @@ class ServiceRate extends Model
1086
1153
  return 2;
1087
1154
  }
1088
1155
 
1156
+ protected function quoteMultiZoneDistance($waypoints = [], ?int $totalDistance = 0): array
1157
+ {
1158
+ $this->loadMissing(['rateFees.zone', 'rateFees.serviceArea']);
1159
+
1160
+ $rules = $this->rateFees
1161
+ ->filter(fn ($rule) => !$rule->is_fallback && ($rule->zone || $rule->serviceArea))
1162
+ ->sortByDesc(fn ($rule) => (int) ($rule->priority ?? 0))
1163
+ ->values();
1164
+
1165
+ $fallbackRule = $this->rateFees
1166
+ ->filter(fn ($rule) => (bool) $rule->is_fallback)
1167
+ ->sortByDesc(fn ($rule) => (int) ($rule->priority ?? 0))
1168
+ ->first();
1169
+
1170
+ if ($rules->isEmpty() && !$fallbackRule) {
1171
+ return [0, collect()];
1172
+ }
1173
+
1174
+ $pricedDistances = $this->calculateMultiZoneDistances($waypoints, $rules, $fallbackRule, $totalDistance);
1175
+ $lines = collect();
1176
+ $subTotal = 0;
1177
+
1178
+ foreach ($pricedDistances as $entry) {
1179
+ $rule = $entry['rule'];
1180
+ if (!$rule instanceof ServiceRateFee) {
1181
+ continue;
1182
+ }
1183
+
1184
+ $distanceInMeters = (float) ($entry['distance_m'] ?? 0);
1185
+ if ($distanceInMeters <= 0) {
1186
+ continue;
1187
+ }
1188
+
1189
+ $unit = strtolower($rule->distance_unit ?: 'km');
1190
+ $distance = $this->normalizeDistanceForUnit($distanceInMeters, $unit);
1191
+ $fee = $this->normalizeCalculatedMoney($distance * Utils::numbersOnly($rule->fee ?? 0));
1192
+ $subTotal += $fee;
1193
+
1194
+ if ($rule->is_fallback) {
1195
+ $label = 'Out-of-zone distance charge';
1196
+ } else {
1197
+ $label = data_get($rule, 'zone.name') ?: data_get($rule, 'serviceArea.name') ?: $rule->label ?: 'Geographic';
1198
+ }
1199
+
1200
+ if (!$rule->is_fallback && !str_ends_with(strtolower($label), 'distance charge')) {
1201
+ $label .= ' distance charge';
1202
+ }
1203
+
1204
+ $lines->push([
1205
+ 'details' => sprintf('%s (%s %s x %s)', $label, round($distance, 2), $unit, Utils::moneyFormat($rule->fee, $this->currency)),
1206
+ 'amount' => $fee,
1207
+ 'formatted_amount' => Utils::moneyFormat($fee, $this->currency),
1208
+ 'currency' => $this->currency,
1209
+ 'code' => 'MULTI_ZONE_DISTANCE_FEE',
1210
+ ]);
1211
+ }
1212
+
1213
+ return [$subTotal, $lines];
1214
+ }
1215
+
1216
+ protected function calculateMultiZoneDistances($waypoints, $rules, ?ServiceRateFee $fallbackRule = null, ?int $totalDistance = 0): array
1217
+ {
1218
+ $places = collect($waypoints)->map(fn ($place) => Place::createFromMixed($place))->filter()->values();
1219
+
1220
+ if ($places->count() < 2 || (int) $totalDistance <= 0) {
1221
+ return $fallbackRule ? [['rule' => $fallbackRule, 'distance_m' => (float) ($totalDistance ?? 0)]] : [];
1222
+ }
1223
+
1224
+ $reader = new GeoJSONReader();
1225
+ $ruleGeometries = $rules->map(function ($rule) use ($reader) {
1226
+ return [
1227
+ 'rule' => $rule,
1228
+ 'geometry' => $this->readRateRuleGeometry($rule, $reader),
1229
+ ];
1230
+ })->filter(fn ($entry) => $entry['geometry'])->values();
1231
+
1232
+ if ($ruleGeometries->isEmpty()) {
1233
+ return $fallbackRule ? [['rule' => $fallbackRule, 'distance_m' => (float) $totalDistance]] : [];
1234
+ }
1235
+
1236
+ $distances = [];
1237
+ $sampledTotal = 0;
1238
+
1239
+ for ($i = 0; $i < $places->count() - 1; $i++) {
1240
+ $start = $this->getLngLatFromPlace($places[$i]);
1241
+ $end = $this->getLngLatFromPlace($places[$i + 1]);
1242
+
1243
+ if (!$start || !$end) {
1244
+ continue;
1245
+ }
1246
+
1247
+ $legDistance = $this->haversineDistanceInMeters($start['lat'], $start['lng'], $end['lat'], $end['lng']);
1248
+ $steps = max(1, min(500, (int) ceil($legDistance / 1000)));
1249
+
1250
+ for ($step = 0; $step < $steps; $step++) {
1251
+ $fromRatio = $step / $steps;
1252
+ $toRatio = ($step + 1) / $steps;
1253
+ $midRatio = ($fromRatio + $toRatio) / 2;
1254
+
1255
+ $from = $this->interpolateLngLat($start, $end, $fromRatio);
1256
+ $to = $this->interpolateLngLat($start, $end, $toRatio);
1257
+ $mid = $this->interpolateLngLat($start, $end, $midRatio);
1258
+
1259
+ $sampleDistance = $this->haversineDistanceInMeters($from['lat'], $from['lng'], $to['lat'], $to['lng']);
1260
+ $sampledTotal += $sampleDistance;
1261
+
1262
+ $matchedRule = $this->matchMultiZoneRule($mid, $ruleGeometries) ?: $fallbackRule;
1263
+ if (!$matchedRule instanceof ServiceRateFee) {
1264
+ continue;
1265
+ }
1266
+
1267
+ $key = $matchedRule->uuid ?? spl_object_hash($matchedRule);
1268
+ if (!isset($distances[$key])) {
1269
+ $distances[$key] = ['rule' => $matchedRule, 'distance_m' => 0];
1270
+ }
1271
+
1272
+ $distances[$key]['distance_m'] += $sampleDistance;
1273
+ }
1274
+ }
1275
+
1276
+ if ($sampledTotal > 0 && $totalDistance > 0) {
1277
+ $scale = $totalDistance / $sampledTotal;
1278
+ foreach ($distances as &$entry) {
1279
+ $entry['distance_m'] *= $scale;
1280
+ }
1281
+ unset($entry);
1282
+ }
1283
+
1284
+ return array_values($distances);
1285
+ }
1286
+
1287
+ protected function readRateRuleGeometry(ServiceRateFee $rule, GeoJSONReader $reader)
1288
+ {
1289
+ $border = data_get($rule, 'zone.border') ?: data_get($rule, 'serviceArea.border');
1290
+
1291
+ if (!$border) {
1292
+ return null;
1293
+ }
1294
+
1295
+ try {
1296
+ if (is_object($border) && method_exists($border, 'toJson')) {
1297
+ return $reader->read($border->toJson());
1298
+ }
1299
+
1300
+ if (is_array($border) || is_object($border)) {
1301
+ return $reader->read(json_encode($border, JSON_UNESCAPED_UNICODE));
1302
+ }
1303
+
1304
+ if (is_string($border)) {
1305
+ return $reader->read($border);
1306
+ }
1307
+ } catch (\Throwable $e) {
1308
+ return null;
1309
+ }
1310
+
1311
+ return null;
1312
+ }
1313
+
1314
+ protected function matchMultiZoneRule(array $point, $ruleGeometries): ?ServiceRateFee
1315
+ {
1316
+ $brickPoint = \Brick\Geo\Point::fromText(sprintf('POINT (%F %F)', $point['lng'], $point['lat']), 4326);
1317
+
1318
+ foreach ($ruleGeometries as $entry) {
1319
+ if ($entry['geometry']->contains($brickPoint)) {
1320
+ return $entry['rule'];
1321
+ }
1322
+ }
1323
+
1324
+ return null;
1325
+ }
1326
+
1327
+ protected function getLngLatFromPlace(?Place $place): ?array
1328
+ {
1329
+ if (!$place instanceof Place) {
1330
+ return null;
1331
+ }
1332
+
1333
+ $point = $place->getLocationAsPoint();
1334
+
1335
+ if (!$point || !method_exists($point, 'getLat') || !method_exists($point, 'getLng')) {
1336
+ return null;
1337
+ }
1338
+
1339
+ return ['lat' => (float) $point->getLat(), 'lng' => (float) $point->getLng()];
1340
+ }
1341
+
1342
+ protected function interpolateLngLat(array $start, array $end, float $ratio): array
1343
+ {
1344
+ return [
1345
+ 'lat' => $start['lat'] + (($end['lat'] - $start['lat']) * $ratio),
1346
+ 'lng' => $start['lng'] + (($end['lng'] - $start['lng']) * $ratio),
1347
+ ];
1348
+ }
1349
+
1350
+ protected function haversineDistanceInMeters(float $lat1, float $lng1, float $lat2, float $lng2): float
1351
+ {
1352
+ $earthRadius = 6371000;
1353
+ $latDelta = deg2rad($lat2 - $lat1);
1354
+ $lngDelta = deg2rad($lng2 - $lng1);
1355
+
1356
+ $a = sin($latDelta / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($lngDelta / 2) ** 2;
1357
+
1358
+ return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
1359
+ }
1360
+
1089
1361
  /**
1090
1362
  * Find the ServiceRateFee based on the total distance.
1091
1363
  *