@fleetbase/fleetops-engine 0.6.45 → 0.6.47

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 (148) hide show
  1. package/addon/components/admin/map-settings.hbs +26 -0
  2. package/addon/components/admin/map-settings.js +46 -0
  3. package/addon/components/admin/routing-settings.hbs +11 -0
  4. package/addon/components/cell/order-route-type.hbs +46 -0
  5. package/addon/components/cell/order-route-type.js +90 -0
  6. package/addon/components/cell/place-address.hbs +1 -0
  7. package/addon/components/display-place.hbs +45 -56
  8. package/addon/components/display-place.js +21 -0
  9. package/addon/components/layout/fleet-ops-sidebar/driver-listing.js +5 -5
  10. package/addon/components/layout/fleet-ops-sidebar/fleet-listing.js +5 -5
  11. package/addon/components/layout/fleet-ops-sidebar.js +8 -0
  12. package/addon/components/maintenance-schedule/form.js +2 -1
  13. package/addon/components/map/drawer/driver-listing.js +5 -5
  14. package/addon/components/map/drawer/place-listing.js +5 -5
  15. package/addon/components/map/drawer/position-listing.js +102 -21
  16. package/addon/components/map/drawer/vehicle-listing.js +5 -5
  17. package/addon/components/map/google-live-map.hbs +9 -0
  18. package/addon/components/map/google-live-map.js +445 -0
  19. package/addon/components/map/leaflet-live-map.hbs +186 -142
  20. package/addon/components/map/leaflet-live-map.js +121 -41
  21. package/addon/components/map/toolbar/visibility-control-panel.js +2 -2
  22. package/addon/components/map/toolbar/zones-panel.hbs +9 -9
  23. package/addon/components/map/toolbar.hbs +3 -3
  24. package/addon/components/map/toolbar.js +9 -1
  25. package/addon/components/modals/map-layer-form.hbs +132 -63
  26. package/addon/components/modals/orchestrator-import.hbs +4 -11
  27. package/addon/components/modals/orchestrator-import.js +17 -1
  28. package/addon/components/modals/update-order-activity.hbs +1 -1
  29. package/addon/components/orchestrator/order-pool.js +3 -2
  30. package/addon/components/orchestrator-workbench.js +29 -41
  31. package/addon/components/order/details/detail.js +2 -2
  32. package/addon/components/order/details/payload.hbs +5 -5
  33. package/addon/components/order/details/payload.js +18 -1
  34. package/addon/components/order/details/route.hbs +2 -2
  35. package/addon/components/order/details/route.js +76 -3
  36. package/addon/components/order/form/details.hbs +1 -2
  37. package/addon/components/order/form/details.js +6 -0
  38. package/addon/components/order/form/payload.hbs +33 -0
  39. package/addon/components/order/form/payload.js +1 -0
  40. package/addon/components/order/form/route.hbs +8 -8
  41. package/addon/components/order/form/route.js +86 -18
  42. package/addon/components/order/form.js +2 -2
  43. package/addon/components/order/route-editor.hbs +209 -147
  44. package/addon/components/order/route-editor.js +143 -102
  45. package/addon/components/place/address.hbs +1 -0
  46. package/addon/components/place/address.js +12 -0
  47. package/addon/components/route-list.hbs +43 -55
  48. package/addon/components/route-list.js +47 -0
  49. package/addon/components/route-optimization-wizard-panel.js +3 -4
  50. package/addon/components/vehicle/card.hbs +4 -4
  51. package/addon/controllers/maintenance/schedules/index.js +2 -1
  52. package/addon/controllers/operations/orders/index/details.js +110 -37
  53. package/addon/controllers/operations/orders/index/new.js +1 -1
  54. package/addon/controllers/operations/orders/index.js +8 -1
  55. package/addon/controllers/settings/map.js +97 -0
  56. package/addon/controllers/settings/payments/index.js +1 -1
  57. package/addon/controllers/settings/routing.js +47 -12
  58. package/addon/extension.js +11 -0
  59. package/addon/helpers/place-address-html.js +6 -0
  60. package/addon/instance-initializers/register-google-routes.js +17 -0
  61. package/addon/instance-initializers/register-osrm.js +5 -0
  62. package/addon/routes/application.js +6 -0
  63. package/addon/routes/operations/orchestrator.js +13 -0
  64. package/addon/routes/operations/orders/index/details.js +29 -22
  65. package/addon/routes/operations/orders/index/new.js +9 -0
  66. package/addon/routes/operations/orders/index.js +0 -1
  67. package/addon/routes/settings/map.js +3 -0
  68. package/addon/routes.js +1 -0
  69. package/addon/services/entity-actions.js +1 -1
  70. package/addon/services/geofence-event-bus.js +2 -1
  71. package/addon/services/geofence.js +204 -168
  72. package/addon/services/google-routes.js +300 -0
  73. package/addon/services/leaflet-draw-restriction.js +2 -1
  74. package/addon/services/leaflet-map-manager.js +17 -0
  75. package/addon/services/map-adapter/google.js +2205 -0
  76. package/addon/services/map-adapter/leaflet.js +1042 -0
  77. package/addon/services/map-adapter-interface.js +551 -0
  78. package/addon/services/map-manager.js +768 -0
  79. package/addon/services/map-settings.js +75 -0
  80. package/addon/services/movement-tracker.js +83 -32
  81. package/addon/services/order-actions.js +11 -1
  82. package/addon/services/osrm.js +79 -6
  83. package/addon/services/position-playback.js +135 -62
  84. package/addon/services/route-engine.js +83 -0
  85. package/addon/services/service-areas.js +8 -1
  86. package/addon/styles/fleetops-engine.css +555 -0
  87. package/addon/templates/management/vehicles/index.hbs +0 -1
  88. package/addon/templates/operations/orders/index/details.hbs +0 -1
  89. package/addon/templates/settings/map.hbs +53 -0
  90. package/addon/templates/settings/routing.hbs +15 -4
  91. package/addon/utils/fleet-ops-options.js +1 -0
  92. package/addon/utils/has-valid-coordinates.js +6 -0
  93. package/addon/utils/map-provider-options.js +6 -0
  94. package/addon/utils/order-route-editing.js +86 -0
  95. package/addon/utils/order-route-summary.js +20 -0
  96. package/addon/utils/place-address-html.js +41 -0
  97. package/addon/utils/route-colors.js +43 -12
  98. package/addon/utils/route-visualization.js +93 -0
  99. package/addon/utils/vendor-integration.js +2 -1
  100. package/app/components/admin/map-settings.js +1 -0
  101. package/app/components/admin/routing-settings.js +1 -0
  102. package/app/components/cell/order-route-type.js +1 -0
  103. package/app/components/cell/place-address.js +1 -0
  104. package/app/components/map/google-live-map.js +1 -0
  105. package/app/components/place/address.js +1 -0
  106. package/app/controllers/settings/map.js +1 -0
  107. package/app/helpers/place-address-html.js +1 -0
  108. package/app/routes/settings/map.js +1 -0
  109. package/app/services/map-adapter/google.js +1 -0
  110. package/app/services/map-adapter/leaflet.js +1 -0
  111. package/app/services/map-adapter-interface.js +1 -0
  112. package/app/services/map-manager.js +1 -0
  113. package/app/services/map-settings.js +1 -0
  114. package/app/templates/settings/map.js +1 -0
  115. package/app/utils/has-valid-coordinates.js +1 -0
  116. package/app/utils/order-route-editing.js +2 -0
  117. package/app/utils/order-route-summary.js +1 -0
  118. package/app/utils/place-address-html.js +1 -0
  119. package/app/utils/route-colors.js +1 -1
  120. package/app/utils/route-visualization.js +1 -0
  121. package/assets/images/drawing-controls-spritesheet.svg +156 -0
  122. package/composer.json +1 -1
  123. package/extension.json +1 -1
  124. package/package.json +4 -4
  125. package/pnpm-workspace.yaml +7 -0
  126. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  127. package/server/src/Http/Controllers/Api/v1/OrderController.php +31 -3
  128. package/server/src/Http/Controllers/Api/v1/PayloadController.php +14 -1
  129. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -0
  130. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +3 -2
  131. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -0
  132. package/server/src/Http/Controllers/Internal/v1/LiveController.php +2 -1
  133. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +7 -4
  134. package/server/src/Http/Controllers/Internal/v1/OrderController.php +90 -34
  135. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +3 -2
  136. package/server/src/Http/Controllers/Internal/v1/SettingController.php +106 -3
  137. package/server/src/Http/Filter/OrderFilter.php +2 -1
  138. package/server/src/Http/Resources/v1/Index/Payload.php +9 -7
  139. package/server/src/Http/Resources/v1/Order.php +20 -1
  140. package/server/src/Models/Order.php +42 -1
  141. package/server/src/Models/OrderConfig.php +15 -0
  142. package/server/src/Models/Payload.php +131 -27
  143. package/server/src/Models/Place.php +182 -48
  144. package/server/src/Models/ServiceRate.php +4 -3
  145. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +59 -23
  146. package/server/src/Support/Utils.php +36 -2
  147. package/server/src/routes.php +4 -0
  148. package/translations/en-us.yaml +1937 -1916
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Resources\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Models\Contact as ContactModel;
5
6
  use Fleetbase\FleetOps\Support\Utils;
