From 9b9b3f72c1b1ffdacb02f5f3fbef2320002d973e Mon Sep 17 00:00:00 2001 From: ah-net <103565001+ah-net@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:49:51 +0200 Subject: [PATCH 1/4] feat: calculate group/bundle prices --- Model/Config.php | 12 ++ Model/Write/Price/IteratorInitializer.php | 1 + .../Products/CollectionDecorator/Price.php | 201 +++++++++++++++++- etc/adminhtml/system.xml | 18 ++ etc/config.xml | 2 + 5 files changed, 227 insertions(+), 7 deletions(-) diff --git a/Model/Config.php b/Model/Config.php index 151ae3c..23770d6 100644 --- a/Model/Config.php +++ b/Model/Config.php @@ -40,6 +40,8 @@ class Config public const BATCH_SIZE_PRODUCTS = 'tweakwise/export/batch_size_products'; public const BATCH_SIZE_PRODUCTS_CHILDREN = 'tweakwise/export/batch_size_products_children'; public const PATH_SKIP_CHILD_BY_COMPOSITE_TYPE = 'tweakwise/export/skip_child_by_composite_type'; + public const CALCULATE_COMPOSITE_PRICES = 'tweakwise/export/calculate_composite_prices'; + public const ADD_TAX_TO_PRICES = 'tweakwise/export/add_tax_to_prices'; /** * Default feed filename @@ -305,4 +307,14 @@ public function getBatchSizeProductsChildren(): int { return (int) $this->config->getValue(self::BATCH_SIZE_PRODUCTS_CHILDREN); } + + public function calculateCombinedPrices($store = null): bool + { + return (bool) $this->config->isSetFlag(self::CALCULATE_COMPOSITE_PRICES, ScopeInterface::SCOPE_STORE, $store); + } + + public function addVat($store = null): bool + { + return (bool) $this->config->isSetFlag(self::ADD_TAX_TO_PRICES, ScopeInterface::SCOPE_STORE, $store); + } } diff --git a/Model/Write/Price/IteratorInitializer.php b/Model/Write/Price/IteratorInitializer.php index af25a32..505b3f1 100644 --- a/Model/Write/Price/IteratorInitializer.php +++ b/Model/Write/Price/IteratorInitializer.php @@ -34,5 +34,6 @@ public function initializeAttributes(EavIterator $iterator) $iterator->selectAttribute('status'); $iterator->selectAttribute('visibility'); $iterator->selectAttribute('type_id'); + $iterator->selectAttribute('tax_class_id'); } } diff --git a/Model/Write/Products/CollectionDecorator/Price.php b/Model/Write/Products/CollectionDecorator/Price.php index c5f705e..6a4fbf7 100644 --- a/Model/Write/Products/CollectionDecorator/Price.php +++ b/Model/Write/Products/CollectionDecorator/Price.php @@ -3,6 +3,10 @@ namespace Tweakwise\Magento2TweakwiseExport\Model\Write\Products\CollectionDecorator; // phpcs:disable Magento2.Legacy.RestrictedCode.ZendDbSelect +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Tax\Model\Calculation; +use Tweakwise\Magento2TweakwiseExport\Exception\InvalidArgumentException; use Tweakwise\Magento2TweakwiseExport\Model\Config; use Tweakwise\Magento2TweakwiseExport\Model\Write\Products\Collection; use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; @@ -10,9 +14,11 @@ use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; use Magento\Store\Model\StoreManagerInterface; use Zend_Db_Select; +use Magento\Tax\Model\TaxCalculation; class Price implements DecoratorInterface { + private const XML_PATH_ROUNDING_METHOD = 'tax/calculation/rounding_method'; /** * @var CollectionFactory */ @@ -28,16 +34,30 @@ class Price implements DecoratorInterface */ protected $config; + /** + * @var float + */ + private float $exchangeRate = 1.0; + + /** + * @var string|null + */ + private ?string $roundingMethod = null; + /** * Price constructor. * @param CollectionFactory $collectionFactory * @param StoreManagerInterface $storeManager * @param Config $config + * @param Calculation $taxCalculation + * @param ScopeConfigInterface $scopeConfig */ public function __construct( CollectionFactory $collectionFactory, StoreManagerInterface $storeManager, - Config $config + Config $config, + private readonly Calculation $taxCalculation, + private readonly ScopeConfigInterface $scopeConfig ) { $this->collectionFactory = $collectionFactory; $this->storeManager = $storeManager; @@ -50,16 +70,18 @@ public function __construct( */ public function decorate(Collection|PriceCollection $collection): void { + $store = $collection->getStore(); $websiteId = $collection->getStore()->getWebsiteId(); - $priceSelect = $this->createPriceSelect($collection->getIds(), $websiteId); + $priceSelect = $this->createPriceSelect($collection->getIds(), $websiteId); $priceQuery = $priceSelect->getSelect()->query(); - $currency = $collection->getStore()->getCurrentCurrency(); - $exchangeRate = 1; + $currency = $collection->getStore()->getCurrentCurrency(); if ($collection->getStore()->getCurrentCurrencyRate() > 0.00001) { $exchangeRate = (float)$collection->getStore()->getCurrentCurrencyRate(); } + $this->exchangeRate = $exchangeRate ?? 1.0; + $this->roundingMethod = $this->scopeConfig->getValue(self::XML_PATH_ROUNDING_METHOD, \Magento\Store\Model\ScopeInterface::SCOPE_STORE, $store->getCode()); $priceFields = $this->config->getPriceFields($collection->getStore()->getId()); @@ -68,15 +90,86 @@ public function decorate(Collection|PriceCollection $collection): void $row['currency'] = $currency->getCurrencyCode(); $row['price'] = $this->getPriceValue($row, $priceFields); - //do all prices * exchange rate - foreach ($priceFields as $priceField) { - $row[$priceField] = (float) ($row[$priceField] * $exchangeRate); + $product = $this->collectionFactory->create()->getItemById($entityId); + $taxClassId = $this->getTaxClassId($collection->get($entityId)); + + if ($this->config->calculateCombinedPrices($store) && $this->isGroupedProduct($product)) { + $row['price'] = $this->calculateGroupedProductPrice($entityId, $store, $taxClassId); + } elseif ($this->config->calculateCombinedPrices($store) && $this->isBundleProduct($product)) { + $row['price'] = $this->calculateBundleProductPrice($entityId, $store, $taxClassId); + } else { + foreach ($priceFields as $priceField) { + $row[$priceField] = $this->calculatePrice((float)$row[$priceField], $taxClassId, $store); + } } $collection->get($entityId)->setFromArray($row); } } + /** + * @param float $value + * @return float + */ + private function applyRoundingMethod(float $value): float + { + return match ($this->roundingMethod) { + 'ceil' => ceil($value), + 'floor' => floor($value), + default => round($value, 2), + }; + } + + /** + * @param float $price + * @param int|null $taxClassId + * @param Store $store + * @return float + */ + private function calculatePrice(float $price, ?int $taxClassId, $store): float + { + if ($this->config->addVat($store)) { + $price = $this->addVat($price, $taxClassId, $store); + } + + $price = $this->calculateExchangeRate($price); + + return $price; + } + + /** + * @param float $price + * @param int|null $taxClassId + * @param Store $store + * @return float + */ + private function addVat(float $price, ?int $taxClassId, $store): float + { + $rateRequest = $this->taxCalculation->getRateRequest(null, null, null, $store); + $taxRate = $this->taxCalculation->getRate($rateRequest->setProductClassId($taxClassId)); + + $price = $this->applyRoundingMethod( + $price * (1 + $taxRate / 100), + $this->roundingMethod + ); + + return $price; + } + + /** + * @param float $price + * @return float + */ + private function calculateExchangeRate(float $price): float + { + return $this->applyRoundingMethod( + $price * $this->exchangeRate, + $this->roundingMethod + ); + + return $price; + } + /** * @param array $ids * @param int $websiteId @@ -120,4 +213,98 @@ protected function getPriceValue(array $priceData, array $priceFields): float return 0; } + + /** + * @param ProductInterface $product + * @return int|null + */ + protected function getTaxClassId($product): ?int + { + try { + if (isset($product->getAttribute('tax_class_id')[0])) { + return $product->getAttribute('tax_class_id')[0]; + } + return null; + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param ProductInterface $product + * @return bool + */ + protected function isGroupedProduct(ProductInterface $product): bool + { + return $product?->getTypeId() === \Magento\GroupedProduct\Model\Product\Type\Grouped::TYPE_CODE; + } + + /** + * @param ProductInterface $product + * @return bool + */ + protected function isBundleProduct($product): bool + { + return $product?->getTypeId() === \Magento\Bundle\Model\Product\Type::TYPE_CODE; + } + + /** + * @param int $entityId + * @param callable $getAssociatedItems + * @param Store $store + * @param int|null $taxClassId + * @return float + */ + protected function calculateProductPrice(int $entityId, callable $getAssociatedItems, $store, ?int $taxClassId): float + { + $product = $this->collectionFactory->create()->getItemById($entityId); + $associatedItems = $getAssociatedItems($product); + + // Convert collection to array if necessary + if ($associatedItems instanceof \Magento\Framework\Data\Collection) { + $associatedItems = $associatedItems->getItems(); + } + + return array_reduce($associatedItems, function ($total, $item) use ($store, $taxClassId) { + $basePrice = $item->getPrice(); + $price = $this->calculatePrice( + $basePrice, + $taxClassId, + $store + ); + + return $total + ($price * $item->getQty()); + }, 0); + } + + /** + * @param int $entityId + * @param Store $store + * @param int|null $taxClassId + * @return float + */ + protected function calculateGroupedProductPrice(int $entityId, $store, ?int $taxClassId): float + { + return $this->calculateProductPrice($entityId, function ($product) { + + return $product->getTypeInstance()->getAssociatedProducts($product); + }, $store, $taxClassId); + } + + /** + * @param int $entityId + * @param Store $store + * @param int|null $taxClassId + * @return float + */ + protected function calculateBundleProductPrice(int $entityId, $store, ?int $taxClassId): float + { + return $this->calculateProductPrice($entityId, function ($product) { + + return $product->getTypeInstance()->getSelectionsCollection( + $product->getTypeInstance()->getOptionsIds($product), + $product + ); + }, $store, $taxClassId); + } } diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 4147335..090584f 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -111,6 +111,24 @@ + + + Would you like to export the combined price of an grouped/bundle product? + Magento\Config\Model\Config\Source\Yesno + + 1 + + + + + + Add the vat to the prices exported. Only enable this if the prices exported to Tweakwise are missing the vat + Magento\Config\Model\Config\Source\Yesno + + 1 + + + 5000 5000 5000 + 0 + 0 0 From 24a707feb268db8c2caa615c4336dff1bb2bf116 Mon Sep 17 00:00:00 2001 From: ah-net <103565001+ah-net@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:06:57 +0200 Subject: [PATCH 2/4] Style fixes --- Model/Config.php | 8 ++++++++ .../Products/CollectionDecorator/Price.php | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Model/Config.php b/Model/Config.php index 23770d6..c55f74e 100644 --- a/Model/Config.php +++ b/Model/Config.php @@ -308,11 +308,19 @@ public function getBatchSizeProductsChildren(): int return (int) $this->config->getValue(self::BATCH_SIZE_PRODUCTS_CHILDREN); } + /** + * @param Store|int|string|null $store + * @return bool + */ public function calculateCombinedPrices($store = null): bool { return (bool) $this->config->isSetFlag(self::CALCULATE_COMPOSITE_PRICES, ScopeInterface::SCOPE_STORE, $store); } + /** + * @param Store|int|string|null $store + * @return bool + */ public function addVat($store = null): bool { return (bool) $this->config->isSetFlag(self::ADD_TAX_TO_PRICES, ScopeInterface::SCOPE_STORE, $store); diff --git a/Model/Write/Products/CollectionDecorator/Price.php b/Model/Write/Products/CollectionDecorator/Price.php index 6a4fbf7..8a73f83 100644 --- a/Model/Write/Products/CollectionDecorator/Price.php +++ b/Model/Write/Products/CollectionDecorator/Price.php @@ -19,6 +19,7 @@ class Price implements DecoratorInterface { private const XML_PATH_ROUNDING_METHOD = 'tax/calculation/rounding_method'; + /** * @var CollectionFactory */ @@ -80,8 +81,13 @@ public function decorate(Collection|PriceCollection $collection): void if ($collection->getStore()->getCurrentCurrencyRate() > 0.00001) { $exchangeRate = (float)$collection->getStore()->getCurrentCurrencyRate(); } + $this->exchangeRate = $exchangeRate ?? 1.0; - $this->roundingMethod = $this->scopeConfig->getValue(self::XML_PATH_ROUNDING_METHOD, \Magento\Store\Model\ScopeInterface::SCOPE_STORE, $store->getCode()); + $this->roundingMethod = $this->scopeConfig->getValue( + self::XML_PATH_ROUNDING_METHOD, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $store->getCode() + ); $priceFields = $this->config->getPriceFields($collection->getStore()->getId()); @@ -166,8 +172,6 @@ private function calculateExchangeRate(float $price): float $price * $this->exchangeRate, $this->roundingMethod ); - - return $price; } /** @@ -224,6 +228,7 @@ protected function getTaxClassId($product): ?int if (isset($product->getAttribute('tax_class_id')[0])) { return $product->getAttribute('tax_class_id')[0]; } + return null; } catch (InvalidArgumentException) { return null; @@ -286,9 +291,9 @@ protected function calculateProductPrice(int $entityId, callable $getAssociatedI protected function calculateGroupedProductPrice(int $entityId, $store, ?int $taxClassId): float { return $this->calculateProductPrice($entityId, function ($product) { - return $product->getTypeInstance()->getAssociatedProducts($product); - }, $store, $taxClassId); + } + , $store, $taxClassId); } /** @@ -300,11 +305,11 @@ protected function calculateGroupedProductPrice(int $entityId, $store, ?int $tax protected function calculateBundleProductPrice(int $entityId, $store, ?int $taxClassId): float { return $this->calculateProductPrice($entityId, function ($product) { - return $product->getTypeInstance()->getSelectionsCollection( $product->getTypeInstance()->getOptionsIds($product), $product ); - }, $store, $taxClassId); + } + , $store, $taxClassId); } } From 2f07b1dc6b8ea5fa0fc36dc47c64947a5331f274 Mon Sep 17 00:00:00 2001 From: ah-net <103565001+ah-net@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:16:20 +0200 Subject: [PATCH 3/4] Style fixes --- .../Products/CollectionDecorator/Price.php | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/Model/Write/Products/CollectionDecorator/Price.php b/Model/Write/Products/CollectionDecorator/Price.php index 8a73f83..1bc7cbc 100644 --- a/Model/Write/Products/CollectionDecorator/Price.php +++ b/Model/Write/Products/CollectionDecorator/Price.php @@ -260,7 +260,12 @@ protected function isBundleProduct($product): bool * @param int|null $taxClassId * @return float */ - protected function calculateProductPrice(int $entityId, callable $getAssociatedItems, $store, ?int $taxClassId): float + protected function calculateProductPrice( + int $entityId, + callable $getAssociatedItems, + $store, + ?int $taxClassId + ): float { $product = $this->collectionFactory->create()->getItemById($entityId); $associatedItems = $getAssociatedItems($product); @@ -270,16 +275,19 @@ protected function calculateProductPrice(int $entityId, callable $getAssociatedI $associatedItems = $associatedItems->getItems(); } - return array_reduce($associatedItems, function ($total, $item) use ($store, $taxClassId) { - $basePrice = $item->getPrice(); - $price = $this->calculatePrice( - $basePrice, - $taxClassId, - $store - ); - - return $total + ($price * $item->getQty()); - }, 0); + return array_reduce( + $associatedItems, + function ($total, $item) use ($store, $taxClassId) { + $basePrice = $item->getPrice(); + $price = $this->calculatePrice( + $basePrice, + $taxClassId, + $store + ); + return $total + ($price * $item->getQty()); + }, + 0 + ); } /** @@ -290,10 +298,14 @@ protected function calculateProductPrice(int $entityId, callable $getAssociatedI */ protected function calculateGroupedProductPrice(int $entityId, $store, ?int $taxClassId): float { - return $this->calculateProductPrice($entityId, function ($product) { - return $product->getTypeInstance()->getAssociatedProducts($product); - } - , $store, $taxClassId); + return $this->calculateProductPrice( + $entityId, + function ($product) { + return $product->getTypeInstance()->getAssociatedProducts($product); + }, + $store, + $taxClassId + ); } /** @@ -304,12 +316,16 @@ protected function calculateGroupedProductPrice(int $entityId, $store, ?int $tax */ protected function calculateBundleProductPrice(int $entityId, $store, ?int $taxClassId): float { - return $this->calculateProductPrice($entityId, function ($product) { - return $product->getTypeInstance()->getSelectionsCollection( - $product->getTypeInstance()->getOptionsIds($product), - $product - ); - } - , $store, $taxClassId); + return $this->calculateProductPrice( + $entityId, + function ($product) { + return $product->getTypeInstance()->getSelectionsCollection( + $product->getTypeInstance()->getOptionsIds($product), + $product + ); + }, + $store, + $taxClassId + ); } } From 7711fccfb99c734b7f7794e81cb5e48ca229906b Mon Sep 17 00:00:00 2001 From: ah-net <103565001+ah-net@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:18:12 +0200 Subject: [PATCH 4/4] Style fixes --- Model/Write/Products/CollectionDecorator/Price.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Model/Write/Products/CollectionDecorator/Price.php b/Model/Write/Products/CollectionDecorator/Price.php index 1bc7cbc..7905616 100644 --- a/Model/Write/Products/CollectionDecorator/Price.php +++ b/Model/Write/Products/CollectionDecorator/Price.php @@ -265,8 +265,7 @@ protected function calculateProductPrice( callable $getAssociatedItems, $store, ?int $taxClassId - ): float - { + ): float { $product = $this->collectionFactory->create()->getItemById($entityId); $associatedItems = $getAssociatedItems($product);