<?php declare(strict_types=1);
namespace Acris\Promotion\Subscriber;
use Acris\Promotion\Component\PriceRoundingService;
use Acris\Promotion\Component\VariantService;
use Acris\Promotion\Core\Checkout\Cart\Price\Struct\AcrisListPrice;
use Acris\Promotion\Core\Checkout\Cart\Price\Struct\OriginalUnitPrice;
use Acris\Promotion\Core\Checkout\Cart\PromotionCartProcessor;
use Acris\Promotion\Core\Checkout\Promotion\LineItemPromotion;
use Acris\Promotion\Core\Checkout\Promotion\LineItemPromotionCollection;
use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\CartBehavior;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Cart\Price\CashRounding;
use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice;
use Shopware\Core\Checkout\Cart\Price\Struct\ListPrice;
use Shopware\Core\Checkout\Cart\Price\Struct\PriceCollection;
use Shopware\Core\Checkout\Cart\Price\Struct\ReferencePrice;
use Shopware\Core\Content\Product\Cart\ProductLineItemFactory;
use Shopware\Core\Content\Product\DataAbstractionLayer\CheapestPrice\CalculatedCheapestPrice;
use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Pricing\CashRoundingConfig;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\Framework\Util\Random;
use Shopware\Core\System\SalesChannel\Entity\SalesChannelEntityLoadedEvent;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use function array_key_exists;
use function dd;
class ProductLoadedSubscriber implements EventSubscriberInterface
{
const PRODUCT_PROMOTION_EXTENSION_NAME = 'acrisLineItemPromotionCollection';
const PRODUCT_ORG_UNIT_PRICE_EXTENSION_NAME = 'acrisPromotionOrgPrice';
const PRODUCT_PROMOTION_ID_EXTENSION_NAME = 'acrisPromotion';
/**
* @var ProductLineItemFactory
*/
private $productLineItemFactory;
/**
* @var PromotionCartProcessor
*/
private $promotionCartProcessor;
/**
* @var array|null
*/
private $promotionsAuto;
/**
* @var VariantService
*/
private $variantService;
/**
* @var CashRounding
*/
private $cashRounding;
/**
* @var PriceRoundingService
*/
private $priceRoundingService;
/**
* @var SystemConfigService
*/
private $systemConfigService;
public function __construct(
ProductLineItemFactory $productLineItemFactory,
PromotionCartProcessor $promotionCartProcessor,
VariantService $variantService,
CashRounding $cashRounding,
PriceRoundingService $priceRoundingService,
SystemConfigService $systemConfigService
)
{
$this->productLineItemFactory = $productLineItemFactory;
$this->promotionCartProcessor = $promotionCartProcessor;
$this->promotionsAuto = null;
$this->variantService = $variantService;
$this->cashRounding = $cashRounding;
$this->priceRoundingService = $priceRoundingService;
$this->systemConfigService = $systemConfigService;
}
public static function getSubscribedEvents()
{
return [
'sales_channel.product.loaded' => ['loaded', -50],
];
}
public function loaded(SalesChannelEntityLoadedEvent $event): void
{
$salesChannelContext = $event->getSalesChannelContext();
if($salesChannelContext->hasExtension('acrisProcessCart') === true) {
return;
}
$salesChannelContext->addExtension('acrisProcessCart', new ArrayEntity(['process' => true]));
$behavior = new CartBehavior($salesChannelContext->getPermissions());
/** @var SalesChannelProductEntity $product */
foreach ($event->getEntities() as $product) {
$originalProductToCalculate = clone $product;
// have cheapest price
if($product->getCalculatedCheapestPrice()) {
$product->setCalculatedCheapestPrice($this->processProductCalculatedCheapestPrice($product->getCalculatedCheapestPrice(), $originalProductToCalculate, $product, $behavior, $salesChannelContext));
if(empty($product->getParentId()) === false && $product->getCalculatedCheapestPrice()->hasRange()) {
if ($this->isCalculatedListPriceInsideCalculatedPrices($product->getCalculatedCheapestPrice(), $product->getCalculatedPrices(), $product->getCalculatedPrice()) !== true) {
$variantSmallestPrice = $this->variantService->loadFirstVariantIdOrderedByPrice($product->getParentId(), FieldSorting::ASCENDING, 'from', $salesChannelContext);
if ($variantSmallestPrice) {
$product->setCalculatedCheapestPrice($this->processProductCalculatedCheapestPrice($variantSmallestPrice->getCalculatedCheapestPrice(), $variantSmallestPrice, $product, $behavior, $salesChannelContext));
}
}
}
}
// have calculated prices
if($product->getCalculatedPrices() && $product->getCalculatedPrices()->count() > 0) {
$calculatedPriceCollectionNew = new PriceCollection();
foreach ($product->getCalculatedPrices() as $calculatedPrice) {
$calculatedPriceCollectionNew->add($this->processProductCalculatedPrice($calculatedPrice, $originalProductToCalculate, $product, $behavior, $salesChannelContext));
}
$product->setCalculatedPrices($calculatedPriceCollectionNew);
}
if($product->getCalculatedPrice()) {
$product->setCalculatedPrice($this->processProductCalculatedPrice($product->getCalculatedPrice(), $originalProductToCalculate, $product, $behavior, $salesChannelContext));
}
}
$salesChannelContext->removeExtension('acrisProcessCart');
}
private function processProductCalculatedPrice(CalculatedPrice $calculatedPrice, SalesChannelProductEntity $product, SalesChannelProductEntity $originalProduct, CartBehavior $behavior, SalesChannelContext $salesChannelContext): CalculatedPrice
{
$cart = new Cart($salesChannelContext->getSalesChannel()->getTypeId(), Random::getAlphanumericString(32));
$quantity = (int) $calculatedPrice->getQuantity();
if($quantity < 1) {
$quantity = 1;
$calculatedPrice = new CalculatedPrice(
$calculatedPrice->getUnitPrice(),
$calculatedPrice->getUnitPrice(),
$calculatedPrice->getCalculatedTaxes(),
$calculatedPrice->getTaxRules(),
$quantity,
$calculatedPrice->getReferencePrice(),
$calculatedPrice->getListPrice(),
$calculatedPrice->getRegulationPrice()
);
}
$lineItem = $this->productLineItemFactory->create($product->getId(), ['quantity' => $quantity]);
$cart->add($lineItem);
// prevent calling of promotions from database duplicate times
if($this->promotionsAuto !== null) {
$cart->getData()->set('promotions-auto', $this->promotionsAuto);
}
$cart = $this->promotionCartProcessor->process($cart, $salesChannelContext, $behavior, $product, $calculatedPrice);
// better compatibility with AcrisDiscountListPrice
if($cart->getLineItems()->has($product->getId()) === true && $cart->getLineItems()->get($product->getId())->getPrice() instanceof CalculatedPrice) {
$calculatedPrice = $cart->getLineItems()->get($product->getId())->getPrice();
}
// save called automatic promotions for further use prevent calling again
$this->promotionsAuto = $cart->getData()->get('promotions-auto');
return $this->calculatePriceByPromotion($calculatedPrice, $quantity, $cart, $originalProduct, $salesChannelContext);
}
private function calculatePriceByPromotion(CalculatedPrice $calculatedPrice, int $quantity, Cart $cart, SalesChannelProductEntity $originalProduct, SalesChannelContext $salesChannelContext): CalculatedPrice
{
$lineItemPromotionCollection = new LineItemPromotionCollection();
foreach ($cart->getLineItems() as $lineItem) {
if($lineItem->getType() === LineItem::PROMOTION_LINE_ITEM_TYPE) {
$composition = $lineItem->getPayloadValue('composition');
if($composition === null) {
continue;
}
foreach ($composition as $lineItemDiscount) {
if(array_key_exists('id', $lineItemDiscount) === true && array_key_exists('discount', $lineItemDiscount) === true) {
if (!empty($lineItem->getPayloadValue('promotionId'))) {
if ($originalProduct->hasExtension(self::PRODUCT_PROMOTION_ID_EXTENSION_NAME) && $originalProduct->getExtension(self::PRODUCT_PROMOTION_ID_EXTENSION_NAME)->has('promotionIds')) {
$promotionIds = $originalProduct->getExtension(self::PRODUCT_PROMOTION_ID_EXTENSION_NAME)->get('promotionIds');
if (!in_array($lineItem->getPayloadValue('promotionId'), $promotionIds)) {
$promotionIds[] = $lineItem->getPayloadValue('promotionId');
$originalProduct->addExtension(self::PRODUCT_PROMOTION_ID_EXTENSION_NAME, new ArrayEntity([
'promotionIds' => $promotionIds
]));
}
} else {
$originalProduct->addExtension(self::PRODUCT_PROMOTION_ID_EXTENSION_NAME, new ArrayEntity([
'promotionIds' => [$lineItem->getPayloadValue('promotionId')]
]));
}
}
$discount = $lineItemDiscount['discount'];
if(!is_float($discount)) {
continue 2;
}
$discount = $this->roundDiscountIfNecessary($discount, $salesChannelContext->getItemRounding());
$lineItemPromotionCollection->add(new LineItemPromotion(
$lineItem->getPayloadValue('discountId'),
$lineItem->getPayloadValue('discountType'),
(float) $lineItem->getPayloadValue('value'),
$discount,
(float) $lineItem->getPayloadValue('maxValue'),
$lineItem->getPayloadValue('code'),
$lineItem->getLabel(),
$quantity
));
continue 2;
}
}
}
}
if($lineItemPromotionCollection->count() > 0) {
return $this->getCalculatedPriceNew($calculatedPrice, $lineItemPromotionCollection, $salesChannelContext);
}
return $calculatedPrice;
}
private function getCalculatedPriceNew(CalculatedPrice $calculatedPrice, LineItemPromotionCollection $lineItemPromotionCollection, SalesChannelContext $salesChannelContext): CalculatedPrice
{
if($calculatedPrice->getListPrice()) {
$totalListPrice = $calculatedPrice->getListPrice()->getPrice();
} else {
$totalListPrice = $calculatedPrice->getUnitPrice();
}
$totalDiscountPrice = $calculatedPrice->getTotalPrice();
$unitDiscountPrice = $calculatedPrice->getUnitPrice();
/** @var LineItemPromotion $lineItemPromotion */
foreach ($lineItemPromotionCollection as $lineItemPromotion) {
$totalDiscountPrice = $totalDiscountPrice - $lineItemPromotion->getDiscount();
$unitDiscountPrice = $unitDiscountPrice - ($lineItemPromotion->getDiscount() / $lineItemPromotion->getQuantity());
}
$newCalculatedPrice = new CalculatedPrice(
$unitDiscountPrice,
$totalDiscountPrice,
$calculatedPrice->getCalculatedTaxes(),
$calculatedPrice->getTaxRules(),
$calculatedPrice->getQuantity(),
$this->calculateReferencePriceByReferencePrice($unitDiscountPrice, $calculatedPrice->getReferencePrice(), $salesChannelContext->getItemRounding()),
ListPrice::createFromUnitPrice($unitDiscountPrice, $totalListPrice),
$calculatedPrice->getRegulationPrice()
);
$newCalculatedPrice->addExtension(self::PRODUCT_ORG_UNIT_PRICE_EXTENSION_NAME, new OriginalUnitPrice($calculatedPrice->getUnitPrice()));
return $this->roundCalculatedPrice( $newCalculatedPrice, $salesChannelContext->getSalesChannelId() );
}
private function isCalculatedListPriceInsideCalculatedPrices(CalculatedPrice $calculatedListPrice, PriceCollection $calculatedPrices, CalculatedPrice $calculatedPriceSingle): bool
{
foreach ($calculatedPrices as $calculatedPrice) {
if($calculatedListPrice->getUnitPrice() === $calculatedPrice->getUnitPrice()) {
return true;
}
}
if($calculatedPriceSingle) {
if($calculatedListPrice->getUnitPrice() === $calculatedPriceSingle->getUnitPrice()) {
return true;
}
}
return false;
}
private function processProductCalculatedCheapestPrice(CalculatedCheapestPrice $calculatedCheapestPrice, SalesChannelProductEntity $product, SalesChannelProductEntity $originalProduct, CartBehavior $behavior, SalesChannelContext $salesChannelContext): CalculatedCheapestPrice
{
$cheapestPriceNew = CalculatedCheapestPrice::createFrom($this->processProductCalculatedPrice($calculatedCheapestPrice, $product, $originalProduct, $behavior, $salesChannelContext));
$cheapestPriceNew->setHasRange($product->getCalculatedCheapestPrice()->hasRange());
return $cheapestPriceNew;
}
private function roundDiscountIfNecessary(float $discount, CashRoundingConfig $cashRoundingConfig): float
{
return $this->cashRounding->cashRound($discount, $cashRoundingConfig);
}
/**
* Copied and adapted from GrossPriceCalculator and NetPriceCalculator
*/
private function calculateReferencePriceByReferencePrice(float $price, ?ReferencePrice $referencePrice, CashRoundingConfig $config): ?ReferencePrice
{
if (!$referencePrice instanceof ReferencePrice) {
return $referencePrice;
}
if ($referencePrice->getPurchaseUnit() <= 0 || $referencePrice->getReferenceUnit() <= 0) {
return null;
}
$price = $price / $referencePrice->getPurchaseUnit() * $referencePrice->getReferenceUnit();
$price = $this->cashRounding->mathRound($price, $config);
return new ReferencePrice(
$price,
$referencePrice->getPurchaseUnit(),
$referencePrice->getReferenceUnit(),
$referencePrice->getUnitName()
);
}
private function roundCalculatedPrice( CalculatedPrice $calculatedPrice, string $salesChannelId ): CalculatedPrice
{
if( $listPrice = $calculatedPrice->getListPrice() )
{
$roundingType = $this->systemConfigService->getString('AcrisPromotionCS.config.typeOfRounding', $salesChannelId );
$decimalPlaces = intval( $this->systemConfigService->get('AcrisPromotionCS.config.decimalPlaces', $salesChannelId ) );
$percentageRounded = $this->priceRoundingService->round( $listPrice->getPercentage(), $decimalPlaces, $roundingType );
$listPriceNew = new AcrisListPrice( $listPrice->getPrice(), $listPrice->getDiscount(), $percentageRounded );
$calculatedPrice = new CalculatedPrice(
$calculatedPrice->getUnitPrice(),
$calculatedPrice->getTotalPrice(),
$calculatedPrice->getCalculatedTaxes(),
$calculatedPrice->getTaxRules(),
$calculatedPrice->getQuantity(),
$calculatedPrice->getReferencePrice(),
$listPriceNew,
$calculatedPrice->getRegulationPrice()
);
}
return $calculatedPrice;
}
}