6
7
  use Fleetbase\Http\Resources\Comment;
7
8
  use Fleetbase\Http\Resources\File;
@@ -56,7 +57,7 @@ class Order extends FleetbaseResource
56
57
  $orderConfigPublicId
57
58
  ),
58
59
  'customer' => $this->whenLoaded('customer', function () {
59
- return $this->setCustomerType($this->transformMorphResource($this->customer));
60
+ return $this->setCustomerType($this->transformOrderCustomerResource($this->customer));
60
61
  }),
61
62
  'payload' => new Payload($this->payload),
62
63
  'facilitator' => $this->whenLoaded('facilitator', function () {
@@ -169,6 +170,24 @@ class Order extends FleetbaseResource
169
170
  return (new \Illuminate\Http\Resources\Json\JsonResource($model))->resolve();
170
171
  }
171
172
 
173
+ /**
174
+ * Transform the order customer resource without nesting the customer's user relationship.
175
+ *
176
+ * @param \Illuminate\Database\Eloquent\Model|null $model
177
+ *
178
+ * @return array|null
179
+ */
180
+ protected function transformOrderCustomerResource($model)
181
+ {
182
+ $resolved = $this->transformMorphResource($model);
183
+
184
+ if ($model instanceof ContactModel && is_array($resolved)) {
185
+ unset($resolved['user']);
186
+ }
187
+
188
+ return $resolved;
189
+ }
190
+
172
191
  /**
173
192
  * Transform the resource into an webhook payload.
174
193
  *
@@ -1732,6 +1732,16 @@ class Order extends Model
1732
1732
 
1733
1733
  if (Str::isUuid($this->order_config_uuid)) {
1734
1734
  $orderConfig = OrderConfig::where('uuid', $this->order_config_uuid)->withTrashed()->first();
1735
+ if ($orderConfig instanceof OrderConfig) {
1736
+ $orderConfig->setOrderContext($this);
1737
+
1738
+ return $orderConfig;
1739
+ }
1740
+ }
1741
+
1742
+ $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
1743
+ $orderConfig = OrderConfig::defaultOrCreate($company);
1744
+ if ($orderConfig instanceof OrderConfig) {
1735
1745
  $orderConfig->setOrderContext($this);
1736
1746
 
1737
1747
  return $orderConfig;
@@ -1740,6 +1750,37 @@ class Order extends Model
1740
1750
  return null;
1741
1751
  }
1742
1752
 
1753
+ /**
1754
+ * Ensure the order has a persisted order config and normalized type.
1755
+ */
1756
+ public function ensureOrderConfig(): ?OrderConfig
1757
+ {
1758
+ $orderConfig = $this->config();
1759
+
1760
+ if (!$orderConfig) {
1761
+ return null;
1762
+ }
1763
+
1764
+ $updates = [];
1765
+
1766
+ if ($this->order_config_uuid !== $orderConfig->uuid) {
1767
+ $updates['order_config_uuid'] = $orderConfig->uuid;
1768
+ }
1769
+
1770
+ if (empty($this->type) || $this->type === 'default' || $this->type !== $orderConfig->key) {
1771
+ $updates['type'] = $orderConfig->key;
1772
+ }
1773
+
1774
+ if (!empty($updates)) {
1775
+ $this->fill($updates);
1776
+ $this->save();
1777
+ }
1778
+
1779
+ $this->setRelation('orderConfig', $orderConfig);
1780
+
1781
+ return $orderConfig;
1782
+ }
1783
+
1743
1784
  /**
1744
1785
  * Retrieves the flow configuration from the order config.
1745
1786
  *
@@ -1753,7 +1794,7 @@ class Order extends Model
1753
1794
  public function getConfigFlow(): array
1754
1795
  {
1755
1796
  $orderConfig = $this->config();
1756
- if (is_array($orderConfig->flow)) {
1797
+ if ($orderConfig && is_array($orderConfig->flow)) {
1757
1798
  return $orderConfig->flow;
1758
1799
  }
1759
1800
 
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Models;
5
5
  use Fleetbase\Casts\Json;
6
6
  use Fleetbase\FleetOps\Casts\OrderConfigEntities;
7
7
  use Fleetbase\FleetOps\Flow\Activity;
8
+ use Fleetbase\FleetOps\Support\FleetOps;
8
9
  use Fleetbase\Models\Company;
9
10
  use Fleetbase\Models\Model;
10
11
  use Fleetbase\Support\Auth;
@@ -498,4 +499,18 @@ class OrderConfig extends Model
498
499
  {
499
500
  return static::where(['namespace' => 'system:order-config:transport', 'company_uuid' => $company ? $company->uuid : session('company')])->first();
500
501
  }
502
+
503
+ /**
504
+ * Get the default transport config, creating it for the company if needed.
505
+ */
506
+ public static function defaultOrCreate(?Company $company = null): ?self
507
+ {
508
+ $company ??= Company::where('uuid', session('company'))->first();
509
+
510
+ if (!$company) {
511
+ return static::default($company);
512
+ }
513
+
514
+ return static::default($company) ?? FleetOps::createTransportConfig($company);
515
+ }
501
516
  }
