@fleetbase/fleetops-engine 0.6.44 → 0.6.46

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 (172) 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/entity/form.hbs +24 -0
  10. package/addon/components/entity/form.js +30 -0
  11. package/addon/components/layout/fleet-ops-sidebar/driver-listing.js +5 -5
  12. package/addon/components/layout/fleet-ops-sidebar/fleet-listing.js +5 -5
  13. package/addon/components/layout/fleet-ops-sidebar.js +8 -0
  14. package/addon/components/leaflet-draw-control.js +1 -1
  15. package/addon/components/maintenance-schedule/form.js +2 -1
  16. package/addon/components/map/drawer/driver-listing.js +5 -5
  17. package/addon/components/map/drawer/geofence-event-listing.js +5 -2
  18. package/addon/components/map/drawer/place-listing.js +5 -5
  19. package/addon/components/map/drawer/position-listing.js +102 -21
  20. package/addon/components/map/drawer/vehicle-listing.js +5 -5
  21. package/addon/components/map/google-live-map.hbs +9 -0
  22. package/addon/components/map/google-live-map.js +445 -0
  23. package/addon/components/map/leaflet-live-map.hbs +186 -142
  24. package/addon/components/map/leaflet-live-map.js +121 -41
  25. package/addon/components/map/toolbar/visibility-control-panel.js +2 -2
  26. package/addon/components/map/toolbar/zones-panel.hbs +9 -9
  27. package/addon/components/map/toolbar.hbs +3 -3
  28. package/addon/components/map/toolbar.js +9 -1
  29. package/addon/components/modals/map-layer-form.hbs +132 -63
  30. package/addon/components/modals/orchestrator-import.hbs +4 -11
  31. package/addon/components/modals/orchestrator-import.js +17 -1
  32. package/addon/components/modals/update-order-activity.hbs +1 -1
  33. package/addon/components/orchestrator/order-pool.js +3 -2
  34. package/addon/components/orchestrator-workbench.js +29 -41
  35. package/addon/components/order/details/detail.js +2 -2
  36. package/addon/components/order/details/payload.hbs +5 -5
  37. package/addon/components/order/details/payload.js +18 -1
  38. package/addon/components/order/details/route.hbs +2 -2
  39. package/addon/components/order/details/route.js +76 -3
  40. package/addon/components/order/form/payload.hbs +33 -0
  41. package/addon/components/order/form/payload.js +1 -0
  42. package/addon/components/order/form/route.hbs +8 -8
  43. package/addon/components/order/form/route.js +78 -18
  44. package/addon/components/order/form/service-rate.hbs +1 -1
  45. package/addon/components/order/form.js +2 -2
  46. package/addon/components/order/route-editor.hbs +209 -147
  47. package/addon/components/order/route-editor.js +143 -102
  48. package/addon/components/place/address.hbs +1 -0
  49. package/addon/components/place/address.js +12 -0
  50. package/addon/components/route-list.hbs +43 -55
  51. package/addon/components/route-list.js +47 -0
  52. package/addon/components/route-optimization-wizard-panel.js +3 -4
  53. package/addon/components/service-rate/details.hbs +6 -6
  54. package/addon/components/service-rate/form.hbs +28 -15
  55. package/addon/components/vehicle/card.hbs +4 -4
  56. package/addon/controllers/maintenance/schedules/index.js +2 -1
  57. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  58. package/addon/controllers/operations/orders/index/details.js +113 -15
  59. package/addon/controllers/operations/orders/index/new.js +1 -1
  60. package/addon/controllers/operations/orders/index.js +8 -1
  61. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  62. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  63. package/addon/controllers/settings/map.js +97 -0
  64. package/addon/controllers/settings/routing.js +47 -12
  65. package/addon/extension.js +11 -0
  66. package/addon/helpers/f-to-int.js +6 -0
  67. package/addon/helpers/place-address-html.js +6 -0
  68. package/addon/instance-initializers/register-google-routes.js +17 -0
  69. package/addon/instance-initializers/register-osrm.js +5 -0
  70. package/addon/routes/application.js +9 -3
  71. package/addon/routes/operations/orchestrator.js +13 -0
  72. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  73. package/addon/routes/operations/orders/index/details.js +47 -24
  74. package/addon/routes/operations/orders/index/new.js +9 -0
  75. package/addon/routes/operations/orders/index.js +0 -1
  76. package/addon/routes/settings/map.js +3 -0
  77. package/addon/routes.js +1 -0
  78. package/addon/services/entity-actions.js +1 -1
  79. package/addon/services/equipment-actions.js +4 -6
  80. package/addon/services/geofence-event-bus.js +2 -1
  81. package/addon/services/geofence.js +204 -168
  82. package/addon/services/google-routes.js +300 -0
  83. package/addon/services/leaflet-draw-restriction.js +2 -1
  84. package/addon/services/leaflet-map-manager.js +21 -0
  85. package/addon/services/maintenance-actions.js +4 -6
  86. package/addon/services/map-adapter/google.js +2205 -0
  87. package/addon/services/map-adapter/leaflet.js +1042 -0
  88. package/addon/services/map-adapter-interface.js +551 -0
  89. package/addon/services/map-manager.js +768 -0
  90. package/addon/services/map-settings.js +75 -0
  91. package/addon/services/movement-tracker.js +83 -32
  92. package/addon/services/order-actions.js +11 -1
  93. package/addon/services/osrm.js +79 -6
  94. package/addon/services/part-actions.js +4 -6
  95. package/addon/services/position-playback.js +135 -62
  96. package/addon/services/route-engine.js +83 -0
  97. package/addon/services/service-areas.js +8 -1
  98. package/addon/services/service-rate-actions.js +8 -3
  99. package/addon/styles/fleetops-engine.css +555 -0
  100. package/addon/templates/management/vehicles/index.hbs +0 -1
  101. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  102. package/addon/templates/operations/orders/index/details.hbs +0 -1
  103. package/addon/templates/settings/map.hbs +53 -0
  104. package/addon/templates/settings/routing.hbs +15 -4
  105. package/addon/utils/fleet-ops-options.js +34 -0
  106. package/addon/utils/has-valid-coordinates.js +6 -0
  107. package/addon/utils/map-provider-options.js +6 -0
  108. package/addon/utils/order-route-editing.js +86 -0
  109. package/addon/utils/order-route-summary.js +20 -0
  110. package/addon/utils/place-address-html.js +41 -0
  111. package/addon/utils/route-colors.js +43 -12
  112. package/addon/utils/route-visualization.js +93 -0
  113. package/addon/utils/serialize-payload.js +1 -1
  114. package/addon/utils/vendor-integration.js +2 -1
  115. package/app/components/admin/map-settings.js +1 -0
  116. package/app/components/admin/routing-settings.js +1 -0
  117. package/app/components/cell/order-route-type.js +1 -0
  118. package/app/components/cell/place-address.js +1 -0
  119. package/app/components/map/google-live-map.js +1 -0
  120. package/app/components/place/address.js +1 -0
  121. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  122. package/app/controllers/settings/map.js +1 -0
  123. package/app/helpers/f-to-int.js +1 -0
  124. package/app/helpers/place-address-html.js +1 -0
  125. package/app/routes/settings/map.js +1 -0
  126. package/app/services/map-adapter/google.js +1 -0
  127. package/app/services/map-adapter/leaflet.js +1 -0
  128. package/app/services/map-adapter-interface.js +1 -0
  129. package/app/services/map-manager.js +1 -0
  130. package/app/services/map-settings.js +1 -0
  131. package/app/templates/settings/map.js +1 -0
  132. package/app/utils/has-valid-coordinates.js +1 -0
  133. package/app/utils/order-route-editing.js +2 -0
  134. package/app/utils/order-route-summary.js +1 -0
  135. package/app/utils/place-address-html.js +1 -0
  136. package/app/utils/route-colors.js +1 -1
  137. package/app/utils/route-visualization.js +1 -0
  138. package/assets/images/drawing-controls-spritesheet.svg +156 -0
  139. package/composer.json +1 -1
  140. package/extension.json +1 -1
  141. package/package.json +3 -3
  142. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  143. package/server/src/Http/Controllers/Api/v1/OrderController.php +36 -8
  144. package/server/src/Http/Controllers/Api/v1/PayloadController.php +14 -1
  145. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  146. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -0
  147. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +7 -2
  148. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  149. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -0
  150. package/server/src/Http/Controllers/Internal/v1/LiveController.php +2 -1
  151. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +7 -4
  152. package/server/src/Http/Controllers/Internal/v1/OrderController.php +61 -34
  153. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +3 -2
  154. package/server/src/Http/Controllers/Internal/v1/SettingController.php +106 -3
  155. package/server/src/Http/Filter/OrderFilter.php +2 -1
  156. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  157. package/server/src/Http/Resources/v1/Index/Payload.php +9 -7
  158. package/server/src/Http/Resources/v1/Order.php +20 -2
  159. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  160. package/server/src/Models/Entity.php +7 -0
  161. package/server/src/Models/Order.php +81 -8
  162. package/server/src/Models/OrderConfig.php +15 -0
  163. package/server/src/Models/Payload.php +131 -27
  164. package/server/src/Models/Place.php +182 -48
  165. package/server/src/Models/ServiceRate.php +125 -32
  166. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  167. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +59 -23
  168. package/server/src/Rules/ComputableAlgo.php +1 -3
  169. package/server/src/Support/Algo.php +87 -1
  170. package/server/src/Support/Utils.php +69 -0
  171. package/server/src/routes.php +4 -0
  172. package/translations/en-us.yaml +1937 -1916
