From c4baa6ad23c0e61cc615a50dbfdff987e9fa33b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 12:48:25 +0800 Subject: [PATCH 01/11] feat: implement repricing and service rate fixes --- .../routes/operations/orders/index/details.js | 22 +++++- .../Controllers/Api/v1/OrderController.php | 10 +-- .../Api/v1/PurchaseRateController.php | 14 +++- .../Api/v1/ServiceRateController.php | 7 +- .../Requests/CreateServiceRateRequest.php | 4 +- server/src/Http/Resources/v1/Order.php | 1 - server/src/Models/Order.php | 44 +++++++++-- server/src/Models/ServiceRate.php | 43 +++++++--- server/src/Observers/PurchaseRateObserver.php | 5 +- server/src/Rules/ComputableAlgo.php | 4 +- server/src/Support/Algo.php | 74 ++++++++++++++++++ .../operations/orders/index/details-test.js | 78 +++++++++++++++++++ 12 files changed, 268 insertions(+), 38 deletions(-) diff --git a/addon/routes/operations/orders/index/details.js b/addon/routes/operations/orders/index/details.js index 210b9922..49c72aca 100644 --- a/addon/routes/operations/orders/index/details.js +++ b/addon/routes/operations/orders/index/details.js @@ -15,9 +15,12 @@ export default class OperationsOrdersIndexDetailsRoute extends Route { @action willTransition(transition) { const fromName = transition.from?.name; const toName = transition.to?.name; + const orderDetailsRoute = 'console.fleet-ops.operations.orders.index.details'; - // only cleanup when actually leaving this route (not intra-route changes) - if (fromName && fromName !== toName) { + // only cleanup when leaving the order details route tree entirely + const isLeavingOrderDetails = fromName?.startsWith(orderDetailsRoute) && !toName?.startsWith(orderDetailsRoute); + + if (isLeavingOrderDetails) { const controller = this.controllerFor('operations.orders.index.details'); const rc = controller.routingControl; @@ -55,7 +58,20 @@ export default class OperationsOrdersIndexDetailsRoute extends Route { return this.store.queryRecord('order', { public_id, single: true, - with: ['payload', 'driverAssigned', 'orderConfig', 'customer', 'facilitator', 'trackingStatuses', 'trackingNumber', 'purchaseRate', 'comments', 'files'], + with: [ + 'payload', + 'driverAssigned', + 'orderConfig', + 'customer', + 'facilitator', + 'trackingStatuses', + 'trackingNumber', + 'purchaseRate', + 'purchaseRate.serviceQuote', + 'purchaseRate.serviceQuote.items', + 'comments', + 'files', + ], }); } diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 4ea96be1..5ce7ddf9 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -321,7 +321,7 @@ public function create(CreateOrderRequest $request) } // load required relations - $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']); + $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); // Determine if order should be dispatched on creation $shouldDispatch = $request->boolean('dispatch') && $integratedVendorOrder === null; @@ -364,7 +364,7 @@ public function update($id, UpdateOrderRequest $request) // find for the order try { - $order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate', 'customer', 'facilitator']); + $order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); } catch (ModelNotFoundException $exception) { return response()->json( [ @@ -549,7 +549,7 @@ public function update($id, UpdateOrderRequest $request) $order->flushAttributesCache(); // load required relations - $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']); + $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); // response the order resource return new OrderResource($order); @@ -759,7 +759,7 @@ public function find($id, Request $request) { // find for the order try { - $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']); + $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); } catch (ModelNotFoundException $exception) { return response()->json( [ @@ -839,7 +839,7 @@ public function getDistanceMatrix(string $id) public function dispatchOrder(string $id) { try { - $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']); + $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); } catch (ModelNotFoundException $exception) { return response()->json( [ diff --git a/server/src/Http/Controllers/Api/v1/PurchaseRateController.php b/server/src/Http/Controllers/Api/v1/PurchaseRateController.php index da67cd71..521580f7 100644 --- a/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +++ b/server/src/Http/Controllers/Api/v1/PurchaseRateController.php @@ -41,10 +41,19 @@ public function create(CreatePurchaseRateRequest $request) // order assignment if ($request->has('order')) { - $input['order_uuid'] = Utils::getUuid('orders', [ + $orderUuid = Utils::getUuid('orders', [ 'public_id' => $request->input('order'), 'company_uuid' => session('company'), ]); + + $order = Order::where('uuid', $orderUuid)->first(); + + if ($order instanceof Order) { + $input['payload_uuid'] = $order->payload_uuid; + $input['customer_uuid'] = $order->customer_uuid; + $input['customer_type'] = $order->customer_type; + $input['company_uuid'] = $order->company_uuid ?? $input['company_uuid']; + } } elseif ($createOrder) { // create order from service quote $serviceQuote = ServiceQuote::where('uuid', $input['service_quote_uuid'])->orWhere('public_id', $request->input('service_quote'))->first(); @@ -70,9 +79,8 @@ public function create(CreatePurchaseRateRequest $request) // create the purchaseRate $purchaseRate = PurchaseRate::create($input); - // set purchase rate to order if ($order instanceof Order) { - $order->update(['purchase_rate_uuid' => $purchaseRate->uuid]); + $order->attachPurchaseRate($purchaseRate); } // response the driver resource diff --git a/server/src/Http/Controllers/Api/v1/ServiceRateController.php b/server/src/Http/Controllers/Api/v1/ServiceRateController.php index 48aa0886..7e0fee4f 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceRateController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceRateController.php @@ -30,6 +30,8 @@ public function create(CreateServiceRateRequest $request) 'rate_calculation_method', 'currency', 'base_fee', + 'max_distance_unit', + 'max_distance', 'per_meter_unit', 'per_meter_flat_rate_fee', 'meter_fees', @@ -119,7 +121,10 @@ public function update($id, UpdateServiceRateRequest $request) 'rate_calculation_method', 'currency', 'base_fee', - 'per_km_flat_rate_fee', + 'max_distance_unit', + 'max_distance', + 'per_meter_unit', + 'per_meter_flat_rate_fee', 'meter_fees', 'meter_fees.*.distance', 'meter_fees.*.fee', diff --git a/server/src/Http/Requests/CreateServiceRateRequest.php b/server/src/Http/Requests/CreateServiceRateRequest.php index 9f5a28b0..2dd58cbb 100644 --- a/server/src/Http/Requests/CreateServiceRateRequest.php +++ b/server/src/Http/Requests/CreateServiceRateRequest.php @@ -31,10 +31,10 @@ public function rules() 'service_type' => [Rule::requiredIf($this->isMethod('POST')), 'string'], 'service_area' => [Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')], 'zone' => [Rule::exists('zones', 'public_id')->whereNull('deleted_at')], - 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo'], + 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo,parcel'], 'currency' => ['required', 'size:3'], 'base_fee' => ['numeric'], - 'per_meter_unit' => ['required_if:rate_calculation_method,per_meter', 'string', 'in:km,m'], + 'per_meter_unit' => ['required_if:rate_calculation_method,per_meter', 'string', 'in:km,m,ft,yd,mi'], 'per_meter_flat_rate_fee' => ['required_if:rate_calculation_method,per_meter', 'numeric'], 'meter_fees' => [Rule::requiredIf(function () { return in_array($this->input('rate_calculation_method'), ['fixed_meter', 'fixed_rate']); diff --git a/server/src/Http/Resources/v1/Order.php b/server/src/Http/Resources/v1/Order.php index 7985e60e..853db2e7 100644 --- a/server/src/Http/Resources/v1/Order.php +++ b/server/src/Http/Resources/v1/Order.php @@ -45,7 +45,6 @@ public function toArray($request): array 'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid), 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid), 'vehicle_assigned_uuid'=> $this->when($isInternal, $this->vehicle_assigned_uuid), - 'service_quote_uuid' => $this->when($isInternal, $this->service_quote_uuid), 'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned), 'is_scheduled' => $this->when($isInternal, $this->is_scheduled), 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid), diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 1fc75503..ee1743a3 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1055,9 +1055,7 @@ public function purchaseQuote(string $serviceQuoteId, $meta = []) 'meta' => $meta, ]); - $this->purchase_rate_uuid = $purchasedRate->uuid; - - return $this->save(); + return $this->attachPurchaseRate($purchasedRate); } /** @@ -1096,14 +1094,48 @@ public function purchaseServiceQuote($serviceQuote, $meta = []) 'meta' => $meta, ]); - return $this->updateQuietly([ - 'purchase_rate_uuid' => $purchasedRate->uuid, - ]); + return $this->attachPurchaseRate($purchasedRate); } return false; } + /** + * Attach a purchase rate to the order and void any superseded transaction. + */ + public function attachPurchaseRate(PurchaseRate $purchaseRate): bool + { + $oldTransaction = $this->transaction_uuid + ? Transaction::where('uuid', $this->transaction_uuid)->first() + : null; + + $attached = (bool) $this->updateQuietly([ + 'purchase_rate_uuid' => $purchaseRate->uuid, + 'transaction_uuid' => $purchaseRate->transaction_uuid, + ]); + + if ($attached) { + $this->setRelation('purchaseRate', $purchaseRate); + + if ($purchaseRate->transaction_uuid) { + $this->setRelation('transaction', Transaction::where('uuid', $purchaseRate->transaction_uuid)->first()); + } + + if ( + $oldTransaction instanceof Transaction + && $oldTransaction->uuid !== $purchaseRate->transaction_uuid + && !$oldTransaction->trashed() + ) { + $oldTransaction->update([ + 'status' => Transaction::STATUS_VOIDED, + 'voided_at' => now(), + ]); + } + } + + return $attached; + } + /** * Creates a transaction for the order without a service quote. * This method is used when an order is made without selecting a specific service quote. diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index 4e7053d8..e95b769d 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -688,7 +688,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ } if ($this->isPerMeter()) { - $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance; + $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit); $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee; $subTotal += $rateFee; @@ -704,10 +704,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->isAlgorithm()) { $rateFee = Algo::exec( $this->algorithm, - [ - 'distance' => $totalDistance, - 'time' => $totalTime, - ], + $this->buildAlgorithmVariables($entities, $waypoints, $totalDistance, $totalTime), true ); @@ -872,7 +869,7 @@ public function quote(Payload $payload) } if ($this->isPerMeter()) { - $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance; + $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit); $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee; $subTotal += $rateFee; @@ -888,10 +885,7 @@ public function quote(Payload $payload) if ($this->isAlgorithm()) { $rateFee = Algo::exec( $this->algorithm, - [ - 'distance' => $totalDistance, - 'time' => $totalTime, - ], + $this->buildAlgorithmVariables($payload->entities->all(), $waypoints->all(), $totalDistance, $totalTime), true ); @@ -993,6 +987,35 @@ public function quote(Payload $payload) return [$subTotal, $lines]; } + protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string $unit = 'm'): float|int + { + $distanceInMeters = (float) ($distanceInMeters ?? 0); + + return match ($unit) { + 'km' => $distanceInMeters / 1000, + 'ft' => $distanceInMeters / 0.3048, + 'yd' => $distanceInMeters / 0.9144, + 'mi' => $distanceInMeters / 1609.344, + default => $distanceInMeters, + }; + } + + protected function buildAlgorithmVariables(array $entities = [], array $waypoints = [], ?int $totalDistance = 0, ?int $totalTime = 0): array + { + $entityCollection = collect($entities)->filter(); + $stopCount = collect($waypoints)->filter()->count(); + + return Algo::normalizeVariables([ + 'distance_m' => $totalDistance ?? 0, + 'time_s' => $totalTime ?? 0, + 'stops' => $stopCount, + 'waypoints' => $stopCount, + 'parcels' => $entityCollection->where('type', 'parcel')->count(), + 'entities' => $entityCollection->count(), + 'base_fee' => Utils::numbersOnly($this->base_fee ?? 0), + ]); + } + /** * Find the ServiceRateFee based on the total distance. * diff --git a/server/src/Observers/PurchaseRateObserver.php b/server/src/Observers/PurchaseRateObserver.php index d89526a7..d0703928 100644 --- a/server/src/Observers/PurchaseRateObserver.php +++ b/server/src/Observers/PurchaseRateObserver.php @@ -2,7 +2,6 @@ namespace Fleetbase\FleetOps\Observers; -use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\PurchaseRate; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Models\Company; @@ -41,9 +40,6 @@ public function creating(PurchaseRate $purchaseRate) 'status' => 'success', ]); - // Update order with transaction id - Order::where('payload_uuid', $purchaseRate->payload_uuid)->update(['transaction_uuid' => $transaction->uuid]); - if (isset($purchaseRate->serviceQuote)) { $purchaseRate->serviceQuote->items->each(function ($serviceQuoteItem) use ($transaction, $currency) { TransactionItem::create([ @@ -57,5 +53,6 @@ public function creating(PurchaseRate $purchaseRate) } $purchaseRate->transaction_uuid = $transaction->uuid; + $purchaseRate->status = $purchaseRate->status ?: Transaction::STATUS_SUCCESS; } } diff --git a/server/src/Rules/ComputableAlgo.php b/server/src/Rules/ComputableAlgo.php index 25e8207c..d743bed8 100644 --- a/server/src/Rules/ComputableAlgo.php +++ b/server/src/Rules/ComputableAlgo.php @@ -16,9 +16,7 @@ class ComputableAlgo implements Rule */ public function passes($attribute, $value) { - $distanceAndTime = Algo::calculateDrivingDistanceAndTime('1.3506853', '103.87199110000006', '1.3621663', '103.88450490000002'); - - return Algo::exec($value, $distanceAndTime) > 0; + return Algo::isComputable($value); } /** diff --git a/server/src/Support/Algo.php b/server/src/Support/Algo.php index 83c91d83..f1230214 100644 --- a/server/src/Support/Algo.php +++ b/server/src/Support/Algo.php @@ -6,6 +6,8 @@ class Algo { + protected const FUNCTION_PATTERN = '/\b(max|min|ceil|floor|round)\(([^()]*)\)/'; + /** * Execute an algorithm strig. * @@ -16,16 +18,88 @@ public static function exec($algorithm, $variables = [], $round = false) $m = new EvalMath(); $m->suppress_errors = true; + $variables = static::normalizeVariables($variables); + foreach ($variables as $key => $value) { $algorithm = str_replace('{' . $key . '}', $value, $algorithm); } + $algorithm = static::evaluateFunctions($algorithm); $result = $m->evaluate($algorithm); + if ($result === false || $result === null || !is_numeric($result)) { + return null; + } + if ($round) { return round($result, 2); // precision 2 cuz most likely dealing with $ } return $result; } + + public static function isComputable($algorithm): bool + { + $result = static::exec($algorithm, static::validationFixture(), true); + + return $result !== null && is_numeric($result); + } + + public static function normalizeVariables(array $variables = []): array + { + $distanceM = (float) ($variables['distance_m'] ?? $variables['distance'] ?? 0); + $timeS = (float) ($variables['time_s'] ?? $variables['time'] ?? 0); + + $normalized = [ + 'distance_m' => $distanceM, + 'distance' => $distanceM, + 'distance_km'=> $distanceM / 1000, + 'distance_mi'=> $distanceM / 1609.344, + 'time_s' => $timeS, + 'time' => $timeS, + 'time_min' => $timeS / 60, + 'stops' => (int) ($variables['stops'] ?? 0), + 'waypoints' => (int) ($variables['waypoints'] ?? 0), + 'parcels' => (int) ($variables['parcels'] ?? 0), + 'entities' => (int) ($variables['entities'] ?? 0), + 'base_fee' => (float) ($variables['base_fee'] ?? 0), + ]; + + return array_merge($normalized, $variables); + } + + protected static function validationFixture(): array + { + return static::normalizeVariables([ + 'distance_m' => 25000, + 'time_s' => 5400, + 'stops' => 4, + 'waypoints' => 2, + 'parcels' => 3, + 'entities' => 5, + 'base_fee' => 100, + ]); + } + + protected static function evaluateFunctions(string $algorithm): string + { + while (preg_match(static::FUNCTION_PATTERN, $algorithm)) { + $algorithm = preg_replace_callback(static::FUNCTION_PATTERN, function ($matches) { + $function = $matches[1]; + $args = array_map('trim', explode(',', $matches[2])); + $numbers = array_map(fn ($arg) => (float) $arg, $args); + + return match ($function) { + 'max' => (string) max($numbers[0] ?? 0, $numbers[1] ?? 0), + 'min' => (string) min($numbers[0] ?? 0, $numbers[1] ?? 0), + 'ceil' => (string) ceil($numbers[0] ?? 0), + 'floor' => (string) floor($numbers[0] ?? 0), + 'round' => (string) round($numbers[0] ?? 0, (int) ($numbers[1] ?? 0)), + default => $matches[0], + }; + }, $algorithm); + } + + return $algorithm; + } } diff --git a/tests/unit/routes/operations/orders/index/details-test.js b/tests/unit/routes/operations/orders/index/details-test.js index 326f405e..748af817 100644 --- a/tests/unit/routes/operations/orders/index/details-test.js +++ b/tests/unit/routes/operations/orders/index/details-test.js @@ -8,4 +8,82 @@ module('Unit | Route | operations/orders/index/details', function (hooks) { let route = this.owner.lookup('route:operations/orders/index/details'); assert.ok(route); }); + + test('willTransition does not cleanup when switching inside order details tabs', function (assert) { + const route = this.owner.lookup('route:operations/orders/index/details'); + + let stopCalled = false; + let removeCalled = false; + let showCalled = false; + + route.orderSocketEvents = { + stop() { + stopCalled = true; + }, + }; + route.leafletMapManager = { + removeRoutingControl() { + removeCalled = true; + }, + }; + route.universe = { + sidebarContext: { + show() { + showCalled = true; + }, + }, + }; + route.controllerFor = () => ({ + model: { id: 'order_1' }, + routingControl: { id: 'rc_1' }, + }); + + route.willTransition({ + from: { name: 'console.fleet-ops.operations.orders.index.details.virtual' }, + to: { name: 'console.fleet-ops.operations.orders.index.details.index' }, + }); + + assert.false(stopCalled); + assert.false(removeCalled); + assert.false(showCalled); + }); + + test('willTransition cleans up when leaving the order details route tree', function (assert) { + const route = this.owner.lookup('route:operations/orders/index/details'); + + let stopCalled = false; + let removeCalled = false; + let showCalled = false; + + route.orderSocketEvents = { + stop() { + stopCalled = true; + }, + }; + route.leafletMapManager = { + removeRoutingControl() { + removeCalled = true; + }, + }; + route.universe = { + sidebarContext: { + show() { + showCalled = true; + }, + }, + }; + route.controllerFor = () => ({ + model: { id: 'order_1' }, + routingControl: { id: 'rc_1' }, + }); + + route.willTransition({ + from: { name: 'console.fleet-ops.operations.orders.index.details.index' }, + to: { name: 'console.fleet-ops.operations.orders.index' }, + }); + + assert.true(stopCalled); + assert.true(removeCalled); + assert.true(showCalled); + }); }); From 5a4bf36e93fdf1df264024dcef696a2b0c55c1ac Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 13:05:19 +0800 Subject: [PATCH 02/11] fix: separate stop and waypoint algorithm counts --- server/src/Models/ServiceRate.php | 35 ++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index e95b769d..be721ac3 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -704,7 +704,13 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->isAlgorithm()) { $rateFee = Algo::exec( $this->algorithm, - $this->buildAlgorithmVariables($entities, $waypoints, $totalDistance, $totalTime), + $this->buildAlgorithmVariables( + $entities, + $waypoints, + $totalDistance, + $totalTime, + $this->inferEndpointCountFromStops($waypoints) + ), true ); @@ -885,7 +891,13 @@ public function quote(Payload $payload) if ($this->isAlgorithm()) { $rateFee = Algo::exec( $this->algorithm, - $this->buildAlgorithmVariables($payload->entities->all(), $waypoints->all(), $totalDistance, $totalTime), + $this->buildAlgorithmVariables( + $payload->entities->all(), + $waypoints->all(), + $totalDistance, + $totalTime, + (int) ($payload->pickup ? 1 : 0) + (int) ($payload->dropoff ? 1 : 0) + ), true ); @@ -1000,22 +1012,35 @@ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string }; } - protected function buildAlgorithmVariables(array $entities = [], array $waypoints = [], ?int $totalDistance = 0, ?int $totalTime = 0): array + protected function buildAlgorithmVariables(array $entities = [], array $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array { $entityCollection = collect($entities)->filter(); - $stopCount = collect($waypoints)->filter()->count(); + $stopCount = collect($stops)->filter()->count(); + $endpointCount = max(0, min($endpointCount, $stopCount)); + $waypointCount = max($stopCount - $endpointCount, 0); return Algo::normalizeVariables([ 'distance_m' => $totalDistance ?? 0, 'time_s' => $totalTime ?? 0, 'stops' => $stopCount, - 'waypoints' => $stopCount, + 'waypoints' => $waypointCount, 'parcels' => $entityCollection->where('type', 'parcel')->count(), 'entities' => $entityCollection->count(), 'base_fee' => Utils::numbersOnly($this->base_fee ?? 0), ]); } + protected function inferEndpointCountFromStops(array $stops = []): int + { + $stopCount = collect($stops)->filter()->count(); + + if ($stopCount <= 1) { + return $stopCount; + } + + return 2; + } + /** * Find the ServiceRateFee based on the total distance. * From 2911b8ca27bea8c5a54c0ec2a6ff45cc05b132a6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 13:49:32 +0800 Subject: [PATCH 03/11] routine fixes --- addon/components/leaflet-draw-control.js | 2 +- addon/components/map/drawer/geofence-event-listing.js | 7 +++++-- addon/routes/application.js | 6 +++--- addon/services/equipment-actions.js | 10 ++++------ addon/services/maintenance-actions.js | 10 ++++------ addon/services/part-actions.js | 10 ++++------ addon/services/service-rate-actions.js | 7 ++++++- 7 files changed, 27 insertions(+), 25 deletions(-) diff --git a/addon/components/leaflet-draw-control.js b/addon/components/leaflet-draw-control.js index eff3b6f3..d1410500 100644 --- a/addon/components/leaflet-draw-control.js +++ b/addon/components/leaflet-draw-control.js @@ -27,7 +27,7 @@ export default class LeafletDrawControl extends BaseLayer { leafletOptions = ['draw', 'edit', 'remove', 'poly', 'position']; @computed('leafletEvents.[]', 'args') get usedLeafletEvents() { - const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw.Event)]; + const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw?.Event ?? {})]; return leafletEvents.filter((eventName) => { eventName = camelize(eventName.replace(':', ' ')); let methodName = `_${eventName}`; diff --git a/addon/components/map/drawer/geofence-event-listing.js b/addon/components/map/drawer/geofence-event-listing.js index 5934a518..80f63617 100644 --- a/addon/components/map/drawer/geofence-event-listing.js +++ b/addon/components/map/drawer/geofence-event-listing.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { next } from '@ember/runloop'; export default class MapDrawerGeofenceEventListingComponent extends Component { @service fetch; @@ -12,8 +13,10 @@ export default class MapDrawerGeofenceEventListingComponent extends Component { constructor() { super(...arguments); - this.geofenceEventBus.subscribe(this.currentUser.companyId); - this.loadRecentEvents(); + next(() => { + this.geofenceEventBus.subscribe(this.currentUser.companyId); + this.loadRecentEvents(); + }); } get events() { diff --git a/addon/routes/application.js b/addon/routes/application.js index 27507def..4b3ad0c7 100644 --- a/addon/routes/application.js +++ b/addon/routes/application.js @@ -20,14 +20,14 @@ export default class ApplicationRoute extends Route { }); } - beforeModel() { + async beforeModel() { if (this.abilities.cannot('fleet-ops see extension')) { this.notifications.warning(this.intl.t('common.unauthorized-access')); return this.hostRouter.transitionTo('console'); } - this.location.getUserLocation(); - this.#loadRoutingSettings(); + await this.location.getUserLocation(); + await this.#loadRoutingSettings(); } async #loadRoutingSettings() { diff --git a/addon/services/equipment-actions.js b/addon/services/equipment-actions.js index f6e5927e..29f404e7 100644 --- a/addon/services/equipment-actions.js +++ b/addon/services/equipment-actions.js @@ -1,7 +1,9 @@ -import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action'; +import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; export default class EquipmentActionsService extends ResourceActionService { - @service currentUser; + get defaultCurrency() { + return this.currentUser?.company?.currency || this.currentUser.currency || 'USD'; + } constructor() { super(...arguments); @@ -12,10 +14,6 @@ export default class EquipmentActionsService extends ResourceActionService { }); } - get defaultCurrency() { - return this.currentUser?.company?.currency || 'USD'; - } - transition = { view: (equipment) => this.transitionTo('maintenance.equipment.index.details', equipment), edit: (equipment) => this.transitionTo('maintenance.equipment.index.edit', equipment), diff --git a/addon/services/maintenance-actions.js b/addon/services/maintenance-actions.js index 3f5b97a0..a1759b29 100644 --- a/addon/services/maintenance-actions.js +++ b/addon/services/maintenance-actions.js @@ -1,7 +1,9 @@ -import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action'; +import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; export default class MaintenanceActionsService extends ResourceActionService { - @service currentUser; + get defaultCurrency() { + return this.currentUser?.company?.currency || this.currentUser.currency || 'USD'; + } constructor() { super(...arguments); @@ -12,10 +14,6 @@ export default class MaintenanceActionsService extends ResourceActionService { }); } - get defaultCurrency() { - return this.currentUser?.company?.currency || 'USD'; - } - transition = { view: (maintenance) => this.transitionTo('maintenance.maintenances.index.details', maintenance), edit: (maintenance) => this.transitionTo('maintenance.maintenances.index.edit', maintenance), diff --git a/addon/services/part-actions.js b/addon/services/part-actions.js index 44fc9629..d6d579eb 100644 --- a/addon/services/part-actions.js +++ b/addon/services/part-actions.js @@ -1,7 +1,9 @@ -import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action'; +import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; export default class PartActionsService extends ResourceActionService { - @service currentUser; + get defaultCurrency() { + return this.currentUser?.company?.currency || this.currentUser.currency || 'USD'; + } constructor() { super(...arguments); @@ -12,10 +14,6 @@ export default class PartActionsService extends ResourceActionService { }); } - get defaultCurrency() { - return this.currentUser?.company?.currency || 'USD'; - } - transition = { view: (part) => this.transitionTo('maintenance.parts.index.details', part), edit: (part) => this.transitionTo('maintenance.parts.index.edit', part), diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js index 4d5cdc2c..20f77952 100644 --- a/addon/services/service-rate-actions.js +++ b/addon/services/service-rate-actions.js @@ -1,18 +1,23 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; import { task } from 'ember-concurrency'; import { isNone } from '@ember/utils'; +import { next } from '@ember/runloop'; import serializePayload from '../utils/serialize-payload'; export default class ServiceRateActionsService extends ResourceActionService { modelNamePath = 'service_name'; + get defaultCurrency() { + return this.currentUser?.company?.currency || this.currentUser.currency || 'USD'; + } + constructor() { super(...arguments); this.initialize('service-rate', { defaultAttributes: { rate_calculation_method: 'per_meter', per_meter_unit: 'm', - currency: this.currentUser.currency, + currency: this.defaultCurrency, parcel_fees: this.#getDefaultParcelFees(), }, }); From e1dd3af4793412d5801e2ca7216e918066f39153 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 13:52:08 +0800 Subject: [PATCH 04/11] fix: dedupe parcel fee display after save --- addon/components/service-rate/details.hbs | 4 ++-- addon/components/service-rate/form.hbs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/addon/components/service-rate/details.hbs b/addon/components/service-rate/details.hbs index a502fd6f..6b6e2c8f 100644 --- a/addon/components/service-rate/details.hbs +++ b/addon/components/service-rate/details.hbs @@ -167,7 +167,7 @@ {{#if @resource.isParcelService}}
- {{#each @resource.parcel_fees as |parcelFee|}} + {{#each @resource.parcelFees as |parcelFee|}}
@@ -323,4 +323,4 @@ -
\ No newline at end of file +
diff --git a/addon/components/service-rate/form.hbs b/addon/components/service-rate/form.hbs index f3468991..b9b35ac6 100644 --- a/addon/components/service-rate/form.hbs +++ b/addon/components/service-rate/form.hbs @@ -287,7 +287,7 @@ {{else if @resource.isParcelService}}
- {{#each @resource.parcel_fees as |parcelFee|}} + {{#each @resource.parcelFees as |parcelFee|}}
@@ -541,4 +541,4 @@ {{/if}} -
\ No newline at end of file +
From f5270a11e9af764bebf584a3780d29e380d2bd3f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 14:00:59 +0800 Subject: [PATCH 05/11] fix: use deduped per-drop fee rows in service rate views --- addon/components/service-rate/details.hbs | 2 +- addon/components/service-rate/form.hbs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addon/components/service-rate/details.hbs b/addon/components/service-rate/details.hbs index 6b6e2c8f..3e12dbcf 100644 --- a/addon/components/service-rate/details.hbs +++ b/addon/components/service-rate/details.hbs @@ -106,7 +106,7 @@ - {{#each @resource.rate_fees as |rateFee|}} + {{#each @resource.rateFees as |rateFee|}} {{n-a rateFee.min}} {{n-a rateFee.max}} diff --git a/addon/components/service-rate/form.hbs b/addon/components/service-rate/form.hbs index b9b35ac6..46956665 100644 --- a/addon/components/service-rate/form.hbs +++ b/addon/components/service-rate/form.hbs @@ -176,7 +176,7 @@ - {{#each @resource.rate_fees as |rateFee|}} + {{#each @resource.rateFees as |rateFee|}} Date: Thu, 23 Apr 2026 14:06:29 +0800 Subject: [PATCH 06/11] docs: update custom algorithm help examples --- addon/components/service-rate/form.hbs | 37 +++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/addon/components/service-rate/form.hbs b/addon/components/service-rate/form.hbs index 46956665..80dd5c62 100644 --- a/addon/components/service-rate/form.hbs +++ b/addon/components/service-rate/form.hbs @@ -252,25 +252,38 @@ {{else if @resource.isAlgorithm}} - {{t "service-rate.fields.custom-algorithm-info-message"}} - {{t "service-rate.fields.custom-algorithm-info-second-message"}} + Define a custom formula for this service rate using variables wrapped in a single pair of curly braces.
    -
  • {{t "service-rate.fields.distance-message"}} - {{t "service-rate.fields.distance-continue-message"}}
  • -
  • {{t "service-rate.fields.time-message"}} - {{t "service-rate.fields.time-continue-message"}}
  • +
  • {'{distance}'}, {'{distance_m}'}, {'{distance_km}'}, {'{distance_mi}'}: route distance in meters, kilometers, or miles.
  • +
  • {'{time}'}, {'{time_s}'}, {'{time_min}'}: route time in seconds or minutes.
  • +
  • {'{stops}'}: total service stops including pickup, dropoff, and intermediate waypoints.
  • +
  • {'{waypoints}'}: intermediate waypoint count only.
  • +
  • {'{parcels}'}, {'{entities}'}: parcel count and total payload entity count.
  • +
  • {'{base_fee}'}: the configured base fee for this service rate.
  • +
  • Supported functions: max(a,b), min(a,b), ceil(x), floor(x), round(x).
-
-

{{t "service-rate.fields.example"}}

-
- {{t "service-rate.fields.example-message"}} - {{t "service-rate.fields.example-second-message"}} +
+
+

{{t "service-rate.fields.example"}} 1

+
Charge by distance in miles after the first 15 miles, plus the base fee.
+ max({'{distance_mi}'} - 15, 0) * 5 + {'{base_fee}'} +
+ +
+

{{t "service-rate.fields.example"}} 2

+
Charge extra for stops after the first 2 service stops.
+ max({'{stops}'} - 2, 0) * 10 + {'{base_fee}'} +
+ +
+

{{t "service-rate.fields.example"}} 3

+
Combine waypoint, parcel, and distance surcharges in one formula.
+ max({'{waypoints}'}, 0) * 15 + max({'{parcels}'} - 3, 0) * 2 + max({'{distance_km}'} - 25, 0) * 0.5 + {'{base_fee}'}
- (( {distance} / 50 ) * .05 ) + (( {time} / 60 ) * .01)
From 6f6e2ee8a4ce1497a84fa2eee0736a9d50353dfb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 14:38:03 +0800 Subject: [PATCH 07/11] service rate patches fully working and ready --- addon/components/service-rate/details.hbs | 6 +++--- addon/components/service-rate/form.hbs | 18 +++++++++--------- .../operations/service-rates/index/edit.js | 1 + .../operations/service-rates/index/new.js | 1 + addon/helpers/f-to-int.js | 6 ++++++ addon/services/service-rate-actions.js | 1 - app/helpers/f-to-int.js | 1 + tests/integration/helpers/f-to-int-test.js | 17 +++++++++++++++++ 8 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 addon/helpers/f-to-int.js create mode 100644 app/helpers/f-to-int.js create mode 100644 tests/integration/helpers/f-to-int-test.js diff --git a/addon/components/service-rate/details.hbs b/addon/components/service-rate/details.hbs index 3e12dbcf..feaba25f 100644 --- a/addon/components/service-rate/details.hbs +++ b/addon/components/service-rate/details.hbs @@ -110,7 +110,7 @@ {{n-a rateFee.min}} {{n-a rateFee.max}} - {{format-currency rateFee.fee @resource.currency}} + {{format-currency (f-to-int rateFee.fee) @resource.currency}} {{else}} @@ -158,7 +158,7 @@
Algorithm Code
-
{{n-a @resource.algorithm}}
+
{{n-a @resource.algorithm}}
@@ -242,7 +242,7 @@ {{t "service-rate.fields.additional-fee"}}
- {{n-a (format-currency parcelFee.fee @resource.currency)}} + {{n-a (format-currency (f-to-int parcelFee.fee) @resource.currency)}}
diff --git a/addon/components/service-rate/form.hbs b/addon/components/service-rate/form.hbs index 80dd5c62..23435ec4 100644 --- a/addon/components/service-rate/form.hbs +++ b/addon/components/service-rate/form.hbs @@ -256,12 +256,12 @@
    -
  • {'{distance}'}, {'{distance_m}'}, {'{distance_km}'}, {'{distance_mi}'}: route distance in meters, kilometers, or miles.
  • -
  • {'{time}'}, {'{time_s}'}, {'{time_min}'}: route time in seconds or minutes.
  • -
  • {'{stops}'}: total service stops including pickup, dropoff, and intermediate waypoints.
  • -
  • {'{waypoints}'}: intermediate waypoint count only.
  • -
  • {'{parcels}'}, {'{entities}'}: parcel count and total payload entity count.
  • -
  • {'{base_fee}'}: the configured base fee for this service rate.
  • +
  • {distance}, {distance_m}, {distance_km}, {distance_mi}: route distance in meters, kilometers, or miles.
  • +
  • {time}, {time_s}, {time_min}: route time in seconds or minutes.
  • +
  • {stops}: total service stops including pickup, dropoff, and intermediate waypoints.
  • +
  • {waypoints}: intermediate waypoint count only.
  • +
  • {parcels}, {entities}: parcel count and total payload entity count.
  • +
  • {base_fee}: the configured base fee for this service rate.
  • Supported functions: max(a,b), min(a,b), ceil(x), floor(x), round(x).
@@ -270,19 +270,19 @@

{{t "service-rate.fields.example"}} 1

Charge by distance in miles after the first 15 miles, plus the base fee.
- max({'{distance_mi}'} - 15, 0) * 5 + {'{base_fee}'} + max({distance_mi} - 15, 0) * 5 + {base_fee}

{{t "service-rate.fields.example"}} 2

Charge extra for stops after the first 2 service stops.
- max({'{stops}'} - 2, 0) * 10 + {'{base_fee}'} + max({stops} - 2, 0) * 10 + {base_fee}

{{t "service-rate.fields.example"}} 3

Combine waypoint, parcel, and distance surcharges in one formula.
- max({'{waypoints}'}, 0) * 15 + max({'{parcels}'} - 3, 0) * 2 + max({'{distance_km}'} - 25, 0) * 0.5 + {'{base_fee}'} + max({waypoints}, 0) * 15 + max({parcels} - 3, 0) * 2 + max({distance_km} - 25, 0) * 0.5 + {base_fee}
diff --git a/addon/controllers/operations/service-rates/index/edit.js b/addon/controllers/operations/service-rates/index/edit.js index 4d2d39ce..54ddf4ee 100644 --- a/addon/controllers/operations/service-rates/index/edit.js +++ b/addon/controllers/operations/service-rates/index/edit.js @@ -22,6 +22,7 @@ export default class OperationsServiceRatesIndexEditController extends Controlle @task *save(serviceRate) { try { yield serviceRate.save(); + yield serviceRate.reload(); this.events.trackResourceUpdated(serviceRate); this.overlay?.close(); diff --git a/addon/controllers/operations/service-rates/index/new.js b/addon/controllers/operations/service-rates/index/new.js index eac50956..f023e13a 100644 --- a/addon/controllers/operations/service-rates/index/new.js +++ b/addon/controllers/operations/service-rates/index/new.js @@ -16,6 +16,7 @@ export default class OperationsServiceRatesIndexNewController extends Controller @task *save(serviceRate) { try { yield serviceRate.save(); + yield serviceRate.reload(); this.events.trackResourceCreated(serviceRate); this.overlay?.close(); diff --git a/addon/helpers/f-to-int.js b/addon/helpers/f-to-int.js new file mode 100644 index 00000000..82caf6be --- /dev/null +++ b/addon/helpers/f-to-int.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import numbersOnly from '@fleetbase/ember-core/utils/numbers-only'; + +export default helper(function fToInt([value]) { + return parseInt(numbersOnly(value)); +}); diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js index 20f77952..c01c42c9 100644 --- a/addon/services/service-rate-actions.js +++ b/addon/services/service-rate-actions.js @@ -1,7 +1,6 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; import { task } from 'ember-concurrency'; import { isNone } from '@ember/utils'; -import { next } from '@ember/runloop'; import serializePayload from '../utils/serialize-payload'; export default class ServiceRateActionsService extends ResourceActionService { diff --git a/app/helpers/f-to-int.js b/app/helpers/f-to-int.js new file mode 100644 index 00000000..0499e52a --- /dev/null +++ b/app/helpers/f-to-int.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/helpers/f-to-int'; diff --git a/tests/integration/helpers/f-to-int-test.js b/tests/integration/helpers/f-to-int-test.js new file mode 100644 index 00000000..894d8213 --- /dev/null +++ b/tests/integration/helpers/f-to-int-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | f-to-int', function (hooks) { + setupRenderingTest(hooks); + + // TODO: Replace this with your real tests. + test('it renders', async function (assert) { + this.set('inputValue', '1234'); + + await render(hbs`{{f-to-int this.inputValue}}`); + + assert.dom().hasText('1234'); + }); +}); From 0921ec326c9edc436d69e5921c44ef9e892e2435 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 14:52:58 +0800 Subject: [PATCH 08/11] fix: round calculated service rate amounts to cents --- server/src/Models/ServiceRate.php | 41 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index be721ac3..3c3bc49d 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -689,12 +689,12 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->isPerMeter()) { $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit); - $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee; + $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee); $subTotal += $rateFee; $lines->push([ 'details' => 'Service Fee', - 'amount' => Utils::numbersOnly($rateFee), + 'amount' => $rateFee, 'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency), 'currency' => $this->currency, 'code' => 'BASE_FEE', @@ -702,7 +702,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ } if ($this->isAlgorithm()) { - $rateFee = Algo::exec( + $rateFee = $this->normalizeCalculatedMoney(Algo::exec( $this->algorithm, $this->buildAlgorithmVariables( $entities, @@ -712,13 +712,13 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ $this->inferEndpointCountFromStops($waypoints) ), true - ); + )); - $subTotal += Utils::numbersOnly($rateFee); + $subTotal += $rateFee; $lines->push([ 'details' => 'Service Fee', - 'amount' => Utils::numbersOnly($rateFee), + 'amount' => $rateFee, 'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency), 'currency' => $this->currency, 'code' => 'BASE_FEE', @@ -780,7 +780,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->hasCodFlatFee()) { $subTotal += $codFee = $this->cod_flat_fee; } elseif ($this->hasCodPercentageFee()) { - $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate); + $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate)); } $lines->push([ @@ -797,7 +797,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ if ($this->hasPeakHoursFlatFee()) { $subTotal += $peakHoursFee = $this->peak_hours_flat_fee; } elseif ($this->hasPeakHoursPercentageFee()) { - $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate); + $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate)); } $lines->push([ @@ -876,12 +876,12 @@ public function quote(Payload $payload) if ($this->isPerMeter()) { $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit); - $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee; + $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee); $subTotal += $rateFee; $lines->push([ 'details' => 'Service Fee', - 'amount' => Utils::numbersOnly($rateFee), + 'amount' => $rateFee, 'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency), 'currency' => $this->currency, 'code' => 'BASE_FEE', @@ -889,7 +889,7 @@ public function quote(Payload $payload) } if ($this->isAlgorithm()) { - $rateFee = Algo::exec( + $rateFee = $this->normalizeCalculatedMoney(Algo::exec( $this->algorithm, $this->buildAlgorithmVariables( $payload->entities->all(), @@ -899,13 +899,13 @@ public function quote(Payload $payload) (int) ($payload->pickup ? 1 : 0) + (int) ($payload->dropoff ? 1 : 0) ), true - ); + )); - $subTotal += Utils::numbersOnly($rateFee); + $subTotal += $rateFee; $lines->push([ 'details' => 'Service Fee', - 'amount' => Utils::numbersOnly($rateFee), + 'amount' => $rateFee, 'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency), 'currency' => $this->currency, 'code' => 'BASE_FEE', @@ -967,7 +967,7 @@ public function quote(Payload $payload) if ($this->hasCodFlatFee()) { $subTotal += $codFee = $this->cod_flat_fee; } elseif ($this->hasCodPercentageFee()) { - $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate); + $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate)); } $lines->push([ @@ -984,7 +984,7 @@ public function quote(Payload $payload) if ($this->hasPeakHoursFlatFee()) { $subTotal += $peakHoursFee = $this->peak_hours_flat_fee; } elseif ($this->hasPeakHoursPercentageFee()) { - $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate); + $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate)); } $lines->push([ @@ -1012,7 +1012,12 @@ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string }; } - protected function buildAlgorithmVariables(array $entities = [], array $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array + protected function normalizeCalculatedMoney($amount = 0): int + { + return (int) round((float) ($amount ?? 0)); + } + + protected function buildAlgorithmVariables($entities = [], $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array { $entityCollection = collect($entities)->filter(); $stopCount = collect($stops)->filter()->count(); @@ -1030,7 +1035,7 @@ protected function buildAlgorithmVariables(array $entities = [], array $stops = ]); } - protected function inferEndpointCountFromStops(array $stops = []): int + protected function inferEndpointCountFromStops($stops = []): int { $stopCount = collect($stops)->filter()->count(); From 97f56311208ea682ae3040f3a11acf6dac2dc28b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 19:12:02 +0800 Subject: [PATCH 09/11] mostly done --- addon/components/entity/form.hbs | 24 ++++++++ addon/components/entity/form.js | 30 ++++++++++ addon/components/order/form/service-rate.hbs | 2 +- .../operations/orders/index/details.js | 29 +++++++++- .../orders/index/details/virtual.js | 5 ++ .../orders/index/details/virtual.js | 1 + addon/services/leaflet-map-manager.js | 4 ++ addon/services/service-rate-actions.js | 5 +- .../orders/index/details/virtual.hbs | 8 ++- addon/utils/fleet-ops-options.js | 33 +++++++++++ addon/utils/serialize-payload.js | 2 +- .../orders/index/details/virtual.js | 1 + .../Api/v1/ServiceQuoteController.php | 4 ++ server/src/Http/Resources/v1/ServiceQuote.php | 4 +- server/src/Models/Entity.php | 7 +++ server/src/Models/ServiceRate.php | 55 ++++++++++++++++--- server/src/Support/Algo.php | 22 ++++++-- .../orders/index/details/virtual-test.js | 12 ++++ tests/unit/utils/serialize-payload-test.js | 14 +++-- 19 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 addon/controllers/operations/orders/index/details/virtual.js create mode 100644 app/controllers/operations/orders/index/details/virtual.js create mode 100644 tests/unit/controllers/operations/orders/index/details/virtual-test.js diff --git a/addon/components/entity/form.hbs b/addon/components/entity/form.hbs index 89339c99..32380a1d 100644 --- a/addon/components/entity/form.hbs +++ b/addon/components/entity/form.hbs @@ -23,6 +23,30 @@
+
+ + {{#if this.useCustomType}} + + {{else}} +
+ +
+
{{type.label}}
+
{{type.description}}
+
+
+
+ {{/if}} +
+ +
diff --git a/addon/components/entity/form.js b/addon/components/entity/form.js index ebed65b5..9c7305b4 100644 --- a/addon/components/entity/form.js +++ b/addon/components/entity/form.js @@ -1,11 +1,41 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; +import fleetOpsOptions from '../../utils/fleet-ops-options'; export default class EntityFormComponent extends Component { @service fetch; @service currentUser; @service notifications; + @tracked useCustomType = false; + + constructor() { + super(...arguments); + this.useCustomType = this.selectedEntityType === undefined && Boolean(this.args.resource?.type); + } + + get entityTypes() { + return fleetOpsOptions('entityTypes'); + } + + get selectedEntityType() { + return this.entityTypes.find((option) => option.value === this.args.resource?.type); + } + + @action selectEntityType(option) { + this.useCustomType = false; + this.args.resource.type = option?.value ?? null; + } + + @action toggleCustomType(value) { + this.useCustomType = value; + + if (!value && !this.selectedEntityType) { + this.args.resource.type = null; + } + } @task *handlePhotoUpload(file) { try { diff --git a/addon/components/order/form/service-rate.hbs b/addon/components/order/form/service-rate.hbs index 39d8611e..dae8e765 100644 --- a/addon/components/order/form/service-rate.hbs +++ b/addon/components/order/form/service-rate.hbs @@ -58,7 +58,7 @@ @name="serviceQuote" @changed={{fn (mut @resource.service_quote_uuid)}} /> - + {{serviceQuote.request_id}} diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js index 92596e3a..f3adf0e1 100644 --- a/addon/controllers/operations/orders/index/details.js +++ b/addon/controllers/operations/orders/index/details.js @@ -16,6 +16,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller { @service universe; @service sidebar; @tracked routingControl; + @tracked routingCompleted = false; get tabs() { const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:order:details'); @@ -109,7 +110,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller { @action async setup() { // Change to map layout and display order route this.index.changeLayout('map'); - this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints); + this.routingCompleted = false; + this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints, { + onRouteFound: () => { + this.routingCompleted = true; + }, + onRoutingError: () => { + this.routingCompleted = true; + }, + }); + + if (!this.routingControl) { + this.routingCompleted = true; + } // Hide sidebar this.sidebar.hideNow(); @@ -125,7 +138,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller { async (_msg, { reloadable }) => { if (reloadable) { await this.hostRouter.refresh(); - this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl); + this.routingCompleted = false; + this.routingControl = await this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl, { + onRouteFound: () => { + this.routingCompleted = true; + }, + onRoutingError: () => { + this.routingCompleted = true; + }, + }); + + if (!this.routingControl) { + this.routingCompleted = true; + } } }, { debounceMs: 250 } diff --git a/addon/controllers/operations/orders/index/details/virtual.js b/addon/controllers/operations/orders/index/details/virtual.js new file mode 100644 index 00000000..6caa543d --- /dev/null +++ b/addon/controllers/operations/orders/index/details/virtual.js @@ -0,0 +1,5 @@ +import Controller, { inject as controller } from '@ember/controller'; + +export default class OperationsOrdersIndexDetailsVirtualController extends Controller { + @controller('operations.orders.index.details') parent; +} diff --git a/addon/routes/operations/orders/index/details/virtual.js b/addon/routes/operations/orders/index/details/virtual.js index 0c504634..b7331fd3 100644 --- a/addon/routes/operations/orders/index/details/virtual.js +++ b/addon/routes/operations/orders/index/details/virtual.js @@ -19,5 +19,6 @@ export default class OperationsOrdersIndexDetailsVirtualRoute extends Route { setupController(controller) { super.setupController(...arguments); controller.resource = this.modelFor('operations.orders.index.details'); + controller.detailsController = this.controllerFor('operations.orders.index.details'); } } diff --git a/addon/services/leaflet-map-manager.js b/addon/services/leaflet-map-manager.js index 3000351e..036eebe6 100644 --- a/addon/services/leaflet-map-manager.js +++ b/addon/services/leaflet-map-manager.js @@ -270,6 +270,10 @@ export default class LeafletMapManagerService extends Service { this.route = routes[0]; }); + routingControl.on('routingerror', (event) => { + options?.onRoutingError?.(event); + }); + this.positionWaypoints(waypoints); return routingControl; diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js index c01c42c9..72dccf77 100644 --- a/addon/services/service-rate-actions.js +++ b/addon/services/service-rate-actions.js @@ -122,12 +122,13 @@ export default class ServiceRateActionsService extends ResourceActionService { const hasFacilitator = !isNone(order.facilitator); const facilitatorServiceType = order.facilitator?.get('service_types.firstObject.key') ?? order.type; + const routeSummary = order.route?.summary ?? order.route?.details?.summary ?? {}; try { const serviceQuotes = yield this.fetch.post('service-quotes/preliminary', { payload: serializePayload(order.payload), - distance: order.route.summary?.totalDistance, - time: order.route.summary?.totalTime, + distance: routeSummary.totalDistance, + time: routeSummary.totalTime, service_type: hasFacilitator ? facilitatorServiceType : order.type, facilitator: order.facilitator?.public_id, scheduled_at: order.scheduled_at, diff --git a/addon/templates/operations/orders/index/details/virtual.hbs b/addon/templates/operations/orders/index/details/virtual.hbs index 2977428d..b2213fad 100644 --- a/addon/templates/operations/orders/index/details/virtual.hbs +++ b/addon/templates/operations/orders/index/details/virtual.hbs @@ -1 +1,7 @@ -{{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}} \ No newline at end of file +{{#if this.parent.routingCompleted}} + {{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}} +{{else}} +
+ +
+{{/if}} diff --git a/addon/utils/fleet-ops-options.js b/addon/utils/fleet-ops-options.js index 5ff3de08..f6c7ee1f 100644 --- a/addon/utils/fleet-ops-options.js +++ b/addon/utils/fleet-ops-options.js @@ -123,6 +123,38 @@ export const contactTypes = [ { label: 'Dispatcher', value: 'dispatcher', description: 'Scheduling and operations contact' }, ]; +export const entityTypes = [ + { label: 'Parcel', value: 'parcel', description: 'Standard parcel or package used by parcel-based pricing.' }, + { label: 'Package', value: 'package', description: 'Generic package or boxed item.' }, + { label: 'Product', value: 'product', description: 'Sellable product or catalog item.' }, + { label: 'Item', value: 'item', description: 'Generic inventory item when no stronger type applies.' }, + { label: 'SKU / Inventory Unit', value: 'sku', description: 'Stock keeping unit or tracked inventory piece.' }, + { label: 'Document', value: 'document', description: 'Envelopes, paperwork, and lightweight documents.' }, + { label: 'Envelope', value: 'envelope', description: 'Flat document mailer or envelope.' }, + { label: 'Box', value: 'box', description: 'Single boxed shipment unit.' }, + { label: 'Carton', value: 'carton', description: 'Single carton, box, or case.' }, + { label: 'Case', value: 'case', description: 'Case-packed goods or packaged case.' }, + { label: 'Bundle', value: 'bundle', description: 'Bundled or strapped items shipped together.' }, + { label: 'Bag', value: 'bag', description: 'Bagged goods, sacks, or pouches.' }, + { label: 'Tote', value: 'tote', description: 'Reusable tote, bin, or container.' }, + { label: 'Crate', value: 'crate', description: 'Rigid crate or caged shipment unit.' }, + { label: 'Pallet', value: 'pallet', description: 'Palletized freight or grouped cargo.' }, + { label: 'Container', value: 'container', description: 'Containerized shipment or storage container.' }, + { label: 'Freight', value: 'freight', description: 'Loose freight or larger shipment unit.' }, + { label: 'Cargo', value: 'cargo', description: 'General cargo not classified more specifically.' }, + { label: 'Equipment', value: 'equipment', description: 'Tools, machines, or operational equipment.' }, + { label: 'Machinery', value: 'machinery', description: 'Industrial machinery or large mechanical unit.' }, + { label: 'Asset', value: 'asset', description: 'Tracked business asset or returnable item.' }, + { label: 'Return', value: 'return', description: 'Returned goods or reverse-logistics item.' }, + { label: 'Sample', value: 'sample', description: 'Sample item for trial, demo, or inspection.' }, + { label: 'Spare Part', value: 'spare_part', description: 'Replacement component or service part.' }, + { label: 'Medical Supply', value: 'medical_supply', description: 'Healthcare, lab, or medical delivery item.' }, + { label: 'Food', value: 'food', description: 'Prepared food, meal, or grocery item.' }, + { label: 'Beverage', value: 'beverage', description: 'Drink, liquid refreshment, or bottled shipment.' }, + { label: 'Retail Goods', value: 'retail_goods', description: 'General consumer or retail merchandise.' }, + { label: 'Passenger', value: 'passenger', description: 'Person transported as part of the order payload.' }, +]; + export const contactStatuses = [ { label: 'Active', value: 'active', description: 'Contact is valid and in use' }, { label: 'Inactive', value: 'inactive', description: 'Contact is no longer valid' }, @@ -821,6 +853,7 @@ export default function fleetOpsOptions(key) { fleetTypes, fleetStatuses, contactTypes, + entityTypes, contactStatuses, fuelReportTypes, fuelReportStatuses, diff --git a/addon/utils/serialize-payload.js b/addon/utils/serialize-payload.js index 5a8a6a73..724f1968 100644 --- a/addon/utils/serialize-payload.js +++ b/addon/utils/serialize-payload.js @@ -5,7 +5,7 @@ export default function serializePayload(payload) { const serialized = { pickup: serializeModel(payload.pickup), dropoff: serializeModel(payload.dropoff), - entitities: serializeArray(payload.entities), + entities: serializeArray(payload.entities), waypoints: serializeArray(payload.waypoints), }; diff --git a/app/controllers/operations/orders/index/details/virtual.js b/app/controllers/operations/orders/index/details/virtual.js new file mode 100644 index 00000000..6019798e --- /dev/null +++ b/app/controllers/operations/orders/index/details/virtual.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/controllers/operations/orders/index/details/virtual'; diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php index 6d26f8b0..81e74187 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php @@ -98,6 +98,7 @@ function ($q) use ($currency) { 'amount' => $subTotal, 'currency' => $serviceRate->currency, ]); + $quote->setRelation('serviceRate', $serviceRate); $items = $lines->map(function ($line) use ($quote) { return ServiceQuoteItem::create([ @@ -143,6 +144,7 @@ function ($query) use ($request) { 'amount' => $subTotal, 'currency' => $serviceRate->currency, ]); + $quote->setRelation('serviceRate', $serviceRate); $items = $lines->map(function ($line) use ($quote) { return ServiceQuoteItem::create([ @@ -288,6 +290,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request) 'amount' => $subTotal, 'currency' => $serviceRate->currency, ]); + $quote->setRelation('serviceRate', $serviceRate); // set preliminary data to meta $quote->updateMeta('preliminary_data', $preliminaryData); @@ -335,6 +338,7 @@ function ($query) use ($request) { 'amount' => $subTotal, 'currency' => $serviceRate->currency, ]); + $quote->setRelation('serviceRate', $serviceRate); // set preliminary data to meta $quote->updateMeta('preliminary_data', $preliminaryData); diff --git a/server/src/Http/Resources/v1/ServiceQuote.php b/server/src/Http/Resources/v1/ServiceQuote.php index 5de30370..2376cb7d 100644 --- a/server/src/Http/Resources/v1/ServiceQuote.php +++ b/server/src/Http/Resources/v1/ServiceQuote.php @@ -23,8 +23,8 @@ public function toArray($request) 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'service_rate_uuid' => $this->when(Http::isInternalRequest(), $this->service_rate_uuid), 'payload_uuid' => $this->when(Http::isInternalRequest(), $this->payload_uuid), - 'service_rate_name' => data_get($this, 'serviceRate.name'), - 'service_name' => data_get($this, 'serviceRate.name'), + 'service_rate_name' => data_get($this, 'serviceRate.service_name'), + 'service_name' => data_get($this, 'serviceRate.service_name'), 'service_rate' => $this->when(Http::isPublicRequest(), data_get($this, 'serviceRate.public_id')), 'facilitator' => $this->when(Http::isPublicRequest(), data_get($this, 'integratedVendor.public_id')), 'items' => ServiceQuoteItem::collection($this->items), diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php index 6195b5f6..2aa02370 100644 --- a/server/src/Models/Entity.php +++ b/server/src/Models/Entity.php @@ -168,6 +168,13 @@ public function label() ])->render(); } + public function setTypeAttribute(?string $type = null): void + { + $type = is_string($type) ? trim($type) : $type; + + $this->attributes['type'] = filled($type) ? Str::snake($type) : null; + } + /** * @var BelongsTo */ diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index 3c3bc49d..ea9dcc27 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -369,11 +369,37 @@ public function setServiceRateParcelFees(?array $serviceRateParcelFees = []) return $this; } + // Normalize duplicate parcel rows by fee shape and keep the latest submitted value. + $serviceRateParcelFees = collect($serviceRateParcelFees) + ->filter(fn ($fee) => is_array($fee)) + ->map(function ($fee) { + return collect($fee)->except(['created_at', 'updated_at'])->toArray(); + }) + ->keyBy(function ($fee) { + return implode(':', [ + data_get($fee, 'size'), + data_get($fee, 'length'), + data_get($fee, 'width'), + data_get($fee, 'height'), + data_get($fee, 'dimensions_unit'), + data_get($fee, 'weight'), + data_get($fee, 'weight_unit'), + ]); + }) + ->values() + ->toArray(); + + $submittedUuids = collect($serviceRateParcelFees) + ->pluck('uuid') + ->filter() + ->values() + ->all(); + $iterate = count($serviceRateParcelFees); for ($i = 0; $i < $iterate; $i++) { // if already has uuid then we just update the record and remove from insert array - if (isset($serviceRateParcelFees[$i]['uuid'])) { + if (!empty($serviceRateParcelFees[$i]['uuid'])) { $id = $serviceRateParcelFees[$i]['uuid']; $updateableAttributes = collect($serviceRateParcelFees[$i])->except(['uuid', 'created_at', 'updated_at'])->toArray(); @@ -391,7 +417,18 @@ public function setServiceRateParcelFees(?array $serviceRateParcelFees = []) } $serviceRateParcelFees = collect($serviceRateParcelFees)->filter()->values()->toArray(); - ServiceRateParcelFee::bulkInsert($serviceRateParcelFees); + + $existingParcelFeesQuery = ServiceRateParcelFee::where('service_rate_uuid', $this->uuid); + + if (!empty($submittedUuids)) { + $existingParcelFeesQuery->whereNotIn('uuid', $submittedUuids)->delete(); + } else { + $existingParcelFeesQuery->delete(); + } + + if (!empty($serviceRateParcelFees)) { + ServiceRateParcelFee::bulkInsert($serviceRateParcelFees); + } return $this; } @@ -761,9 +798,10 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $ } $subTotal += $serviceParcelFee->fee; + $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel'))); $lines->push([ - 'details' => $serviceParcelFee->name . ' parcel fee', + 'details' => $parcelFeeName . ' parcel fee', 'amount' => Utils::numbersOnly($serviceParcelFee->fee), 'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency), 'currency' => $this->currency, @@ -948,9 +986,10 @@ public function quote(Payload $payload) } $subTotal += $serviceParcelFee->fee; + $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel'))); $lines->push([ - 'details' => $serviceParcelFee->name . ' parcel fee', + 'details' => $parcelFeeName . ' parcel fee', 'amount' => Utils::numbersOnly($serviceParcelFee->fee), 'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency), 'currency' => $this->currency, @@ -1004,10 +1043,10 @@ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string $distanceInMeters = (float) ($distanceInMeters ?? 0); return match ($unit) { - 'km' => $distanceInMeters / 1000, - 'ft' => $distanceInMeters / 0.3048, - 'yd' => $distanceInMeters / 0.9144, - 'mi' => $distanceInMeters / 1609.344, + 'km' => $distanceInMeters / 1000, + 'ft' => $distanceInMeters / 0.3048, + 'yd' => $distanceInMeters / 0.9144, + 'mi' => $distanceInMeters / 1609.344, default => $distanceInMeters, }; } diff --git a/server/src/Support/Algo.php b/server/src/Support/Algo.php index f1230214..62554153 100644 --- a/server/src/Support/Algo.php +++ b/server/src/Support/Algo.php @@ -25,7 +25,7 @@ public static function exec($algorithm, $variables = [], $round = false) } $algorithm = static::evaluateFunctions($algorithm); - $result = $m->evaluate($algorithm); + $result = $m->evaluate($algorithm); if ($result === false || $result === null || !is_numeric($result)) { return null; @@ -45,8 +45,10 @@ public static function isComputable($algorithm): bool return $result !== null && is_numeric($result); } - public static function normalizeVariables(array $variables = []): array + public static function normalizeVariables($variables = []): array { + $variables = collect($variables)->all(); + $distanceM = (float) ($variables['distance_m'] ?? $variables['distance'] ?? 0); $timeS = (float) ($variables['time_s'] ?? $variables['time'] ?? 0); @@ -85,9 +87,19 @@ protected static function evaluateFunctions(string $algorithm): string { while (preg_match(static::FUNCTION_PATTERN, $algorithm)) { $algorithm = preg_replace_callback(static::FUNCTION_PATTERN, function ($matches) { - $function = $matches[1]; - $args = array_map('trim', explode(',', $matches[2])); - $numbers = array_map(fn ($arg) => (float) $arg, $args); + $function = $matches[1]; + $args = array_map('trim', explode(',', $matches[2])); + $m = new EvalMath(); + $m->suppress_errors = true; + $numbers = array_map(function ($arg) use ($m) { + $result = $m->evaluate($arg); + + if ($result === false || $result === null || !is_numeric($result)) { + return (float) $arg; + } + + return (float) $result; + }, $args); return match ($function) { 'max' => (string) max($numbers[0] ?? 0, $numbers[1] ?? 0), diff --git a/tests/unit/controllers/operations/orders/index/details/virtual-test.js b/tests/unit/controllers/operations/orders/index/details/virtual-test.js new file mode 100644 index 00000000..3894d139 --- /dev/null +++ b/tests/unit/controllers/operations/orders/index/details/virtual-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Controller | operations/orders/index/details/virtual', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:operations/orders/index/details/virtual'); + assert.ok(controller); + }); +}); diff --git a/tests/unit/utils/serialize-payload-test.js b/tests/unit/utils/serialize-payload-test.js index 6ac106db..ac074de7 100644 --- a/tests/unit/utils/serialize-payload-test.js +++ b/tests/unit/utils/serialize-payload-test.js @@ -2,9 +2,15 @@ import serializePayload from 'dummy/utils/serialize-payload'; import { module, test } from 'qunit'; module('Unit | Utility | serialize-payload', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = serializePayload(); - assert.ok(result); + test('it serializes entities under the correct key', function (assert) { + let result = serializePayload({ + pickup: { id: 'pickup-1' }, + dropoff: { id: 'dropoff-1' }, + entities: [{ id: 'entity-1' }], + waypoints: [{ id: 'waypoint-1' }], + }); + + assert.true(Object.prototype.hasOwnProperty.call(result, 'entities')); + assert.false(Object.prototype.hasOwnProperty.call(result, 'entitities')); }); }); From 36d66a5b394408d3013c063811c03aff89aef8dd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 24 Apr 2026 11:30:59 +0800 Subject: [PATCH 10/11] Fix transaction currency fallback precedence --- server/src/Models/Order.php | 2 +- server/src/Observers/PurchaseRateObserver.php | 3 +- server/src/Support/Utils.php | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index ee1743a3..07b59950 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1154,7 +1154,7 @@ public function createOrderTransactionWithoutServiceQuote(): ?Transaction 'gateway_transaction_id' => Transaction::generateNumber(), 'gateway' => 'internal', 'amount' => 0, - 'currency' => data_get($this->company, 'country') ? Utils::getCurrenyFromCountryCode(data_get($this->company, 'country')) : 'SGD', + 'currency' => Utils::getCompanyTransactionCurrency($this->company ?? $this->company_uuid), 'description' => 'Dispatch order', 'type' => 'dispatch', 'status' => 'success', diff --git a/server/src/Observers/PurchaseRateObserver.php b/server/src/Observers/PurchaseRateObserver.php index d0703928..d1a3e3dc 100644 --- a/server/src/Observers/PurchaseRateObserver.php +++ b/server/src/Observers/PurchaseRateObserver.php @@ -24,7 +24,8 @@ public function creating(PurchaseRate $purchaseRate) $company = Company::where('uuid', session('company', $purchaseRate->company_uuid))->first(); // get currency to use - $currency = data_get($purchaseRate, 'serviceQuote.currency', $company->country ? Utils::getCurrenyFromCountryCode($company->country) : 'SGD'); + $currency = data_get($purchaseRate, 'serviceQuote.currency') + ?: Utils::getCompanyTransactionCurrency($company ?? $purchaseRate->company_uuid); // create transaction and transaction items $transaction = Transaction::create([ diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 7201c2ce..c509a93c 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -3,6 +3,8 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\Models\Company; +use Fleetbase\Models\Setting; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Support\Utils as FleetbaseUtils; use Illuminate\Support\Arr; @@ -20,6 +22,39 @@ class Utils extends FleetbaseUtils */ public const DRIVING_TIME_MULTIPLIER = 7.2; + /** + * Resolve the preferred transaction currency for a company. + * + * Resolution order: + * 1. Organization/company configured currency + * 2. Ledger accounting base currency + * 3. USD + */ + public static function getCompanyTransactionCurrency(string|Company|null $company = null): string + { + $companyModel = $company instanceof Company + ? $company + : (filled($company) ? Company::where('uuid', $company)->first() : null); + + $companyUuid = $companyModel?->uuid ?? (is_string($company) ? $company : null); + + $companyCurrency = data_get($companyModel, 'currency'); + if (filled($companyCurrency)) { + return strtoupper($companyCurrency); + } + + if (filled($companyUuid)) { + $accountingSettings = Setting::lookup('company.' . $companyUuid . '.ledger.accounting-settings', []); + $baseCurrency = data_get($accountingSettings, 'base_currency'); + + if (filled($baseCurrency)) { + return strtoupper($baseCurrency); + } + } + + return 'USD'; + } + /** * Get a formatted string representation of a place's address. * From d8ffbfaa9413e9f25ffd4ac4b0cfb472cd3ea90b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 24 Apr 2026 11:35:09 +0800 Subject: [PATCH 11/11] bump version to v0.645 and upgrade dependencies --- composer.json | 2 +- extension.json | 2 +- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 1a2bafbe..c069ce26 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.44", + "version": "0.6.45", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 7624b564..d14b5fa5 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.44", + "version": "0.6.45", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 6497f157..1b12ddfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.44", + "version": "0.6.45", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" @@ -44,7 +44,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.18", "@fleetbase/ember-ui": "^0.3.26", - "@fleetbase/fleetops-data": "^0.1.29", + "@fleetbase/fleetops-data": "^0.1.30", "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2438081..3db1bbb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.3.26 version: 0.3.26(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(@glimmer/component@1.1.2(@babel/core@7.27.1))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(postcss@8.5.3)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.27.1))(webpack@5.99.8) '@fleetbase/fleetops-data': - specifier: ^0.1.29 - version: 0.1.29(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8) + specifier: ^0.1.30 + version: 0.1.30(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8) '@fleetbase/leaflet-routing-machine': specifier: ^3.2.17 version: 3.2.17 @@ -1428,8 +1428,8 @@ packages: resolution: {integrity: sha512-qE4AdrrFShEfoQjpItqHj3+OtQfs8pBH7rQPllX8R/MvwpDEOrEXiU7KG8IcQ1teW3YFsWc45FiidABKn+OFRw==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.29': - resolution: {integrity: sha512-EoB5//1I3qbRNUs1zIc+dGfTGn7rVWaUJciuDbDssgg//V3LbV2ISDot4zicAhNZMTooA/qLr17jZKwNGl2+Tg==} + '@fleetbase/fleetops-data@0.1.30': + resolution: {integrity: sha512-n+jCGT2tFnhduHDEkBPENNn9UTaWNSygW9ZmNYiRs6fnGFaRsrMfvBrW+N9galB2dpvA7M8bJO+bl/exkb4dnA==} engines: {node: '>= 18'} '@fleetbase/intl-lint@0.0.1': @@ -11042,7 +11042,7 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.29(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)': + '@fleetbase/fleetops-data@0.1.30(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)': dependencies: '@babel/core': 7.27.1 '@fleetbase/ember-core': 0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)