@@ -137,6 +137,26 @@ class Payload extends Model
137
137
  return $this->hasMany(Waypoint::class)->with(['place']);
138
138
  }
139
139
 
140
+ /**
141
+ * The first waypoint marker for lightweight index fallback.
142
+ */
143
+ public function firstWaypointMarker()
144
+ {
145
+ return $this->hasOne(Waypoint::class, 'payload_uuid', 'uuid')
146
+ ->oldestOfMany('order')
147
+ ->with(['place']);
148
+ }
149
+
150
+ /**
151
+ * The last waypoint marker for lightweight index fallback.
152
+ */
153
+ public function lastWaypointMarker()
154
+ {
155
+ return $this->hasOne(Waypoint::class, 'payload_uuid', 'uuid')
156
+ ->latestOfMany('order')
157
+ ->with(['place']);
158
+ }
159
+
140
160
  public function getTotalEntitiesAttribute()
141
161
  {
142
162
  return $this->entities()->count();
@@ -534,48 +554,104 @@ class Payload extends Model
534
554
  return $this;
535
555
  }
536
556
 
537
- $placeIds = [];
557
+ $this->loadMissing('waypointMarkers');
558
+ $keptWaypointIds = [];
559
+ $availableWaypointMarkers = $this->waypointMarkers()->get();
538
560
 
