From ecccd1957c84176f53a32f954f5b891a72cdbd61 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 24 Jul 2026 08:46:50 +0600 Subject: [PATCH 1/4] fix(order): align finalize cost with ManagerOrderCostRecalculator Extract calculateBreakdown() from manager recalculate and use it when finalizing draft orders so free_delivery_amount, percent delivery, and payment commission match the recalculate-cost path. Closes #373 --- .../Order/ManagerOrderCostRecalculator.php | 51 ++++++++++--- .../Services/Order/OrderFinalizeService.php | 65 +++++++--------- .../ManagerOrderCostRecalculatorRulesTest.php | 75 +++++++++++++++++++ 3 files changed, 145 insertions(+), 46 deletions(-) create mode 100644 core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php b/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php index 73f729da..3d29f631 100644 --- a/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php +++ b/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php @@ -67,19 +67,17 @@ public function calculateProductTotals(msOrder $order): array } /** + * Compute cost breakdown without persisting the order. + * + * Shared by manager recalculate and draft finalize so both paths use the same rules. + * * @param array $options * @return array{success: bool, message?: string, data?: array} */ - public function recalculate(msOrder $order, array $options = []): array + public function calculateBreakdown(msOrder $order, array $options = []): array { $mode = (string) ($options['mode'] ?? self::MODE_AUTO); - $totals = $this->calculateProductTotals($order); - $cartCost = $totals['cart_cost']; - $orderWeight = $totals['weight']; - - $warnings = []; - if ($mode !== self::MODE_AUTO && $mode !== self::MODE_MANUAL && $mode !== self::MODE_FORCE_PROVIDER) { return $this->ms3->utils->error('ms3_mgr_order_recalc_invalid_mode'); } @@ -88,6 +86,12 @@ public function recalculate(msOrder $order, array $options = []): array return $this->ms3->utils->error('ms3_mgr_order_recalc_manual_delivery_missing'); } + $totals = $this->calculateProductTotals($order); + $cartCost = $totals['cart_cost']; + $orderWeight = $totals['weight']; + + $warnings = []; + $prevDeliveryCost = round((float) $order->get('delivery_cost'), 6); $deliveryResult = $this->resolveDeliveryCost($order, $cartCost, $orderWeight, $prevDeliveryCost, $mode, $options); if (!$deliveryResult['success']) { @@ -95,7 +99,6 @@ public function recalculate(msOrder $order, array $options = []): array } $warnings = array_merge($warnings, $deliveryResult['warnings']); - $deliveryCost = $deliveryResult['delivery_cost']; $paymentBase = round($cartCost + $deliveryCost, 6); @@ -105,13 +108,43 @@ public function recalculate(msOrder $order, array $options = []): array } $warnings = array_merge($warnings, $paymentResult['warnings']); - $paymentFee = $paymentResult['payment_fee']; /** @var OrderService $orderService */ $orderService = $this->modx->services->get('ms3_order_service'); $cost = round($orderService->clampComputedTotal($order, $cartCost, $deliveryCost, $paymentFee), 6); + return $this->ms3->utils->success('', [ + 'breakdown' => [ + 'cart_cost' => $cartCost, + 'weight' => $orderWeight, + 'delivery_cost' => $deliveryCost, + 'payment_cost' => $paymentFee, + 'cost' => $cost, + ], + 'warnings' => $warnings, + ]); + } + + /** + * @param array $options + * @return array{success: bool, message?: string, data?: array} + */ + public function recalculate(msOrder $order, array $options = []): array + { + $result = $this->calculateBreakdown($order, $options); + if (!$result['success']) { + return $result; + } + + $breakdown = $result['data']['breakdown']; + $warnings = $result['data']['warnings']; + $cartCost = $breakdown['cart_cost']; + $orderWeight = $breakdown['weight']; + $deliveryCost = $breakdown['delivery_cost']; + $paymentFee = $breakdown['payment_cost']; + $cost = $breakdown['cost']; + $before = [ 'cart_cost' => (float)$order->get('cart_cost'), 'delivery_cost' => (float)$order->get('delivery_cost'), diff --git a/core/components/minishop3/src/Services/Order/OrderFinalizeService.php b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php index 893d1433..4478119a 100644 --- a/core/components/minishop3/src/Services/Order/OrderFinalizeService.php +++ b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php @@ -106,6 +106,8 @@ public function finalize(int $orderId, array $options = []): array return $costResult; } + $costWarnings = $costResult['data']['warnings'] ?? []; + // Generate order number if not set if (empty($order->get('num'))) { $order->set('num', $this->getNewOrderNum()); @@ -116,7 +118,11 @@ public function finalize(int $orderId, array $options = []): array $order->set('cost', $costResult['data']['total_cost']); $order->set('cart_cost', $costResult['data']['cart_cost']); $order->set('delivery_cost', $costResult['data']['delivery_cost']); - $order->save(); + $order->set('weight', $costResult['data']['weight']); + + if (!$order->save()) { + return $this->error('ms3_err_unknown'); + } // Event: before create order (same as frontend) $response = $this->ms3->utils->invokeEvent('msOnBeforeCreateOrder', [ @@ -169,6 +175,7 @@ public function finalize(int $orderId, array $options = []): array 'order_id' => $order->get('id'), 'order_num' => $order->get('num'), 'status_id' => $order->get('status_id'), + 'cost_warnings' => $costWarnings, ]); } @@ -380,50 +387,34 @@ protected function validateDeliveryRequiredFields(msOrder $order): array */ protected function calculateCosts(msOrder $order): array { - // Calculate cart cost from order products - $cartCost = 0; - $weight = 0; + $recalculator = new ManagerOrderCostRecalculator($this->modx, $this->ms3); + $result = $recalculator->calculateBreakdown($order); - $products = $this->modx->getIterator(msOrderProduct::class, [ - 'order_id' => $order->get('id'), - ]); - - foreach ($products as $product) { - $cartCost += (float) $product->get('cost'); - $weight += (float) $product->get('weight') * (int) $product->get('count'); + if (!$result['success']) { + return $result; } - // Calculate delivery cost - $deliveryCost = 0; - $deliveryId = (int) $order->get('delivery_id'); + $breakdown = $result['data']['breakdown']; + $warnings = $result['data']['warnings'] ?? []; - if ($deliveryId > 0) { - /** @var msDelivery $delivery */ - $delivery = $this->modx->getObject(msDelivery::class, $deliveryId); - if ($delivery) { - // Use delivery's getCost method if available, otherwise use fixed price - $deliveryCost = (float) $delivery->get('price'); - - // Check for weight-based pricing - $weightPrice = (float) $delivery->get('weight_price'); - if ($weightPrice > 0 && $weight > 0) { - $deliveryCost += $weight * $weightPrice; - } - } + if ($warnings !== []) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[OrderFinalizeService] Cost calculation warnings for order #' + . $order->get('id') + . ': ' + . implode(', ', $warnings) + ); } - // Update order weight - $order->set('weight', $weight); - - /** @var OrderService $orderService */ - $orderService = $this->modx->services->get('ms3_order_service'); - $totalCost = $orderService->clampComputedTotal($order, (float) $cartCost, (float) $deliveryCost, 0.0); + $order->set('weight', $breakdown['weight']); return $this->success('', [ - 'cart_cost' => $cartCost, - 'delivery_cost' => $deliveryCost, - 'total_cost' => $totalCost, - 'weight' => $weight, + 'cart_cost' => $breakdown['cart_cost'], + 'delivery_cost' => $breakdown['delivery_cost'], + 'total_cost' => $breakdown['cost'], + 'weight' => $breakdown['weight'], + 'warnings' => $warnings, ]); } diff --git a/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php new file mode 100644 index 00000000..6ef9248a --- /dev/null +++ b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php @@ -0,0 +1,75 @@ + 0 && $cartCost >= $freeDeliveryAmount) { + return 0.0; + } + + $deliveryCost = $weightPrice * $orderWeight; + + if ($addPrice === '') { + return round($deliveryCost, 6); + } + + return round($deliveryCost + PriceAdjustment::calculate($cartCost, $addPrice), 6); +}; + +$assertSame( + 0.0, + $calculateDefaultDeliveryCost(1000, 50, '100', 1500, 2), + 'free delivery when cart meets threshold' +); + +$assertSame( + 70.0, + $calculateDefaultDeliveryCost(0, 10, '5%', 1000, 2), + 'weight cost plus percent of cart' +); + +$assertSame( + 30.0, + round(PriceAdjustment::calculate(1000, '3%'), 6), + 'payment commission percent of cart-only base' +); + +$paymentBase = 1000 + 100; +$assertSame( + 33.0, + round(PriceAdjustment::calculate($paymentBase, '3%'), 6), + 'payment commission percent of cart plus delivery base' +); + +fwrite(STDOUT, "OK ManagerOrderCostRecalculatorRulesTest\n"); +exit(0); From c12cbf95385612f6945596547e7d6485be646548 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 24 Jul 2026 08:48:27 +0600 Subject: [PATCH 2/4] refactor(order): extract OrderPersistedCostRules for shared cost math Move default delivery/payment formulas to a pure helper used by ManagerOrderCostRecalculator and smoke tests. Clarify recalculator docblock for manager recalculate and draft finalize paths. --- .../Order/ManagerOrderCostRecalculator.php | 45 +++++-------- .../Order/OrderPersistedCostRules.php | 63 +++++++++++++++++++ .../ManagerOrderCostRecalculatorRulesTest.php | 37 +++-------- 3 files changed, 87 insertions(+), 58 deletions(-) create mode 100644 core/components/minishop3/src/Services/Order/OrderPersistedCostRules.php diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php b/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php index 3d29f631..deb55c51 100644 --- a/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php +++ b/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php @@ -14,13 +14,16 @@ use MODX\Revolution\modX; /** - * Explicit manager-side recomputation of order totals from persisted msOrderProducts - * and configured delivery/payment methods (without mutating unrelated order fields). + * Recomputation of order totals from persisted msOrderProducts and configured delivery/payment. + * + * Used by manager «Пересчитать стоимость» ({@see calculateBreakdown()} / {@see recalculate()}) + * and draft finalize ({@see OrderFinalizeService}). * * External delivery/payment provider classes are not invoked in {@see self::MODE_AUTO}; * callers should use manual delivery cost or {@see self::MODE_FORCE_PROVIDER}. * * Payment commission is reflected only in aggregated {@see msOrder cost}, not stored in a column. + * Default handler math lives in {@see OrderPersistedCostRules}. */ class ManagerOrderCostRecalculator { @@ -360,14 +363,7 @@ protected function resolvePaymentFee(msOrder $order, float $paymentBase, string protected function calculateDefaultDeliveryCost(msDelivery $delivery, float $cartCost, float $orderWeight): float { - $freeDeliveryAmount = (float)$delivery->get('free_delivery_amount'); - - if ($freeDeliveryAmount > 0 && $cartCost >= $freeDeliveryAmount) { - return 0.0; - } - - $deliveryCost = 0.0; - $weightPrice = (float)$delivery->get('weight_price'); + $weightPrice = (float) $delivery->get('weight_price'); if ($weightPrice < 0) { $this->modx->log( modX::LOG_LEVEL_ERROR, @@ -375,17 +371,10 @@ protected function calculateDefaultDeliveryCost(msDelivery $delivery, float $car 'id' ) . ': ' . $weightPrice, ); - $weightPrice = 0; } - $deliveryCost += $weightPrice * $orderWeight; - $addPrice = $delivery->get('price'); - if (empty($addPrice)) { - return round($deliveryCost, 6); - } - - if (PriceAdjustment::isPercent($addPrice)) { + if (!empty($addPrice) && PriceAdjustment::isPercent($addPrice)) { $percent = PriceAdjustment::getPercent($addPrice); if (!PriceAdjustment::isAllowedPercent($percent)) { $this->modx->log( @@ -396,12 +385,16 @@ protected function calculateDefaultDeliveryCost(msDelivery $delivery, float $car $percent ) ); - - return round($deliveryCost, 6); } } - return round($deliveryCost + PriceAdjustment::calculate($cartCost, $addPrice), 6); + return OrderPersistedCostRules::calculateDefaultDeliveryCost( + (float) $delivery->get('free_delivery_amount'), + $weightPrice, + $addPrice, + $cartCost, + $orderWeight + ); } /** @@ -410,11 +403,7 @@ protected function calculateDefaultDeliveryCost(msDelivery $delivery, float $car protected function calculateDefaultPaymentCommission(msPayment $payment, float $baseCost): float { $addPrice = $payment->get('price'); - if (empty($addPrice)) { - return 0.0; - } - - if (PriceAdjustment::isPercent($addPrice)) { + if (!empty($addPrice) && PriceAdjustment::isPercent($addPrice)) { $percent = PriceAdjustment::getPercent($addPrice); if (!PriceAdjustment::isAllowedPercent($percent)) { $this->modx->log( @@ -425,12 +414,10 @@ protected function calculateDefaultPaymentCommission(msPayment $payment, float $ $percent ) ); - - return 0.0; } } - return round(PriceAdjustment::calculate($baseCost, $addPrice), 6); + return OrderPersistedCostRules::calculateDefaultPaymentCommission($addPrice, $baseCost); } protected function isSimpleDelivery(msDelivery $delivery): bool diff --git a/core/components/minishop3/src/Services/Order/OrderPersistedCostRules.php b/core/components/minishop3/src/Services/Order/OrderPersistedCostRules.php new file mode 100644 index 00000000..231629d9 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/OrderPersistedCostRules.php @@ -0,0 +1,63 @@ + 0 && $cartCost >= $freeDeliveryAmount) { + return 0.0; + } + + if ($weightPrice < 0) { + $weightPrice = 0.0; + } + + $deliveryCost = $weightPrice * $orderWeight; + + if (empty($addPrice)) { + return round($deliveryCost, 6); + } + + if (PriceAdjustment::isPercent($addPrice)) { + $percent = PriceAdjustment::getPercent($addPrice); + if (!PriceAdjustment::isAllowedPercent($percent)) { + return round($deliveryCost, 6); + } + } + + return round($deliveryCost + PriceAdjustment::calculate($cartCost, $addPrice), 6); + } + + /** + * Surcharge only (excluding base), aligned with {@see \MiniShop3\Controllers\Payment\Payment::getCost()}. + */ + public static function calculateDefaultPaymentCommission(mixed $addPrice, float $baseCost): float + { + if (empty($addPrice)) { + return 0.0; + } + + if (PriceAdjustment::isPercent($addPrice)) { + $percent = PriceAdjustment::getPercent($addPrice); + if (!PriceAdjustment::isAllowedPercent($percent)) { + return 0.0; + } + } + + return round(PriceAdjustment::calculate($baseCost, $addPrice), 6); + } +} diff --git a/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php index 6ef9248a..a87d44a0 100644 --- a/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php +++ b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php @@ -1,10 +1,10 @@ 0 && $cartCost >= $freeDeliveryAmount) { - return 0.0; - } - - $deliveryCost = $weightPrice * $orderWeight; - - if ($addPrice === '') { - return round($deliveryCost, 6); - } - - return round($deliveryCost + PriceAdjustment::calculate($cartCost, $addPrice), 6); -}; - $assertSame( 0.0, - $calculateDefaultDeliveryCost(1000, 50, '100', 1500, 2), + OrderPersistedCostRules::calculateDefaultDeliveryCost(1000, 50, '100', 1500, 2), 'free delivery when cart meets threshold' ); $assertSame( 70.0, - $calculateDefaultDeliveryCost(0, 10, '5%', 1000, 2), + OrderPersistedCostRules::calculateDefaultDeliveryCost(0, 10, '5%', 1000, 2), 'weight cost plus percent of cart' ); $assertSame( 30.0, - round(PriceAdjustment::calculate(1000, '3%'), 6), + OrderPersistedCostRules::calculateDefaultPaymentCommission('3%', 1000), 'payment commission percent of cart-only base' ); -$paymentBase = 1000 + 100; $assertSame( 33.0, - round(PriceAdjustment::calculate($paymentBase, '3%'), 6), + OrderPersistedCostRules::calculateDefaultPaymentCommission('3%', 1100), 'payment commission percent of cart plus delivery base' ); From addbe30d5e9bbb2f1c6aff189bfe61f2d1a4ec4c Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 24 Jul 2026 08:50:31 +0600 Subject: [PATCH 3/4] fix(order): block finalize when cost breakdown has warnings Return error from calculateCosts when ManagerOrderCostRecalculator reports degraded AUTO breakdown so draft orders are not finalized with potentially wrong totals. Surface warnings in Vue with recalc hints. --- .../minishop3/lexicon/en/default.inc.php | 2 ++ .../minishop3/lexicon/en/vue.inc.php | 2 ++ .../minishop3/lexicon/ru/default.inc.php | 2 ++ .../minishop3/lexicon/ru/vue.inc.php | 2 ++ .../Services/Order/OrderFinalizeService.php | 8 +++--- vueManager/src/components/OrderView.vue | 28 +++++++++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 2a79dfe9..3c66b296 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -226,3 +226,5 @@ $_lang['ms3_mgr_order_recalc_invalid_mode'] = 'Invalid order cost recalculation mode.'; $_lang['ms3_mgr_order_recalc_manual_delivery_missing'] = 'Manual delivery cost (manual_delivery_cost) is required in manual mode.'; $_lang['ms3_order_cost_recalc_success'] = 'Order cost recalculated'; +$_lang['ms3_order_finalize_cost_recalc_required'] = + 'Recalculate order cost before finalizing: the selected delivery or payment requires manual cost or force_provider mode.'; diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index df128018..dc9c1d59 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -775,6 +775,8 @@ $_lang['ms3_order_finalize_confirm_desc'] = 'After finalization, the order will receive a number, status will change to "New", and notifications will be sent.'; $_lang['ms3_order_finalized'] = 'Order successfully finalized'; $_lang['ms3_order_finalize_error'] = 'Error finalizing order'; +$_lang['ms3_order_finalize_cost_recalc_required'] = + 'Recalculate order cost before finalizing: the selected delivery or payment requires manual cost or force_provider mode.'; $_lang['ms3_order_is_draft'] = 'Draft'; $_lang['ms3_order_err_validation'] = 'Order data validation error'; $_lang['ms3_order_err_products'] = 'Order has no products'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 1a01983e..a9de62f0 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -226,3 +226,5 @@ $_lang['ms3_mgr_order_recalc_invalid_mode'] = 'Недопустимый режим пересчёта стоимости заказа.'; $_lang['ms3_mgr_order_recalc_manual_delivery_missing'] = 'В режиме manual обязательно укажите manual_delivery_cost (стоимость доставки).'; $_lang['ms3_order_cost_recalc_success'] = 'Стоимость заказа пересчитана'; +$_lang['ms3_order_finalize_cost_recalc_required'] = + 'Перед оформлением пересчитайте стоимость заказа: для выбранных доставки или оплаты нужен ручной расчёт или force_provider.'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 4a3da770..d8150eeb 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -775,6 +775,8 @@ $_lang['ms3_order_finalize_confirm_desc'] = 'После оформления заказ получит номер, статус изменится на «Новый», и будут отправлены уведомления.'; $_lang['ms3_order_finalized'] = 'Заказ успешно оформлен'; $_lang['ms3_order_finalize_error'] = 'Ошибка при оформлении заказа'; +$_lang['ms3_order_finalize_cost_recalc_required'] = + 'Перед оформлением пересчитайте стоимость заказа: для выбранных доставки или оплаты нужен ручной расчёт или force_provider.'; $_lang['ms3_order_is_draft'] = 'Черновик'; $_lang['ms3_order_err_validation'] = 'Ошибка валидации данных заказа'; $_lang['ms3_order_err_products'] = 'В заказе нет товаров'; diff --git a/core/components/minishop3/src/Services/Order/OrderFinalizeService.php b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php index 4478119a..36c596e2 100644 --- a/core/components/minishop3/src/Services/Order/OrderFinalizeService.php +++ b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php @@ -106,8 +106,6 @@ public function finalize(int $orderId, array $options = []): array return $costResult; } - $costWarnings = $costResult['data']['warnings'] ?? []; - // Generate order number if not set if (empty($order->get('num'))) { $order->set('num', $this->getNewOrderNum()); @@ -175,7 +173,6 @@ public function finalize(int $orderId, array $options = []): array 'order_id' => $order->get('id'), 'order_num' => $order->get('num'), 'status_id' => $order->get('status_id'), - 'cost_warnings' => $costWarnings, ]); } @@ -405,6 +402,10 @@ protected function calculateCosts(msOrder $order): array . ': ' . implode(', ', $warnings) ); + + return $this->error('ms3_order_finalize_cost_recalc_required', [ + 'warnings' => $warnings, + ]); } $order->set('weight', $breakdown['weight']); @@ -414,7 +415,6 @@ protected function calculateCosts(msOrder $order): array 'delivery_cost' => $breakdown['delivery_cost'], 'total_cost' => $breakdown['cost'], 'weight' => $breakdown['weight'], - 'warnings' => $warnings, ]); } diff --git a/vueManager/src/components/OrderView.vue b/vueManager/src/components/OrderView.vue index 589f7286..d89ac6e8 100644 --- a/vueManager/src/components/OrderView.vue +++ b/vueManager/src/components/OrderView.vue @@ -1139,6 +1139,14 @@ function confirmFinalizeOrder() { }) } +/** Hint keys for cost recalculation warnings (ManagerOrderCostRecalculator). */ +const COST_RECALC_WARNING_HINTS = Object.freeze({ + delivery_manual_required: 'order_cost_recalc_delivery_manual_hint', + payment_manual_required: 'order_cost_recalc_payment_manual_hint', + delivery_provider_error: 'order_cost_recalc_delivery_manual_hint', + payment_provider_error: 'order_cost_recalc_payment_manual_hint', +}) + /** * Finalize order API call */ @@ -1182,6 +1190,26 @@ async function finalizeOrder(forceCreateCustomer = false) { } catch (error) { console.error('[OrderView] Error finalizing order:', error) + const apiErrors = error.data?.errors + const costWarnings = Array.isArray(apiErrors?.warnings) + ? apiErrors.warnings.map(String) + : [] + + if (costWarnings.length > 0) { + costRecalcWarnings.value = costWarnings + costWarnings.forEach(code => { + const hintKey = COST_RECALC_WARNING_HINTS[code] + if (hintKey) { + toast.add({ + severity: 'warn', + summary: _('error'), + detail: _(hintKey), + life: 7000, + }) + } + }) + } + // Show each validation error as separate toast // Response structure: error.data.object.errors contains array of field names const validationErrors = error.data?.object?.errors From efb496e0c8ffc819c7c743f8c189efa1e04bb924 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 24 Jul 2026 08:52:19 +0600 Subject: [PATCH 4/4] test(order): exercise calculateBreakdown in cost rules smoke test Add xPDO/modX stubs and integration cases for ManagerOrderCostRecalculator including free delivery, cart+delivery payment fee, and custom handler warnings. --- .../ManagerOrderCostRecalculatorRulesTest.php | 202 +++++++++++++++++- .../stubs/OrderCostRecalculatorModxStub.php | 110 ++++++++++ .../minishop3/tests/stubs/XpdoStub.php | 21 ++ 3 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 core/components/minishop3/tests/stubs/OrderCostRecalculatorModxStub.php create mode 100644 core/components/minishop3/tests/stubs/XpdoStub.php diff --git a/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php index a87d44a0..a1ea1252 100644 --- a/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php +++ b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php @@ -3,17 +3,28 @@ /** * Regression smoke tests for persisted order cost rules (#373). * - * Calls {@see OrderPersistedCostRules} — the same pure helpers used by - * ManagerOrderCostRecalculator::calculateBreakdown(). + * 1. {@see OrderPersistedCostRules} — pure formulas. + * 2. {@see ManagerOrderCostRecalculator::calculateBreakdown()} — production path with stubs. * * Run: php tests/ManagerOrderCostRecalculatorRulesTest.php */ declare(strict_types=1); +require __DIR__ . '/stubs/ModxStub.php'; +require __DIR__ . '/stubs/XpdoStub.php'; +require __DIR__ . '/stubs/OrderCostRecalculatorModxStub.php'; require __DIR__ . '/../vendor/autoload.php'; +use MiniShop3\MiniShop3; +use MiniShop3\Model\msDelivery; +use MiniShop3\Model\msOrder; +use MiniShop3\Model\msOrderProduct; +use MiniShop3\Model\msPayment; +use MiniShop3\Services\Order\ManagerOrderCostRecalculator; use MiniShop3\Services\Order\OrderPersistedCostRules; +use MiniShop3\Utils\Utils; +use MODX\Revolution\OrderCostRecalculatorModxStub; $fail = static function (string $message): never { fwrite(STDERR, "FAIL: {$message}\n"); @@ -26,28 +37,199 @@ } }; +$assertTrue = static function (bool $value, string $case) use ($fail): void { + if (!$value) { + $fail($case); + } +}; + +// --- OrderPersistedCostRules (pure helpers) --- + $assertSame( 0.0, OrderPersistedCostRules::calculateDefaultDeliveryCost(1000, 50, '100', 1500, 2), - 'free delivery when cart meets threshold' + 'rules: free delivery when cart meets threshold' ); $assertSame( 70.0, OrderPersistedCostRules::calculateDefaultDeliveryCost(0, 10, '5%', 1000, 2), - 'weight cost plus percent of cart' + 'rules: weight cost plus percent of cart' ); $assertSame( - 30.0, - OrderPersistedCostRules::calculateDefaultPaymentCommission('3%', 1000), - 'payment commission percent of cart-only base' + 33.0, + OrderPersistedCostRules::calculateDefaultPaymentCommission('3%', 1100), + 'rules: payment commission on cart plus delivery base' ); +// --- ManagerOrderCostRecalculator::calculateBreakdown() --- + +$createMs3 = static function (OrderCostRecalculatorModxStub $modx): MiniShop3 { + $ref = new ReflectionClass(MiniShop3::class); + /** @var MiniShop3 $instance */ + $instance = $ref->newInstanceWithoutConstructor(); + $instance->modx = $modx; + $instance->utils = new Utils($instance); + + return $instance; +}; + +$orderStub = static function (array $fields): msOrder { + return new class($fields) extends msOrder { + public function __construct(private array $fields) + { + } + + public function get($key, $format = null, $formatTemplate = null): mixed + { + return $this->fields[$key] ?? null; + } + }; +}; + +$deliveryStub = static function (array $fields): msDelivery { + return new class($fields) extends msDelivery { + public function __construct(private array $fields) + { + } + + public function get($key, $format = null, $formatTemplate = null): mixed + { + return $this->fields[$key] ?? null; + } + }; +}; + +$paymentStub = static function (array $fields): msPayment { + return new class($fields) extends msPayment { + public function __construct(private array $fields) + { + } + + public function get($key, $format = null, $formatTemplate = null): mixed + { + return $this->fields[$key] ?? null; + } + }; +}; + +$productStub = static function (array $fields): msOrderProduct { + return new class($fields) extends msOrderProduct { + public function __construct(private array $fields) + { + } + + public function get($key, $format = null, $formatTemplate = null): mixed + { + return $this->fields[$key] ?? null; + } + }; +}; + +$modx = new OrderCostRecalculatorModxStub(); +$ms3 = $createMs3($modx); +$recalculator = new ManagerOrderCostRecalculator($modx, $ms3); + +$orderFreeDelivery = $orderStub([ + 'id' => 10, + 'delivery_id' => 1, + 'payment_id' => 0, + 'delivery_cost' => 0, +]); + +$modx->registerObject(msDelivery::class, $deliveryStub([ + 'id' => 1, + 'class' => '', + 'free_delivery_amount' => 1000, + 'weight_price' => 50, + 'price' => '100', +])); + +$modx->registerIteratorObject(msOrderProduct::class, $productStub([ + 'order_id' => 10, + 'cost' => 1500, + 'weight' => 1, + 'count' => 1, +])); + +$freeDeliveryBreakdown = $recalculator->calculateBreakdown($orderFreeDelivery); +$assertTrue($freeDeliveryBreakdown['success'] === true, 'breakdown: free delivery success'); +$assertSame([], $freeDeliveryBreakdown['data']['warnings'], 'breakdown: free delivery no warnings'); +$assertSame(1500.0, $freeDeliveryBreakdown['data']['breakdown']['cart_cost'], 'breakdown: cart cost'); +$assertSame(0.0, $freeDeliveryBreakdown['data']['breakdown']['delivery_cost'], 'breakdown: free delivery cost'); +$assertSame(1500.0, $freeDeliveryBreakdown['data']['breakdown']['cost'], 'breakdown: total with free delivery'); + +$modx2 = new OrderCostRecalculatorModxStub(); +$ms3b = $createMs3($modx2); +$recalculator2 = new ManagerOrderCostRecalculator($modx2, $ms3b); + +$orderFull = $orderStub([ + 'id' => 20, + 'delivery_id' => 2, + 'payment_id' => 3, + 'delivery_cost' => 0, +]); + +$modx2->registerObject(msDelivery::class, $deliveryStub([ + 'id' => 2, + 'class' => '', + 'free_delivery_amount' => 0, + 'weight_price' => 10, + 'price' => '5%', +])); + +$modx2->registerObject(msPayment::class, $paymentStub([ + 'id' => 3, + 'class' => '', + 'price' => '3%', +])); + +$modx2->registerIteratorObject(msOrderProduct::class, $productStub([ + 'order_id' => 20, + 'cost' => 1000, + 'weight' => 2, + 'count' => 1, +])); + +$fullBreakdown = $recalculator2->calculateBreakdown($orderFull); +$assertTrue($fullBreakdown['success'] === true, 'breakdown: percent delivery and payment success'); +$assertSame([], $fullBreakdown['data']['warnings'], 'breakdown: default handlers no warnings'); +$assertSame(70.0, $fullBreakdown['data']['breakdown']['delivery_cost'], 'breakdown: percent delivery cost'); +$assertSame(32.1, $fullBreakdown['data']['breakdown']['payment_cost'], 'breakdown: payment fee on cart plus delivery'); +$assertSame(1102.1, $fullBreakdown['data']['breakdown']['cost'], 'breakdown: integrated total'); + +$modx3 = new OrderCostRecalculatorModxStub(); +$recalculator3 = new ManagerOrderCostRecalculator($modx3, $createMs3($modx3)); + +$orderCustom = $orderStub([ + 'id' => 30, + 'delivery_id' => 4, + 'payment_id' => 0, + 'delivery_cost' => 0, +]); + +$modx3->registerObject(msDelivery::class, $deliveryStub([ + 'id' => 4, + 'class' => 'Vendor\\CustomDelivery', + 'free_delivery_amount' => 0, + 'weight_price' => 10, + 'price' => '100', +])); + +$modx3->registerIteratorObject(msOrderProduct::class, $productStub([ + 'order_id' => 30, + 'cost' => 500, + 'weight' => 1, + 'count' => 1, +])); + +$customBreakdown = $recalculator3->calculateBreakdown($orderCustom); +$assertTrue($customBreakdown['success'] === true, 'breakdown: custom delivery still success'); $assertSame( - 33.0, - OrderPersistedCostRules::calculateDefaultPaymentCommission('3%', 1100), - 'payment commission percent of cart plus delivery base' + [ManagerOrderCostRecalculator::WARNING_DELIVERY_MANUAL_REQUIRED], + $customBreakdown['data']['warnings'], + 'breakdown: custom delivery emits manual warning' ); fwrite(STDOUT, "OK ManagerOrderCostRecalculatorRulesTest\n"); diff --git a/core/components/minishop3/tests/stubs/OrderCostRecalculatorModxStub.php b/core/components/minishop3/tests/stubs/OrderCostRecalculatorModxStub.php new file mode 100644 index 00000000..1fbb2efe --- /dev/null +++ b/core/components/minishop3/tests/stubs/OrderCostRecalculatorModxStub.php @@ -0,0 +1,110 @@ +> */ + private array $objectsByClass = []; + + /** @var array> */ + private array $iteratorsByClass = []; + + public function lexicon(string $key, array $placeholders = []): string + { + return $key; + } + + public function getOption(string $key, $options = null, $default = null, $skipEvents = false): mixed + { + return $default; + } + + public $services; + + public function __construct() + { + parent::__construct(); + $this->services = new class($this) { + public function __construct(private OrderCostRecalculatorModxStub $modx) + { + } + + public function get(string $key): object + { + if ($key === 'ms3_order_service') { + return new \MiniShop3\Services\Order\OrderService($this->modx); + } + + throw new \RuntimeException('Unknown service: ' . $key); + } + + public function has(string $key): bool + { + return false; + } + }; + } + + public function registerObject(string $class, object $object): void + { + $this->objectsByClass[$class][] = $object; + } + + /** + * @param array $criteria + */ + public function getObject(string $class, array $criteria = []): ?object + { + foreach ($this->objectsByClass[$class] ?? [] as $object) { + if ($this->matchesCriteria($object, $criteria)) { + return $object; + } + } + + return null; + } + + /** + * @param array $criteria + * @return iterable + */ + public function getIterator(string $class, array $criteria = []): iterable + { + foreach ($this->iteratorsByClass[$class] ?? [] as $object) { + if ($this->matchesCriteria($object, $criteria)) { + yield $object; + } + } + } + + /** + * @param array $criteria + */ + public function registerIteratorObject(string $class, object $object): void + { + $this->iteratorsByClass[$class][] = $object; + } + + /** + * @param array $criteria + */ + private function matchesCriteria(object $object, array $criteria): bool + { + foreach ($criteria as $key => $expected) { + if (!method_exists($object, 'get')) { + return false; + } + if ($object->get($key) != $expected) { + return false; + } + } + + return true; + } +} diff --git a/core/components/minishop3/tests/stubs/XpdoStub.php b/core/components/minishop3/tests/stubs/XpdoStub.php new file mode 100644 index 00000000..5363f17f --- /dev/null +++ b/core/components/minishop3/tests/stubs/XpdoStub.php @@ -0,0 +1,21 @@ +