Skip to content

feat: calculate group/bundle prices incl/excl vat #100

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: beta
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Model/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -305,4 +307,22 @@ 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);
}
}
1 change: 1 addition & 0 deletions Model/Write/Price/IteratorInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ public function initializeAttributes(EavIterator $iterator)
$iterator->selectAttribute('status');
$iterator->selectAttribute('visibility');
$iterator->selectAttribute('type_id');
$iterator->selectAttribute('tax_class_id');
}
}
221 changes: 214 additions & 7 deletions Model/Write/Products/CollectionDecorator/Price.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@
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;
use Tweakwise\Magento2TweakwiseExport\Model\Write\Price\Collection as PriceCollection;
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
*/
Expand All @@ -28,16 +35,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;
Expand All @@ -50,33 +71,109 @@ 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());

while ($row = $priceQuery->fetch()) {
$entityId = $row['entity_id'];
$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
);
}

/**
* @param array $ids
* @param int $websiteId
Expand Down Expand Up @@ -120,4 +217,114 @@ 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
);
}
}
18 changes: 18 additions & 0 deletions etc/adminhtml/system.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@
</depends>
</field>

<field id="calculate_composite_prices" translate="label,comment" type="select" sortOrder="70" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Calculate combined prices</label>
<comment>Would you like to export the combined price of an grouped/bundle product?</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<depends>
<field id="enabled">1</field>
</depends>
</field>

<field id="add_tax_to_prices" translate="label,comment" type="select" sortOrder="70" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Export prices incl vat</label>
<comment>Add the vat to the prices exported. Only enable this if the prices exported to Tweakwise are missing the vat</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<depends>
<field id="enabled">1</field>
</depends>
</field>

<field id="batch_size_categories"
translate="batch size for categories"
type="text"
Expand Down
2 changes: 2 additions & 0 deletions etc/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
<batch_size_categories>5000</batch_size_categories>
<batch_size_products>5000</batch_size_products>
<batch_size_products_children>5000</batch_size_products_children>
<calculate_composite_prices>0</calculate_composite_prices>
<add_tax_to_price>0</add_tax_to_price>
</export>
<export_stock>
<enabled>0</enabled>
Expand Down