539
- // collect all place ids to insert
540
561
  foreach ($waypoints as $index => $attributes) {
562
+ $raw = $attributes;
563
+ $type = data_get($raw, 'type', 'dropoff');
564
+ $customerUuidIn = data_get($raw, 'customer_uuid');
565
+ $customerPubIdIn = data_get($raw, 'customer_id');
566
+ $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
567
+
541
568
  if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) {
542
569
  $attributes = Utils::get($attributes, 'place');
543
570
  }
544
571
 
545
- if (is_array($attributes) && array_key_exists('place_uuid', $attributes)) {
546
- $placeIds[] = $attributes['place_uuid'];
572
+ $placeUuid = null;
573
+
574
+ if (
575
+ is_array($attributes)
576
+ && isset($attributes['place_uuid'])
577
+ && Place::where('uuid', $attributes['place_uuid'])->exists()
578
+ ) {
579
+ $placeUuid = $attributes['place_uuid'];
580
+ } elseif (
581
+ is_array($attributes)
582
+ && isset($attributes['uuid'])
583
+ && ($resolvedUuid = Place::where('uuid', $attributes['uuid'])->value('uuid'))
584
+ ) {
585
+ $placeUuid = $resolvedUuid;
586
+ } elseif (
587
+ is_array($attributes)
588
+ && isset($attributes['id'])
589
+ && ($resolvedUuid = Place::where('public_id', $attributes['id'])->value('uuid'))
590
+ ) {
591
+ $placeUuid = $resolvedUuid;
547
592
  } else {
548
- $placeUuid = Place::insertFromMixed($attributes);
549
- $placeIds[] = $placeUuid;
593
+ $place = Place::createFromMixed($attributes);
594
+
595
+ if ($place instanceof Place && isset($attributes['uuid']) && $place->uuid !== $attributes['uuid']) {
596
+ $place->updateMeta('search_uuid', $attributes['uuid']);
597
+ }
598
+
599
+ $placeUuid = $place->uuid;
550
600
  }
551
- }
552
601
 