@@ -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
  /**
@@ -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
  }
@@ -640,7 +677,7 @@ class ServiceRate extends Model
640
677
  *
641
678
  * @return array an array containing the calculated quote and line items
642
679
  */
643
- 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)
644
681
  {
645
682
  $lines = collect();
646
683
  $subTotal = data_get($this, 'base_fee', 0);
@@ -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,24 @@ class ServiceRate extends Model
702
739
  }
703
740
 
704
741
  if ($this->isAlgorithm()) {
705
- $rateFee = Algo::exec(
742
+ $resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
743
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
706
744
  $this->algorithm,
707
- [
708
- 'distance' => $totalDistance,
709
- 'time' => $totalTime,
710
- ],
745
+ $this->buildAlgorithmVariables(
746
+ $entities,
747
+ $waypoints,
748
+ $totalDistance,
749
+ $totalTime,
750
+ $resolvedEndpointCount
751
+ ),
711
752
  true
712
- );
753
+ ));
713
754
 
714
- $subTotal += Utils::numbersOnly($rateFee);
755
+ $subTotal += $rateFee;
715
756
 
716
757
  $lines->push([
717
758
  'details' => 'Service Fee',
718
- 'amount' => Utils::numbersOnly($rateFee),
759
+ 'amount' => $rateFee,
719
760
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
720
761
  'currency' => $this->currency,
721
762
  'code' => 'BASE_FEE',
@@ -758,9 +799,10 @@ class ServiceRate extends Model
758
799
  }
759
800
 
760
801
  $subTotal += $serviceParcelFee->fee;
802
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
761
803
 
762
804
  $lines->push([
763
- 'details' => $serviceParcelFee->name . ' parcel fee',
805
+ 'details' => $parcelFeeName . ' parcel fee',
764
806
  'amount' => Utils::numbersOnly($serviceParcelFee->fee),
765
807
  'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
766
808
  'currency' => $this->currency,
@@ -777,7 +819,7 @@ class ServiceRate extends Model
777
819
  if ($this->hasCodFlatFee()) {
778
820
  $subTotal += $codFee = $this->cod_flat_fee;
779
821
  } elseif ($this->hasCodPercentageFee()) {
780
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
822
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
781
823
  }
782
824
 
783
825
  $lines->push([
@@ -794,7 +836,7 @@ class ServiceRate extends Model
794
836
  if ($this->hasPeakHoursFlatFee()) {
795
837
  $subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
796
838
  } elseif ($this->hasPeakHoursPercentageFee()) {
797
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
839
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
798
840
  }
799
841
 
800
842
  $lines->push([
@@ -872,13 +914,13 @@ class ServiceRate extends Model
872
914
  }
873
915
 
874
916
  if ($this->isPerMeter()) {
875
- $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance;
876
- $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee;
917
+ $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit);
918
+ $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee);
877
919
  $subTotal += $rateFee;
878
920
 
879
921
  $lines->push([
880
922
  'details' => 'Service Fee',
881
- 'amount' => Utils::numbersOnly($rateFee),
923
+ 'amount' => $rateFee,
882
924
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
883
925
  'currency' => $this->currency,
884
926
  'code' => 'BASE_FEE',
@@ -886,20 +928,23 @@ class ServiceRate extends Model
886
928
  }
887
929
 
888
930
  if ($this->isAlgorithm()) {
889
- $rateFee = Algo::exec(
931
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
890
932
  $this->algorithm,
891
- [
892
- 'distance' => $totalDistance,
893
- 'time' => $totalTime,
894
- ],
933
+ $this->buildAlgorithmVariables(
934
+ $payload->entities->all(),
935
+ $waypoints->all(),
936
+ $totalDistance,
937
+ $totalTime,
938
+ (int) ($payload->pickup ? 1 : 0) + (int) ($payload->dropoff ? 1 : 0)
939
+ ),
895
940
  true
896
- );
941
+ ));
897
942
 
898
- $subTotal += Utils::numbersOnly($rateFee);
943
+ $subTotal += $rateFee;
899
944
 
900
945
  $lines->push([
901
946
  'details' => 'Service Fee',
902
- 'amount' => Utils::numbersOnly($rateFee),
947
+ 'amount' => $rateFee,
903
948
  'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
904
949
  'currency' => $this->currency,
905
950
  'code' => 'BASE_FEE',
@@ -942,9 +987,10 @@ class ServiceRate extends Model
942
987
  }
943
988
 
944
989
  $subTotal += $serviceParcelFee->fee;
990
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
945
991
 
946
992
  $lines->push([
947
- 'details' => $serviceParcelFee->name . ' parcel fee',
993
+ 'details' => $parcelFeeName . ' parcel fee',
948
994
  'amount' => Utils::numbersOnly($serviceParcelFee->fee),
949
995
  'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
950
996
  'currency' => $this->currency,
@@ -961,7 +1007,7 @@ class ServiceRate extends Model
961
1007
  if ($this->hasCodFlatFee()) {
962
1008
  $subTotal += $codFee = $this->cod_flat_fee;
963
1009
  } elseif ($this->hasCodPercentageFee()) {
964
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
1010
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
965
1011
  }
966
1012
 
967
1013
  $lines->push([
@@ -978,7 +1024,7 @@ class ServiceRate extends Model
978
1024
  if ($this->hasPeakHoursFlatFee()) {
979
1025
  $subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
980
1026
  } elseif ($this->hasPeakHoursPercentageFee()) {
981
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
1027
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
982
1028
  }
983
1029
 
984
1030
  $lines->push([
@@ -993,6 +1039,53 @@ class ServiceRate extends Model
993
1039
  return [$subTotal, $lines];
994
1040
  }
995
1041
 
1042
+ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string $unit = 'm'): float|int
1043
+ {
1044
+ $distanceInMeters = (float) ($distanceInMeters ?? 0);
1045
+
1046
+ return match ($unit) {
1047
+ 'km' => $distanceInMeters / 1000,
1048
+ 'ft' => $distanceInMeters / 0.3048,
1049
+ 'yd' => $distanceInMeters / 0.9144,
1050
+ 'mi' => $distanceInMeters / 1609.344,
1051
+ default => $distanceInMeters,
1052
+ };
1053
+ }
1054
+
1055
+ protected function normalizeCalculatedMoney($amount = 0): int
1056
+ {
1057
+ return (int) round((float) ($amount ?? 0));
1058
+ }
1059
+
1060
+ protected function buildAlgorithmVariables($entities = [], $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array
1061
+ {
1062
+ $entityCollection = collect($entities)->filter();
1063
+ $stopCount = collect($stops)->filter()->count();
1064
+ $endpointCount = max(0, min($endpointCount, $stopCount));
1065
+ $waypointCount = max($stopCount - $endpointCount, 0);
1066
+
1067
+ return Algo::normalizeVariables([
1068
+ 'distance_m' => $totalDistance ?? 0,
1069
+ 'time_s' => $totalTime ?? 0,
1070
+ 'stops' => $stopCount,
1071
+ 'waypoints' => $waypointCount,
1072
+ 'parcels' => $entityCollection->where('type', 'parcel')->count(),
1073
+ 'entities' => $entityCollection->count(),
1074
+ 'base_fee' => Utils::numbersOnly($this->base_fee ?? 0),
1075
+ ]);
1076
+ }
1077
+
1078
+ protected function inferEndpointCountFromStops($stops = []): int
1079
+ {
1080
+ $stopCount = collect($stops)->filter()->count();
1081
+
1082
+ if ($stopCount <= 1) {
1083
+ return $stopCount;
1084
+ }
1085
+
1086
+ return 2;
1087
+ }
1088
+
996
1089
  /**
997
1090
  * Find the ServiceRateFee based on the total distance.
998
1091
  *
@@ -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
  }