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/ManagerOrderCostRecalculator.php b/core/components/minishop3/src/Services/Order/ManagerOrderCostRecalculator.php index 73f729da..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 { @@ -67,19 +70,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 +89,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 +102,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 +111,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'), @@ -327,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, @@ -342,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( @@ -363,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 + ); } /** @@ -377,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( @@ -392,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/OrderFinalizeService.php b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php index 893d1433..36c596e2 100644 --- a/core/components/minishop3/src/Services/Order/OrderFinalizeService.php +++ b/core/components/minishop3/src/Services/Order/OrderFinalizeService.php @@ -116,7 +116,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', [ @@ -380,50 +384,37 @@ 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); + return $this->error('ms3_order_finalize_cost_recalc_required', [ + 'warnings' => $warnings, + ]); + } - /** @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'], ]); } 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 new file mode 100644 index 00000000..a1ea1252 --- /dev/null +++ b/core/components/minishop3/tests/ManagerOrderCostRecalculatorRulesTest.php @@ -0,0 +1,236 @@ +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( + [ManagerOrderCostRecalculator::WARNING_DELIVERY_MANUAL_REQUIRED], + $customBreakdown['data']['warnings'], + 'breakdown: custom delivery emits manual warning' +); + +fwrite(STDOUT, "OK ManagerOrderCostRecalculatorRulesTest\n"); +exit(0); 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 @@ + 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