553
- /** @return \Illuminate\Database\Eloquent\Collection $waypointMakers */
554
- $waypointMakers = $this->waypointMarkers()->get();
602
+ $customerUuid = null;
603
+ $customerTypeNamespace = null;
555
604
 
556
- // remove all waypoints that are not included in the placeids
557
- $waypointMakers = $waypointMakers->filter(function ($waypointMarker) use ($placeIds) {
558
- if (!in_array($waypointMarker->place_uuid, $placeIds)) {
559
- $waypointMarker->delete();
605
+ if ($customerType) {
606
+ $customerTypeNamespace = Utils::getMutationType($customerType);
607
+ $customerModel = app($customerTypeNamespace);
608
+
609
+ if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) {
610
+ $customerUuid = $customerUuidIn;
611
+ }
612
+
613
+ if (!$customerUuid && $customerPubIdIn) {
614
+ $maybeUuid = $customerModel->where('public_id', $customerPubIdIn)->value('uuid');
615
+ if ($maybeUuid) {
616
+ $customerUuid = $maybeUuid;
617
+ } else {
618
+ $customerTypeNamespace = null;
619
+ }
620
+ }
621
+
622
+ if (!$customerUuid) {
623
+ $customerTypeNamespace = null;
624
+ }
560
625
  }
561
626
 
562
- return in_array($waypointMarker->place_uuid, $placeIds);
563
- });
627
+ $values = [
628
+ 'payload_uuid' => $this->uuid,
629
+ 'place_uuid' => $placeUuid,
630
+ 'order' => $index,
631
+ 'type' => $type,
632
+ 'customer_uuid' => $customerUuid,
633
+ 'customer_type' => $customerTypeNamespace,
634
+ ];
635
+
636
+ $existingWaypointMarker = $availableWaypointMarkers
637
+ ->first(fn ($waypointMarker) => $waypointMarker->place_uuid === $placeUuid && !in_array($waypointMarker->uuid, $keptWaypointIds));
638
+
639
+ if ($existingWaypointMarker) {
640
+ $existingWaypointMarker->update($values);
641
+ $keptWaypointIds[] = $existingWaypointMarker->uuid;
642
+ continue;
643
+ }
564
644
 
565
- // update or create waypoint markers
566
- foreach ($placeIds as $placeId) {
567
- Waypoint::updateOrCreate(
568
- [
569
- 'payload_uuid' => $this->uuid,
570
- 'place_uuid' => $placeId,
571
- ],
572
- [
573
- 'payload_uuid' => $this->uuid,
574
- 'place_uuid' => $placeId,
575
- ]
576
- );
645
+ $waypointRecord = Waypoint::create($values);
646
+ $keptWaypointIds[] = $waypointRecord->uuid;
577
647
  }
578
648
 
649
+ $this->waypointMarkers()
650
+ ->whereNotIn('uuid', $keptWaypointIds)
651
+ ->get()
652
+ ->each
653
+ ->delete();
654
+
579
655
  return $this->refresh()->load(['waypoints']);
580
656
  }
581
657
 
@@ -673,6 +749,34 @@ class Payload extends Model
673
749
  return null;
674
750
  }
675
751
 
752
+ /**
753
+ * Get the lightweight pickup fallback for index resources.
754
+ */
755
+ public function getIndexPickupPlaceAttribute(): ?Place
756
+ {
757
+ if ($this->pickup instanceof Place) {
758
+ return $this->pickup;
759
+ }
760
+
761
+ $this->loadMissing('firstWaypointMarker.place');
762
+
763
+ return data_get($this, 'firstWaypointMarker.place');
764
+ }
765
+
766
+ /**
767
+ * Get the lightweight dropoff fallback for index resources.
768
+ */
769
+ public function getIndexDropoffPlaceAttribute(): ?Place
770
+ {
771
+ if ($this->dropoff instanceof Place) {
772
+ return $this->dropoff;
773
+ }
774
+
775
+ $this->loadMissing('lastWaypointMarker.place');
776
+
777
+ return data_get($this, 'lastWaypointMarker.place');
778
+ }
779
+
676
780
  public function getPickupRegion(): string
