From 69b13f1806ab6d75fa77c371edcb913ce2a77ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 10 Dec 2024 22:37:46 +0100 Subject: [PATCH] Pre-qualification and runtime starting to take shape --- composer.json | 5 + psalm.xml | 5 + .../RuntimePromotionsApplicator.php | 82 +++++++ .../RuntimePromotionsApplicatorInterface.php | 17 ++ .../ProductVariantPricesCalculator.php | 101 ++++++++ .../PreQualificationCheckerInterface.php | 16 ++ .../Rule/RuleCheckerInterface.php | 12 + .../Runtime/ChannelContextRuntimeChecker.php | 20 ++ .../Runtime/CompositeRuntimeChecker.php | 25 ++ src/Checker/Runtime/DateRuntimeChecker.php | 32 +++ src/Checker/Runtime/EnabledRuntimeChecker.php | 15 ++ .../Runtime/RuntimeCheckerInterface.php | 17 ++ src/Command/ProcessCommand.php | 30 +++ .../ProductDataProviderInterface.php | 18 ++ src/DependencyInjection/Configuration.php | 2 +- .../SetonoSyliusCatalogPromotionExtension.php | 3 + .../ORM/ChannelPricingRepositoryTrait.php | 230 ------------------ .../ORM/HasAnyBeenUpdatedSinceTrait.php | 47 ---- src/Doctrine/ORM/ProductRepositoryTrait.php | 10 - .../ORM/ProductVariantRepositoryTrait.php | 10 - src/Doctrine/ORM/PromotionRepository.php | 38 --- src/Message/Command/CommandInterface.php | 9 + .../Command/ProcessCatalogPromotions.php | 29 +++ .../ProcessCatalogPromotionsHandler.php | 37 +++ src/Model/ChannelPricingInterface.php | 17 -- src/Model/ChannelPricingTrait.php | 27 -- src/Model/ProductInterface.php | 25 ++ src/Model/ProductTrait.php | 57 +++++ .../ChannelPricingRepositoryInterface.php | 34 --- ...AnyBeenUpdatedSinceRepositoryInterface.php | 15 -- src/Repository/ProductRepositoryInterface.php | 11 - .../ProductVariantRepositoryInterface.php | 11 - src/Repository/PromotionRepository.php | 28 +++ .../PromotionRepositoryInterface.php | 13 +- src/Resources/config/services.xml | 3 + src/Resources/config/services/applicator.xml | 12 + src/Resources/config/services/calculator.xml | 14 ++ src/Resources/config/services/checker.xml | 29 +++ src/SetonoSyliusCatalogPromotionPlugin.php | 3 + tests/Application/Model/ChannelPricing.php | 20 -- tests/Application/Model/Product.php | 20 ++ .../Application/config/packages/_sylius.yaml | 6 +- .../ProductVariantPricesCalculatorTest.php | 114 +++++++++ 43 files changed, 786 insertions(+), 483 deletions(-) create mode 100644 src/Applicator/RuntimePromotionsApplicator.php create mode 100644 src/Applicator/RuntimePromotionsApplicatorInterface.php create mode 100644 src/Calculator/ProductVariantPricesCalculator.php create mode 100644 src/Checker/PreQualification/PreQualificationCheckerInterface.php create mode 100644 src/Checker/PreQualification/Rule/RuleCheckerInterface.php create mode 100644 src/Checker/Runtime/ChannelContextRuntimeChecker.php create mode 100644 src/Checker/Runtime/CompositeRuntimeChecker.php create mode 100644 src/Checker/Runtime/DateRuntimeChecker.php create mode 100644 src/Checker/Runtime/EnabledRuntimeChecker.php create mode 100644 src/Checker/Runtime/RuntimeCheckerInterface.php create mode 100644 src/Command/ProcessCommand.php create mode 100644 src/DataProvider/ProductDataProviderInterface.php delete mode 100644 src/Doctrine/ORM/ChannelPricingRepositoryTrait.php delete mode 100644 src/Doctrine/ORM/HasAnyBeenUpdatedSinceTrait.php delete mode 100644 src/Doctrine/ORM/ProductRepositoryTrait.php delete mode 100644 src/Doctrine/ORM/ProductVariantRepositoryTrait.php delete mode 100644 src/Doctrine/ORM/PromotionRepository.php create mode 100644 src/Message/Command/CommandInterface.php create mode 100644 src/Message/Command/ProcessCatalogPromotions.php create mode 100644 src/Message/CommandHandler/ProcessCatalogPromotionsHandler.php delete mode 100644 src/Model/ChannelPricingInterface.php delete mode 100644 src/Model/ChannelPricingTrait.php create mode 100644 src/Model/ProductInterface.php create mode 100644 src/Model/ProductTrait.php delete mode 100644 src/Repository/ChannelPricingRepositoryInterface.php delete mode 100644 src/Repository/HasAnyBeenUpdatedSinceRepositoryInterface.php delete mode 100644 src/Repository/ProductRepositoryInterface.php delete mode 100644 src/Repository/ProductVariantRepositoryInterface.php create mode 100644 src/Repository/PromotionRepository.php create mode 100644 src/Resources/config/services/applicator.xml create mode 100644 src/Resources/config/services/calculator.xml create mode 100644 src/Resources/config/services/checker.xml delete mode 100644 tests/Application/Model/ChannelPricing.php create mode 100644 tests/Application/Model/Product.php create mode 100644 tests/Calculator/ProductVariantPricesCalculatorTest.php diff --git a/composer.json b/composer.json index a27fafa..ab09580 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,9 @@ "doctrine/dbal": "^3.0", "doctrine/orm": "^2.10", "knplabs/knp-menu": "^3.0", + "psr/clock": "^1.0", + "psr/clock-implementation": "*", + "setono/composite-compiler-pass": "^1.2", "sylius/channel": "^1.0", "sylius/channel-bundle": "^1.0", "sylius/core": "^1.0", @@ -30,6 +33,7 @@ "symfony/config": "^5.4 || ^6.4 || ^7.0", "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", "symfony/form": "^5.4 || ^6.4 || ^7.0", + "symfony/messenger": "^5.4 || ^6.4 || ^7.0", "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", "symfony/validator": "^5.4 || ^6.4 || ^7.0", "webmozart/assert": "^1.11" @@ -44,6 +48,7 @@ "lexik/jwt-authentication-bundle": "^2.17", "matthiasnoback/symfony-config-test": "^4.3 || ^5.1", "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.1", + "phpspec/prophecy-phpunit": "^2.3", "phpunit/phpunit": "^9.6.20", "psalm/plugin-phpunit": "^0.18.4", "psalm/plugin-symfony": "^5.2", diff --git a/psalm.xml b/psalm.xml index c33c458..4e111b7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,11 @@ + + + + + diff --git a/src/Applicator/RuntimePromotionsApplicator.php b/src/Applicator/RuntimePromotionsApplicator.php new file mode 100644 index 0000000..ceb6810 --- /dev/null +++ b/src/Applicator/RuntimePromotionsApplicator.php @@ -0,0 +1,82 @@ + */ + private array $multiplierCache = []; + + /** @var array */ + private array $catalogPromotionCache = []; + + public function __construct( + private readonly PromotionRepositoryInterface $promotionRepository, + private readonly RuntimeCheckerInterface $runtimeChecker, + ) { + } + + public function apply(array $catalogPromotions, int $price, bool $manuallyDiscounted): int + { + if ([] === $catalogPromotions) { + return $price; + } + + return (int) floor($this->getMultiplier($catalogPromotions, $manuallyDiscounted) * $price); + } + + /** + * @param list $catalogPromotions + */ + private function getMultiplier(array $catalogPromotions, bool $manuallyDiscounted): float + { + $cacheKey = sprintf('%s%d', implode($catalogPromotions), (int) $manuallyDiscounted); + + if (!isset($this->multiplierCache[$cacheKey])) { + $multiplier = 1.0; + + foreach ($this->getEligiblePromotions($catalogPromotions, $manuallyDiscounted) as $promotion) { + $multiplier *= $promotion->getMultiplier(); + } + + $this->multiplierCache[$cacheKey] = $multiplier; + } + + return $this->multiplierCache[$cacheKey]; + } + + /** + * @param list $catalogPromotions + * + * @return \Generator + */ + private function getEligiblePromotions(array $catalogPromotions, bool $manuallyDiscounted): \Generator + { + // todo check if any of the promotions are exclusive + foreach ($catalogPromotions as $catalogPromotion) { + if (!array_key_exists($catalogPromotion, $this->catalogPromotionCache)) { + $this->catalogPromotionCache[$catalogPromotion] = $this->promotionRepository->findOneByCode($catalogPromotion); + } + + if (null === $this->catalogPromotionCache[$catalogPromotion]) { + continue; + } + + if ($manuallyDiscounted && $this->catalogPromotionCache[$catalogPromotion]->isManuallyDiscountedProductsExcluded()) { + continue; + } + + if (!$this->runtimeChecker->isEligible($this->catalogPromotionCache[$catalogPromotion])) { + continue; + } + + yield $this->catalogPromotionCache[$catalogPromotion]; + } + } +} diff --git a/src/Applicator/RuntimePromotionsApplicatorInterface.php b/src/Applicator/RuntimePromotionsApplicatorInterface.php new file mode 100644 index 0000000..3e6f9aa --- /dev/null +++ b/src/Applicator/RuntimePromotionsApplicatorInterface.php @@ -0,0 +1,17 @@ + $catalogPromotions The codes of the catalog promotions to apply + * @param int $price The price before the catalog promotions have been applied + * @param bool $manuallyDiscounted Whether the price has been manually discounted + * + * @return int The price after the catalog promotions have been applied + */ + public function apply(array $catalogPromotions, int $price, bool $manuallyDiscounted): int; +} diff --git a/src/Calculator/ProductVariantPricesCalculator.php b/src/Calculator/ProductVariantPricesCalculator.php new file mode 100644 index 0000000..00e70b0 --- /dev/null +++ b/src/Calculator/ProductVariantPricesCalculator.php @@ -0,0 +1,101 @@ + */ + private array $persistedPricesCache = []; + + /** @var array */ + private array $computedPriceCache = []; + + public function __construct(private readonly RuntimePromotionsApplicatorInterface $runtimePromotionsApplicator) + { + } + + public function calculate(ProductVariantInterface $productVariant, array $context): int + { + $hash = spl_object_hash($productVariant); + if (!isset($this->computedPriceCache[$hash])) { + $this->computedPriceCache[$hash] = $this->getPrice($productVariant, $context); + } + + return $this->computedPriceCache[$hash]; + } + + public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int + { + return $this->getPersistedPrices($productVariant, $context)['originalPrice']; + } + + private function getPrice(ProductVariantInterface $productVariant, array $context): int + { + $prices = $this->getPersistedPrices($productVariant, $context); + + $product = $productVariant->getProduct(); + if (!$product instanceof ProductInterface || !$product->hasPreQualifiedCatalogPromotions()) { + return $prices['price']; + } + + return max($prices['minimumPrice'], $this->runtimePromotionsApplicator->apply( + $product->getPreQualifiedCatalogPromotions(), + $prices['price'], + $prices['price'] < $prices['originalPrice'], + )); + } + + /** + * @psalm-assert ChannelInterface $context['channel'] + * + * @return array{price: int, originalPrice: int, minimumPrice: int} + */ + private function getPersistedPrices(ProductVariantInterface $productVariant, array $context): array + { + $hash = spl_object_hash($productVariant); + + if (!isset($this->persistedPricesCache[$hash])) { + Assert::keyExists($context, 'channel'); + Assert::isInstanceOf($context['channel'], ChannelInterface::class); + + $channelPricing = $productVariant->getChannelPricingForChannel($context['channel']); + + if (null === $channelPricing) { + throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']); + } + + $price = $channelPricing->getPrice(); + if (null === $price) { + throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']); + } + + $this->persistedPricesCache[$hash] = [ + 'price' => $price, + 'originalPrice' => $channelPricing->getOriginalPrice() ?? $price, + 'minimumPrice' => self::getMinimumPrice($channelPricing), + ]; + } + + return $this->persistedPricesCache[$hash]; + } + + private static function getMinimumPrice(ChannelPricingInterface $channelPricing): int + { + if (method_exists($channelPricing, 'getMinimumPrice')) { + return $channelPricing->getMinimumPrice(); + } + + return 0; + } +} diff --git a/src/Checker/PreQualification/PreQualificationCheckerInterface.php b/src/Checker/PreQualification/PreQualificationCheckerInterface.php new file mode 100644 index 0000000..b6f36b1 --- /dev/null +++ b/src/Checker/PreQualification/PreQualificationCheckerInterface.php @@ -0,0 +1,16 @@ +getChannels()->contains($this->channelContext->getChannel()); + } +} diff --git a/src/Checker/Runtime/CompositeRuntimeChecker.php b/src/Checker/Runtime/CompositeRuntimeChecker.php new file mode 100644 index 0000000..60b9bfa --- /dev/null +++ b/src/Checker/Runtime/CompositeRuntimeChecker.php @@ -0,0 +1,25 @@ + + */ +final class CompositeRuntimeChecker extends CompositeService implements RuntimeCheckerInterface +{ + public function isEligible(PromotionInterface $catalogPromotion): bool + { + foreach ($this->services as $service) { + if (!$service->isEligible($catalogPromotion)) { + return false; + } + } + + return true; + } +} diff --git a/src/Checker/Runtime/DateRuntimeChecker.php b/src/Checker/Runtime/DateRuntimeChecker.php new file mode 100644 index 0000000..1561d94 --- /dev/null +++ b/src/Checker/Runtime/DateRuntimeChecker.php @@ -0,0 +1,32 @@ +clock->now(); + + $startsAt = $catalogPromotion->getStartsAt(); + if (null !== $startsAt && $startsAt > $now) { + return false; + } + + $endsAt = $catalogPromotion->getEndsAt(); + if (null !== $endsAt && $endsAt < $now) { + return false; + } + + return true; + } +} diff --git a/src/Checker/Runtime/EnabledRuntimeChecker.php b/src/Checker/Runtime/EnabledRuntimeChecker.php new file mode 100644 index 0000000..480675a --- /dev/null +++ b/src/Checker/Runtime/EnabledRuntimeChecker.php @@ -0,0 +1,15 @@ +isEnabled(); + } +} diff --git a/src/Checker/Runtime/RuntimeCheckerInterface.php b/src/Checker/Runtime/RuntimeCheckerInterface.php new file mode 100644 index 0000000..7cc7ca7 --- /dev/null +++ b/src/Checker/Runtime/RuntimeCheckerInterface.php @@ -0,0 +1,17 @@ +commandBus->dispatch(new ProcessCatalogPromotions()); + + return 0; + } +} diff --git a/src/DataProvider/ProductDataProviderInterface.php b/src/DataProvider/ProductDataProviderInterface.php new file mode 100644 index 0000000..a0af26c --- /dev/null +++ b/src/DataProvider/ProductDataProviderInterface.php @@ -0,0 +1,18 @@ + + */ + public function getProducts(): iterable; +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e284aa6..ee60344 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,11 +4,11 @@ namespace Setono\SyliusCatalogPromotionPlugin\DependencyInjection; -use Setono\SyliusCatalogPromotionPlugin\Doctrine\ORM\PromotionRepository; use Setono\SyliusCatalogPromotionPlugin\Form\Type\PromotionRuleType; use Setono\SyliusCatalogPromotionPlugin\Form\Type\PromotionType; use Setono\SyliusCatalogPromotionPlugin\Model\Promotion; use Setono\SyliusCatalogPromotionPlugin\Model\PromotionRule; +use Setono\SyliusCatalogPromotionPlugin\Repository\PromotionRepository; use Sylius\Bundle\ResourceBundle\Controller\ResourceController; use Sylius\Component\Resource\Factory\Factory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; diff --git a/src/DependencyInjection/SetonoSyliusCatalogPromotionExtension.php b/src/DependencyInjection/SetonoSyliusCatalogPromotionExtension.php index c5d2c63..4f2fa5b 100644 --- a/src/DependencyInjection/SetonoSyliusCatalogPromotionExtension.php +++ b/src/DependencyInjection/SetonoSyliusCatalogPromotionExtension.php @@ -4,6 +4,7 @@ namespace Setono\SyliusCatalogPromotionPlugin\DependencyInjection; +use Setono\SyliusCatalogPromotionPlugin\Checker\Runtime\RuntimeCheckerInterface; use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; use Symfony\Component\Config\FileLocator; @@ -24,6 +25,8 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.xml'); + $container->registerForAutoconfiguration(RuntimeCheckerInterface::class)->addTag('setono_sylius_catalog_promotion.runtime_checker'); + $this->registerResources('setono_sylius_catalog_promotion', SyliusResourceBundle::DRIVER_DOCTRINE_ORM, $config['resources'], $container); } } diff --git a/src/Doctrine/ORM/ChannelPricingRepositoryTrait.php b/src/Doctrine/ORM/ChannelPricingRepositoryTrait.php deleted file mode 100644 index 9b606c9..0000000 --- a/src/Doctrine/ORM/ChannelPricingRepositoryTrait.php +++ /dev/null @@ -1,230 +0,0 @@ -_em->getConnection(); - $oldTransactionIsolation = (int) $connection->getTransactionIsolation(); - $connection->setTransactionIsolation(TransactionIsolationLevel::READ_COMMITTED); - - do { - $connection->beginTransaction(); - - try { - $qb = $this->createQueryBuilder('o'); - $ids = $qb - ->select('o.id') - // this ensures that the loop we are in doesn't turn into an infinite loop - ->andWhere( - $qb->expr()->orX( - 'o.bulkIdentifier != :bulkIdentifier', - 'o.bulkIdentifier is null', - ), - ) - ->andWhere( - $qb->expr()->orX( - // if the multiplier is different from 1 we know that it was discounted before, and we reset it - 'o.multiplier != 1', - - // if the previous job timed out, the bulk identifier will be different from the - // bulk identifier for this run. This will ensure that they will also be handled in this run - 'o.bulkIdentifier is not null', - - // if the applied promotions is not null we know that it was discounted before, and we reset it - 'o.appliedPromotions is not null', - ), - ) - ->setParameter('bulkIdentifier', $bulkIdentifier) - ->setMaxResults(100) - ->getQuery() - ->getResult() - ; - - $res = (int) $this - ->createQueryBuilder('o') - ->update() - ->set('o.multiplier', 1) - ->set('o.updatedAt', ':updatedAt') - ->set('o.bulkIdentifier', ':bulkIdentifier') - ->set('o.appliedPromotions', ':null') - ->andWhere('o.id IN (:ids)') - ->setParameter('updatedAt', $dateTime) - ->setParameter('bulkIdentifier', $bulkIdentifier) - ->setParameter('null', null) - ->setParameter('ids', $ids) - ->getQuery() - ->execute() - ; - - $connection->commit(); - } catch (\Throwable $e) { - $connection->rollBack(); - - throw $e; - } - } while ($res > 0); - - $connection->setTransactionIsolation($oldTransactionIsolation); - } - - public function updateMultiplier( - string $promotionCode, - float $multiplier, - array $productVariantIds, - array $channelCodes, - DateTimeInterface $dateTime, - string $bulkIdentifier, - bool $exclusive = false, - bool $manuallyDiscountedProductsExcluded = true, - ): void { - \assert($this instanceof EntityRepository); - - if (count($channelCodes) === 0 || count($productVariantIds) === 0) { - return; - } - - $qb = $this->createQueryBuilder('channelPricing'); - - $qb->update() - ->andWhere('channelPricing.productVariant IN (:productVariantIds)') - ->andWhere('channelPricing.channelCode IN (:channelCodes)') - // this 'or' is a safety check. If the previous run timed out, but managed to update some multipliers - // this will end up in discounts being compounded. With this check we ensure we only operate on pricings - // from this run or pricings that haven't been touched - ->andWhere($qb->expr()->orX( - 'channelPricing.bulkIdentifier is null', - 'channelPricing.bulkIdentifier = :bulkIdentifier', - )) - // here is another safety check. If the promotion code is already applied, - // do not select this pricing for a discount - ->andWhere($qb->expr()->orX( - 'channelPricing.appliedPromotions IS NULL', - $qb->expr()->andX( - 'channelPricing.appliedPromotions NOT LIKE :promotionEnding', - 'channelPricing.appliedPromotions NOT LIKE :promotionMiddle', - ), - )) - ->set('channelPricing.updatedAt', ':date') - ->set('channelPricing.bulkIdentifier', ':bulkIdentifier') - ->set('channelPricing.appliedPromotions', "CONCAT(COALESCE(channelPricing.appliedPromotions, ''), CONCAT(',', :promotion))") - ->setParameter('productVariantIds', $productVariantIds) - ->setParameter('channelCodes', $channelCodes) - ->setParameter('date', $dateTime) - ->setParameter('bulkIdentifier', $bulkIdentifier) - ->setParameter('promotion', $promotionCode) - // if you are checking for the promo code 'all_10_percent' there are two options for the applied_promotions column: - // 1. ,single_tshirt,all_10_percent - // 2. ,all_10_percent,single_tshirt - // and these two wildcards selections will handle those two options - ->setParameter('promotionEnding', '%,' . $promotionCode) - ->setParameter('promotionMiddle', '%,' . $promotionCode . ',%') - ; - - if ($manuallyDiscountedProductsExcluded) { - $qb->andWhere('channelPricing.manuallyDiscounted = false'); - } - - if ($exclusive) { - $qb->set('channelPricing.multiplier', ':multiplier'); - } else { - $qb->set('channelPricing.multiplier', 'channelPricing.multiplier * :multiplier'); - } - - $qb->setParameter('multiplier', $multiplier); - - $qb->getQuery()->execute(); - } - - public function updatePrices(string $bulkIdentifier): void - { - \assert($this instanceof EntityRepository); - - $connection = $this->_em->getConnection(); - $oldTransactionIsolation = (int) $connection->getTransactionIsolation(); - $connection->setTransactionIsolation(TransactionIsolationLevel::READ_COMMITTED); - - do { - $res = 0; - $connection->beginTransaction(); - - try { - // get an array of ids to work on - $ids = $this->createQueryBuilder('o') - ->select('o.id') - ->andWhere('o.bulkIdentifier = :bulkIdentifier') - ->setParameter('bulkIdentifier', $bulkIdentifier) - ->setMaxResults(100) - ->getQuery() - ->getResult(); - - // this query handles the case where an original price is set - // i.e. we have made discounts on this product before - $this->createQueryBuilder('o') - ->update() - ->set('o.price', 'ROUND(o.originalPrice * o.multiplier)') - ->andWhere('o.originalPrice is not null') - ->andWhere('o.id in (:ids)') - ->setParameter('ids', $ids) - ->getQuery() - ->execute(); - - // this query handles the case where a discount hasn't been applied before - // so we want to move the current price to the original price before changing the price - $this->createQueryBuilder('o') - ->update() - ->set('o.originalPrice', 'o.price') - ->set('o.price', 'ROUND(o.price * o.multiplier)') - ->andWhere('o.originalPrice is null') - ->andWhere('o.multiplier != 1') - ->andWhere('o.id in (:ids)') - ->setParameter('ids', $ids) - ->getQuery() - ->execute(); - - // this query sets the original price to null where the original price equals the price - $this->createQueryBuilder('o') - ->update() - ->set('o.originalPrice', ':originalPrice') - ->andWhere('o.price = o.originalPrice') - ->andWhere('o.id in (:ids)') - ->setParameter('ids', $ids) - ->setParameter('originalPrice', null) - ->getQuery() - ->execute(); - - // set the bulk identifier to null to ensure the loop will come to an end ;) - $res = (int) $this - ->createQueryBuilder('o') - ->update() - ->set('o.bulkIdentifier', ':null') - ->andWhere('o.id IN (:ids)') - ->setParameter('null', null) - ->setParameter('ids', $ids) - ->getQuery() - ->execute(); - - $connection->commit(); - } catch (\Throwable $e) { - $connection->rollBack(); - } - } while ($res > 0); - - $connection->setTransactionIsolation($oldTransactionIsolation); - } -} diff --git a/src/Doctrine/ORM/HasAnyBeenUpdatedSinceTrait.php b/src/Doctrine/ORM/HasAnyBeenUpdatedSinceTrait.php deleted file mode 100644 index 208462f..0000000 --- a/src/Doctrine/ORM/HasAnyBeenUpdatedSinceTrait.php +++ /dev/null @@ -1,47 +0,0 @@ -createQueryBuilder('o') - ->select('count(o)') - ->setParameter('date', $dateTime) - ; - - /* - * These queries has been split into two queries instead of one 'or' query. - * This is because 'or' queries does not leverage indices as one would expect. - * Therefore this approach will be much faster on large data sets. - * Usually you would use UNION in cases like this, but UNION is not supported in Doctrine - */ - - $updated = (int) $qb - ->andWhere('o.updatedAt is not null', 'o.updatedAt > :date') - ->getQuery() - ->getSingleScalarResult() > 0 - ; - - if ($updated) { - return true; - } - - $qb->resetDQLPart('where'); - - return (int) $qb - ->andWhere('o.createdAt is not null', 'o.createdAt > :date') - ->getQuery() - ->getSingleScalarResult() > 0 - ; - } -} diff --git a/src/Doctrine/ORM/ProductRepositoryTrait.php b/src/Doctrine/ORM/ProductRepositoryTrait.php deleted file mode 100644 index 56001f8..0000000 --- a/src/Doctrine/ORM/ProductRepositoryTrait.php +++ /dev/null @@ -1,10 +0,0 @@ -createQueryBuilder('o') - ->andWhere('o.enabled = true') - ->andWhere('SIZE(o.channels) > 0') - ->andWhere('o.startsAt is null OR o.startsAt <= :date') - ->andWhere('o.endsAt is null OR o.endsAt >= :date') - ->addOrderBy('o.exclusive', 'ASC') - ->addOrderBy('o.priority', 'ASC') - ->setParameter('date', $dt) - ->getQuery() - ->getResult() - ; - - Assert::isArray($res); - Assert::allIsInstanceOf($res, PromotionInterface::class); - - return $res; - } -} diff --git a/src/Message/Command/CommandInterface.php b/src/Message/Command/CommandInterface.php new file mode 100644 index 0000000..1f7e0c3 --- /dev/null +++ b/src/Message/Command/CommandInterface.php @@ -0,0 +1,9 @@ + + */ + public readonly array $catalogPromotions; + + /** + * @param list $catalogPromotions + */ + public function __construct( + array $catalogPromotions = [], + ) { + $this->catalogPromotions = array_map( + static fn (string|PromotionInterface $catalogPromotion) => $catalogPromotion instanceof PromotionInterface ? (string) $catalogPromotion->getCode() : $catalogPromotion, + $catalogPromotions, + ); + } +} diff --git a/src/Message/CommandHandler/ProcessCatalogPromotionsHandler.php b/src/Message/CommandHandler/ProcessCatalogPromotionsHandler.php new file mode 100644 index 0000000..8d1ba14 --- /dev/null +++ b/src/Message/CommandHandler/ProcessCatalogPromotionsHandler.php @@ -0,0 +1,37 @@ +promotionRepository->findForProcessing(); + + foreach ($this->productDataProvider->getProducts() as $product) { + $preQualifiedCatalogPromotions = []; + + foreach ($catalogPromotions as $catalogPromotion) { + if ($this->preQualificationChecker->isPreQualified($product, $catalogPromotion)) { + $preQualifiedCatalogPromotions[] = (string) $catalogPromotion->getCode(); + } + } + + $product->setPreQualifiedCatalogPromotions($preQualifiedCatalogPromotions); + } + } +} diff --git a/src/Model/ChannelPricingInterface.php b/src/Model/ChannelPricingInterface.php deleted file mode 100644 index bae558c..0000000 --- a/src/Model/ChannelPricingInterface.php +++ /dev/null @@ -1,17 +0,0 @@ - 0])] - protected bool $manuallyDiscounted = false; - - public function isManuallyDiscounted(): bool - { - return $this->manuallyDiscounted; - } - - public function setManuallyDiscounted(bool $manuallyDiscounted): void - { - $this->manuallyDiscounted = $manuallyDiscounted; - } -} diff --git a/src/Model/ProductInterface.php b/src/Model/ProductInterface.php new file mode 100644 index 0000000..67fc48b --- /dev/null +++ b/src/Model/ProductInterface.php @@ -0,0 +1,25 @@ + + */ + public function getPreQualifiedCatalogPromotions(): array; + + /** + * @param list|null $preQualifiedCatalogPromotions + */ + public function setPreQualifiedCatalogPromotions(?array $preQualifiedCatalogPromotions): void; + + public function hasPreQualifiedCatalogPromotions(): bool; +} diff --git a/src/Model/ProductTrait.php b/src/Model/ProductTrait.php new file mode 100644 index 0000000..3d98032 --- /dev/null +++ b/src/Model/ProductTrait.php @@ -0,0 +1,57 @@ +|null + * + * @ORM\Column(type="json", nullable=true) + */ + #[ORM\Column(type: 'json', nullable: true)] + protected ?array $preQualifiedCatalogPromotions = null; + + /** + * @return list + */ + public function getPreQualifiedCatalogPromotions(): array + { + return $this->preQualifiedCatalogPromotions ?? []; + } + + /** + * @param list|null $preQualifiedCatalogPromotions + */ + public function setPreQualifiedCatalogPromotions(?array $preQualifiedCatalogPromotions): void + { + $preQualifiedCatalogPromotions = self::sanitizeCodes($preQualifiedCatalogPromotions ?? []); + + if ([] === $preQualifiedCatalogPromotions) { + $preQualifiedCatalogPromotions = null; + } + + $this->preQualifiedCatalogPromotions = $preQualifiedCatalogPromotions; + } + + public function hasPreQualifiedCatalogPromotions(): bool + { + return null !== $this->preQualifiedCatalogPromotions && [] !== $this->preQualifiedCatalogPromotions; + } + + /** + * @param list $codes + * + * @return list + */ + private static function sanitizeCodes(array $codes): array + { + sort($codes, \SORT_STRING); + + return array_values(array_unique($codes)); + } +} diff --git a/src/Repository/ChannelPricingRepositoryInterface.php b/src/Repository/ChannelPricingRepositoryInterface.php deleted file mode 100644 index 7f672dd..0000000 --- a/src/Repository/ChannelPricingRepositoryInterface.php +++ /dev/null @@ -1,34 +0,0 @@ -findAll(); + Assert::allIsInstanceOf($objs, PromotionInterface::class); + + return $objs; + } + + public function findOneByCode(string $code): ?PromotionInterface + { + $obj = $this->findOneBy(['code' => $code]); + Assert::nullOrIsInstanceOf($obj, PromotionInterface::class); + + return $obj; + } +} diff --git a/src/Repository/PromotionRepositoryInterface.php b/src/Repository/PromotionRepositoryInterface.php index 68b4cb5..5098fd2 100644 --- a/src/Repository/PromotionRepositoryInterface.php +++ b/src/Repository/PromotionRepositoryInterface.php @@ -7,17 +7,12 @@ use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; -interface PromotionRepositoryInterface extends RepositoryInterface, HasAnyBeenUpdatedSinceRepositoryInterface +interface PromotionRepositoryInterface extends RepositoryInterface { /** - * This is the method used for processing of promotions - * It should return promotions with these properties - * - Enabled - * - At least one enabled channel - * - Sorted by exclusive ascending and thereafter priority - * - The current time should be within the respective promotions time interval - * - * @return PromotionInterface[] + * @return list */ public function findForProcessing(): array; + + public function findOneByCode(string $code): ?PromotionInterface; } diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index dcf8d73..3ea1511 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -3,6 +3,9 @@ + + + diff --git a/src/Resources/config/services/applicator.xml b/src/Resources/config/services/applicator.xml new file mode 100644 index 0000000..bfd0066 --- /dev/null +++ b/src/Resources/config/services/applicator.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/calculator.xml b/src/Resources/config/services/calculator.xml new file mode 100644 index 0000000..205edca --- /dev/null +++ b/src/Resources/config/services/calculator.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/checker.xml b/src/Resources/config/services/checker.xml new file mode 100644 index 0000000..2504494 --- /dev/null +++ b/src/Resources/config/services/checker.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SetonoSyliusCatalogPromotionPlugin.php b/src/SetonoSyliusCatalogPromotionPlugin.php index 4914cf2..1a8ea86 100644 --- a/src/SetonoSyliusCatalogPromotionPlugin.php +++ b/src/SetonoSyliusCatalogPromotionPlugin.php @@ -4,6 +4,8 @@ namespace Setono\SyliusCatalogPromotionPlugin; +use Setono\CompositeCompilerPass\CompositeCompilerPass; +use Setono\SyliusCatalogPromotionPlugin\Checker\Runtime\CompositeRuntimeChecker; use Setono\SyliusCatalogPromotionPlugin\DependencyInjection\Compiler\RegisterRulesPass; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; use Sylius\Bundle\ResourceBundle\AbstractResourceBundle; @@ -26,5 +28,6 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new RegisterRulesPass()); + $container->addCompilerPass(new CompositeCompilerPass(CompositeRuntimeChecker::class, 'setono_sylius_catalog_promotion.runtime_checker')); } } diff --git a/tests/Application/Model/ChannelPricing.php b/tests/Application/Model/ChannelPricing.php deleted file mode 100644 index 84f34f3..0000000 --- a/tests/Application/Model/ChannelPricing.php +++ /dev/null @@ -1,20 +0,0 @@ -prophesize(ChannelPricingInterface::class); + $channelPricing->getPrice()->willReturn(800); + $channelPricing->getOriginalPrice()->willReturn(1000); + $channelPricing->getMinimumPrice()->willReturn(0); + + $product = $this->prophesize(ProductInterface::class); + $product->hasPreQualifiedCatalogPromotions()->willReturn(false); + + $productVariant = $this->prophesize(ProductVariantInterface::class); + $productVariant->getChannelPricingForChannel($channel)->willReturn($channelPricing->reveal()); + $productVariant->getProduct()->willReturn($product->reveal()); + + $runtimePromotionsApplicator = $this->prophesize(RuntimePromotionsApplicatorInterface::class); + $calculator = new ProductVariantPricesCalculator($runtimePromotionsApplicator->reveal()); + + $this->assertSame(800, $calculator->calculate($productVariant->reveal(), [ + 'channel' => $channel, + ])); + + $this->assertSame(1000, $calculator->calculateOriginal($productVariant->reveal(), [ + 'channel' => $channel, + ])); + } + + /** + * @test + */ + public function it_calculates_promotion(): void + { + $channel = new Channel(); + + $channelPricing = $this->prophesize(ChannelPricingInterface::class); + $channelPricing->getPrice()->willReturn(800); + $channelPricing->getOriginalPrice()->willReturn(1000); + $channelPricing->getMinimumPrice()->willReturn(0); + + $product = $this->prophesize(ProductInterface::class); + $product->hasPreQualifiedCatalogPromotions()->willReturn(true); + $product->getPreQualifiedCatalogPromotions()->willReturn(['promo1', 'promo2']); + + $productVariant = $this->prophesize(ProductVariantInterface::class); + $productVariant->getChannelPricingForChannel($channel)->willReturn($channelPricing->reveal()); + $productVariant->getProduct()->willReturn($product->reveal()); + + $runtimePromotionsApplicator = $this->prophesize(RuntimePromotionsApplicatorInterface::class); + $runtimePromotionsApplicator->apply(['promo1', 'promo2'], 800, true)->willReturn(600); + + $calculator = new ProductVariantPricesCalculator($runtimePromotionsApplicator->reveal()); + + $this->assertSame(600, $calculator->calculate($productVariant->reveal(), [ + 'channel' => $channel, + ])); + + $this->assertSame(1000, $calculator->calculateOriginal($productVariant->reveal(), [ + 'channel' => $channel, + ])); + } + + /** + * @test + */ + public function it_respects_minimum_price(): void + { + $channel = new Channel(); + + $channelPricing = $this->prophesize(ChannelPricingInterface::class); + $channelPricing->getPrice()->willReturn(800); + $channelPricing->getOriginalPrice()->willReturn(800); + $channelPricing->getMinimumPrice()->willReturn(700); + + $product = $this->prophesize(ProductInterface::class); + $product->hasPreQualifiedCatalogPromotions()->willReturn(true); + $product->getPreQualifiedCatalogPromotions()->willReturn(['promo1']); + + $productVariant = $this->prophesize(ProductVariantInterface::class); + $productVariant->getChannelPricingForChannel($channel)->willReturn($channelPricing->reveal()); + $productVariant->getProduct()->willReturn($product->reveal()); + + $runtimePromotionsApplicator = $this->prophesize(RuntimePromotionsApplicatorInterface::class); + $runtimePromotionsApplicator->apply(['promo1'], 800, false)->willReturn(600); + + $calculator = new ProductVariantPricesCalculator($runtimePromotionsApplicator->reveal()); + + $this->assertSame(700, $calculator->calculate($productVariant->reveal(), [ + 'channel' => $channel, + ])); + } +}