677
781
  {
678
782
  $pickup = $this->getPickupOrCurrentWaypoint();
@@ -335,24 +335,8 @@ class Place extends Model
335
335
 
336
336
  // Before saving or returning this instance check the database for a duplicate address
337
337
  // it cannot have any owner, and must belong to this session
338
- if ($companyUuid = session('company')) {
339
- $duplicate = static::where([
340
- 'company_uuid' => $companyUuid,
341
- 'street1' => $instance->street1,
342
- 'city' => $instance->city,
343
- 'country' => $instance->country,
344
- ])
345
- ->when(
346
- $instance->postal_code !== null,
347
- fn ($q) => $q->where('postal_code', $instance->postal_code),
348
- fn ($q) => $q->whereNull('postal_code')
349
- )
350
- ->whereNull('owner_uuid')
351
- ->first();
352
-
353
- if ($duplicate) {
354
- return $duplicate;
355
- }
338
+ if ($duplicate = static::findExistingSharedPlace($instance->toArray())) {
339
+ return $duplicate;
356
340
  }
357
341
 
358
342
  if ($saveInstance) {
@@ -400,7 +384,7 @@ class Place extends Model
400
384
  /**
401
385
  * Create a new Place instance from a geocoding lookup.
402
386
  *
403
- * @return \Fleetbase\Models\Place|null
387
+ * @return array
404
388
  */
405
389
  public static function getValuesFromGeocodingLookup(string $address): array
406
390
  {
@@ -413,6 +397,163 @@ class Place extends Model
413
397
  return static::getGoogleAddressArray($results->first());
414
398
  }
415
399
 
400
+ /**
401
+ * Compose a fuller geocoding query from structured place attributes.
402
+ */
403
+ public static function composeGeocodingQuery(array $place): ?string
404
+ {
405
+ $parts = [];
406
+
407
+ foreach (['street1', 'street2', 'city', 'province', 'postal_code', 'country'] as $field) {
408
+ $value = static::normalizePlaceValue(data_get($place, $field));
409
+ if ($value) {
410
+ $parts[] = $value;
411
+ }
412
+ }
413
+
414
+ if (empty($parts)) {
415
+ return null;
416
+ }
417
+
418
+ return implode(', ', array_values(array_unique($parts)));
419
+ }
420
+
421
+ /**
422
+ * Normalize a place field value for matching and persistence.
423
+ */
424
+ public static function normalizePlaceValue($value): ?string
425
+ {
426
+ if (!is_scalar($value)) {
427
+ return null;
428
+ }
429
+
430
+ $value = trim((string) $value);
431
+
432
+ return $value !== '' ? $value : null;
433
+ }
434
+
435
+ /**
436
+ * Merge explicit structured place attributes with geocoded fallbacks.
437
+ * Explicit values always win.
438
+ */
439
+ public static function mergeStructuredPlaceAttributes(array $place, array $geocoded = []): array
440
+ {
441
+ $place = array_merge($geocoded, $place);
442
+
443
+ foreach (['name', 'street1', 'street2', 'city', 'province', 'postal_code', 'country', 'phone'] as $field) {
444
+ $normalized = static::normalizePlaceValue(data_get($place, $field));
445
+ if ($normalized !== null) {
446
+ $place[$field] = $normalized;
447
+ } elseif (array_key_exists($field, $place)) {
448
+ $place[$field] = null;
449
+ }
450
+ }
451
+
452
+ if (!empty($place['location'])) {
453
+ $place['location'] = Utils::getPointFromMixed($place['location']) ?? $place['location'];
454
+ }
455
+
456
+ return array_filter($place, fn ($value) => $value !== '');
457
+ }
458
+
459
+ /**
460
+ * Find an existing shared place using normalized address identity.
461
+ */
462
+ public static function findExistingSharedPlace(array $place): ?Place
463
+ {
464
+ if (data_get($place, 'owner_uuid')) {
465
+ return null;
466
+ }
467
+
468
+ $companyUuid = data_get($place, 'company_uuid', session('company'));
469
+ $street1 = static::normalizePlaceValue(data_get($place, 'street1'));
470
+ $city = static::normalizePlaceValue(data_get($place, 'city'));
471
+ $country = static::normalizePlaceValue(data_get($place, 'country'));
472
+
473
+ if (!$companyUuid || !$street1 || !$city || !$country) {
474
+ return null;
475
+ }
476
+
477
+ $postalCode = static::normalizePlaceValue(data_get($place, 'postal_code'));
478
+ $street2 = static::normalizePlaceValue(data_get($place, 'street2'));
479
+ $location = Utils::getPointFromMixed(data_get($place, 'location'));
480
+
481
+ $existingPlace = static::query()
482
+ ->where('company_uuid', $companyUuid)
483
+ ->whereNull('owner_uuid')
484
+ ->whereNull('deleted_at')
485
+ ->where('street1', $street1)
486
+ ->where('city', $city)
487
+ ->where('country', $country)
488
+ ->when(
489
+ $postalCode !== null,
490
+ fn ($query) => $query->where('postal_code', $postalCode)
491
+ )
492
+ ->when(
493
+ $postalCode === null,
494
+ fn ($query) => $query->whereNull('postal_code')
495
+ )
496
+ ->when(
497
+ $street2 !== null,
498
+ fn ($query) => $query->where('street2', $street2)
499
+ )
500
+ ->first();
501
+
502
+ if ($existingPlace) {
503
+ return $existingPlace;
504
+ }
505
+
506
+ if (!$location instanceof SpatialPoint) {
507
+ return null;
508
+ }
509
+
510
+ return static::query()
511
+ ->where('company_uuid', $companyUuid)
512
+ ->whereNull('owner_uuid')
513
+ ->whereNull('deleted_at')
514
+ ->where('street1', $street1)
515
+ ->where('city', $city)
516
+ ->where('country', $country)
517
+ ->whereRaw('ST_Equals(location, ST_GeomFromText(?))', [
518
+ sprintf('POINT(%s %s)', $location->getLng(), $location->getLat()),
519
+ ])
520
+ ->first();
521
+ }
522
+
523
+ /**
524
+ * Update safe missing fields on an existing shared place.
525
+ */
526
+ public static function hydrateSharedPlace(Place $existingPlace, array $values): Place
527
+ {
528
+ $updates = [];
529
+
530
+ foreach (['name', 'street2', 'province', 'postal_code', 'neighborhood', 'district', 'building', 'phone'] as $field) {
531
+ $current = static::normalizePlaceValue($existingPlace->{$field});
532
+ $next = static::normalizePlaceValue(data_get($values, $field));
533
+
534
+ if ($current === null && $next !== null) {
535
+ $updates[$field] = $next;
536
+ }
537
+ }
538
+
539
+ $existingLocation = Utils::getPointFromMixed($existingPlace->location);
540
+ $location = Utils::getPointFromMixed(data_get($values, 'location'));
541
+ $hasZeroLocation = $existingLocation instanceof SpatialPoint
542
+ && $existingLocation->getLat() == 0.0
543
+ && $existingLocation->getLng() == 0.0;
544
+
545
+ if (($existingLocation === null || $hasZeroLocation) && $location instanceof SpatialPoint) {
546
+ $updates['location'] = $location;
547
+ }
548
+
549
+ if (!empty($updates)) {
550
+ $existingPlace->fill($updates);
551
+ $existingPlace->save();
552
+ }
553
+
554
+ return $existingPlace;
555
+ }
556
+
416
557
  /**
417
558
  * Create a new Place instance from a geocoding lookup.
418
559
  *
@@ -571,31 +712,33 @@ class Place extends Model
571
712
  }
572
713
 
573
714
  // Get public_id if supplied if set
574
- $id = data_get($place, 'id') || data_get($place, 'public_id');
715
+ $id = data_get($place, 'id') ?: data_get($place, 'public_id');
575
716
 
576
717
  // If $place has a valid uuid and a matching Place object exists, return the uuid
577
718
  if (Utils::isPublicId($id) && $existingPlace = static::where('public_id', $id)->first()) {
578
719
  return $existingPlace;
579
720
  }
580
721
 
581
- // If has $attributes['address']
582
- $address = data_get($place, 'address');
583
- if ($address) {
584
- return static::create(array_merge($place, static::getValuesFromGeocodingLookup($address)));
722
+ $query = static::composeGeocodingQuery($place)
723
+ ?? static::normalizePlaceValue(data_get($place, 'address'))
724
+ ?? static::normalizePlaceValue(data_get($place, 'street1'));
725
+
726
+ $geocoded = [];
727
+ if ($query) {
728
+ $geocoded = static::getValuesFromGeocodingLookup($query);
585
729
  }
586
730
 
587
- // Perform google lookup to fill street1
588
- $street1 = data_get($place, 'street1');
589
- if ($street1) {
590
- return static::create(array_merge($place, static::getValuesFromGeocodingLookup($street1)));
731
+ $values = static::mergeStructuredPlaceAttributes($place, $geocoded);
732
+
733
+ if ($existingPlace = static::findExistingSharedPlace($values)) {
734
+ return static::hydrateSharedPlace($existingPlace, $values);
591
735
  }
592
736
 
593
- // Otherwise, create a new Place owith the given attributes
594
- if (empty($place['location'])) {
595
- $place['location'] = new SpatialPoint(0, 0);
737
+ if (empty($values['location'])) {
738
+ $values['location'] = new SpatialPoint(0, 0);
596
739
  }
597
740
 
598
- return static::create($place);
741
+ return static::create($values);
599
742
  }
600
743
  // If $place is a GoogleAddress object
601
744
  elseif ($place instanceof \Geocoder\Provider\GoogleMaps\Model\GoogleAddress) {
@@ -655,7 +798,11 @@ class Place extends Model
655
798
  }
656
799
  }
657
800
 
658
- $values = $place;
801
+ $values = is_array($place) ? $place : (array) $place;
802
+
803
+ if ($existingPlace = static::findExistingSharedPlace($values)) {
804
+ return $existingPlace->uuid;
805
+ }
659
806
 
660
807
  // create a new place
661
808
  return static::insertGetUuid((array) $values);
@@ -703,21 +850,8 @@ class Place extends Model
703
850
  $values['location'] = Utils::parsePointToWkt($values['location']);
704
851
  }
705
852
 
706
- // check if place already exists
707
- $existing = DB::table($instance->getTable())
708
- ->select(['uuid'])->where([
709
- 'company_uuid' => session('company'),
710
- 'name' => $values['name'] ?? null,
711
- 'street1' => $values['street1'] ?? null,
712
- ])
713
- ->whereNull('deleted_at')
714
- ->first();
715
-
716
- if ($existing) {
717
- unset($values['uuid'], $values['created_at'], $values['_key'], $values['company_uuid']);
718
- static::where('uuid', $existing->uuid)->update($values);
719
-
720
- return $existing->uuid;
853
+ if ($existing = static::findExistingSharedPlace($values)) {
854
+ return static::hydrateSharedPlace($existing, $values)->uuid;
721
855
  }
722
856
 
723
857
  if (isset($values['meta']) && (is_object($values['meta']) || is_array($values['meta']))) {
@@ -229,7 +229,7 @@ class ServiceRate extends Model
229
229
  */
230
230
  public function isAlgorithm(): bool
231
231
  {
232
- return $this->rate_calculation_method === 'algo';
232
+ return $this->rate_calculation_method === 'algo' || $this->rate_calculation_method === 'algorithm';
233
233
  }
234
234
 
235
235
  /**
@@ -677,7 +677,7 @@ class ServiceRate extends Model
677
677
  *
678
678
  * @return array an array containing the calculated quote and line items
679
679
  */
680
- public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $totalDistance = 0, ?int $totalTime = 0, ?bool $isCashOnDelivery = false)
680
+ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $totalDistance = 0, ?int $totalTime = 0, ?bool $isCashOnDelivery = false, ?int $endpointCount = null)
681
681
  {
682
682
  $lines = collect();
683
683
  $subTotal = data_get($this, 'base_fee', 0);
@@ -739,6 +739,7 @@ class ServiceRate extends Model
739
739
  }
740
740
 
741
741
  if ($this->isAlgorithm()) {
742
+ $resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
742
743
  $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
743
744
  $this->algorithm,
744
745
  $this->buildAlgorithmVariables(
@@ -746,7 +747,7 @@ class ServiceRate extends Model
746
747
  $waypoints,
747
748
  $totalDistance,
748
749
  $totalTime,
749
- $this->inferEndpointCountFromStops($waypoints)
750
+ $resolvedEndpointCount
750
751
  ),
751
752
  true
752
753
  ));