diff --git a/UPGRADE.md b/UPGRADE.md index 6a5c643..c1812ef 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -14,3 +14,66 @@ ``` ALTER TABLE setono_sylius_catalog_promotion__promotion CHANGE discount discount NUMERIC(10, 5) DEFAULT '0' NOT NULL; ``` + +2. Change rules configuration at your `catalog_promotion` fixtures: + + - **Taxon** + + Replace: + + ``` + rules: + - type: "has_taxon" + configuration: + - "caps" + ``` + + to: + + ``` + rules: + - type: "has_taxon" + configuration: + taxons: # <--- + - "caps" + ``` + + - **Product** + + Replace: + + ``` + rules: + - type: "contains_product" + configuration: "santa-cap" + ``` + + to: + + ``` + rules: + - type: "contains_product" + configuration: + product: "santa-cap" # <--- + ``` + + - **Products** + + Replace: + + ``` + rules: + - type: "contains_products" + configuration: + - "santa-cap" + ``` + + to: + + ``` + rules: + - type: "contains_products" + configuration: + products: # <--- + - "santa-cap" + ``` diff --git a/composer-require-checker.json b/composer-require-checker.json index a2bd2de..5627941 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -32,7 +32,10 @@ "Sylius\\Component\\Core\\Formatter\\StringInflector", "Sylius\\Component\\Core\\Model\\ChannelInterface", "Sylius\\Component\\Core\\Model\\ChannelPricingInterface", + "Sylius\\Component\\Core\\Model\\ProductTaxon", + "Sylius\\Component\\Core\\Model\\ProductVariant", "Sylius\\Component\\Core\\Repository\\ProductRepositoryInterface", - "Sylius\\Component\\Core\\Repository\\ProductVariantRepositoryInterface" + "Sylius\\Component\\Core\\Repository\\ProductVariantRepositoryInterface", + "Sylius\\Component\\Taxonomy\\Repository\\TaxonRepositoryInterface" ] } diff --git a/src/Factory/PromotionRuleFactory.php b/src/Factory/PromotionRuleFactory.php index 68f7a1d..dd563b1 100644 --- a/src/Factory/PromotionRuleFactory.php +++ b/src/Factory/PromotionRuleFactory.php @@ -8,6 +8,7 @@ use Setono\SyliusCatalogPromotionPlugin\Model\PromotionRuleInterface; use Setono\SyliusCatalogPromotionPlugin\Rule\ContainsProductRule; use Setono\SyliusCatalogPromotionPlugin\Rule\ContainsProductsRule; +use Setono\SyliusCatalogPromotionPlugin\Rule\HasNotTaxonRule; use Setono\SyliusCatalogPromotionPlugin\Rule\HasTaxonRule; use function sprintf; use Sylius\Component\Resource\Factory\FactoryInterface; @@ -43,6 +44,11 @@ public function createByType(string $type, array $configuration, bool $strict = Assert::isArray($configuration['taxons']); return $this->createHasTaxon($configuration['taxons']); + case HasNotTaxonRule::TYPE: + Assert::keyExists($configuration, 'taxons'); + Assert::isArray($configuration['taxons']); + + return $this->createHasNotTaxon($configuration['taxons']); case ContainsProductRule::TYPE: Assert::keyExists($configuration, 'product'); Assert::string($configuration['product']); @@ -75,6 +81,16 @@ public function createHasTaxon(array $taxonCodes): PromotionRuleInterface ); } + public function createHasNotTaxon(array $taxonCodes): PromotionRuleInterface + { + Assert::allString($taxonCodes); + + return $this->createPromotionRule( + HasNotTaxonRule::TYPE, + ['taxons' => $taxonCodes] + ); + } + public function createContainsProduct(string $productCode): PromotionRuleInterface { return $this->createPromotionRule( diff --git a/src/Factory/PromotionRuleFactoryInterface.php b/src/Factory/PromotionRuleFactoryInterface.php index d5f4226..97f2276 100644 --- a/src/Factory/PromotionRuleFactoryInterface.php +++ b/src/Factory/PromotionRuleFactoryInterface.php @@ -13,6 +13,8 @@ public function createByType(string $type, array $configuration, bool $strict = public function createHasTaxon(array $taxonCodes): PromotionRuleInterface; + public function createHasNotTaxon(array $taxonCodes): PromotionRuleInterface; + public function createContainsProduct(string $productCode): PromotionRuleInterface; public function createContainsProducts(array $productCodes): PromotionRuleInterface; diff --git a/src/Form/Type/Rule/HasNotTaxonConfigurationType.php b/src/Form/Type/Rule/HasNotTaxonConfigurationType.php new file mode 100644 index 0000000..3dbb2db --- /dev/null +++ b/src/Form/Type/Rule/HasNotTaxonConfigurationType.php @@ -0,0 +1,37 @@ +taxonsToCodesTransformer = $taxonsToCodesTransformer; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('taxons', TaxonAutocompleteChoiceType::class, [ + 'label' => 'setono_sylius_catalog_promotion.form.promotion_rule.has_taxon_configuration.taxons', + 'multiple' => true, + ]) + ; + + $builder->get('taxons')->addModelTransformer($this->taxonsToCodesTransformer); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_catalog_promotion_promotion_rule_has_not_taxon_configuration'; + } +} diff --git a/src/Resources/config/app/fixtures.yaml b/src/Resources/config/app/fixtures.yaml index 8477e30..991f236 100644 --- a/src/Resources/config/app/fixtures.yaml +++ b/src/Resources/config/app/fixtures.yaml @@ -2,32 +2,98 @@ sylius_fixtures: suites: default: fixtures: + + catalog_promotion_taxons: + name: taxon + options: + custom: + taxon_santa_caps: + code: "santa_caps" + slug: "santa-caps" + children: + - code: "pompon_santa_caps" + slug: "santa-caps/pompon" + name: "Santa caps with pompon" + product: options: custom: + product_cap_with_pompon: + code: "pompon_cap" + name: "Cap with pompon" + main_taxon: "caps" + taxons: + - 'caps' + - 'caps_with_pompons' + images: + - { path: '@SyliusCoreBundle/Resources/fixtures/caps/cap_01.jpg', type: 'main' } + channels: + - "FASHION_WEB" + product_santa_cap: - code: "santa-cap" + code: "santa_cap" + slug: "santa-cap" name: "Santa cap" main_taxon: "caps" taxons: - 'caps' + - 'santa_caps' images: - { path: '@SyliusCoreBundle/Resources/fixtures/caps/cap_03.jpg', type: 'main' } channels: - "FASHION_WEB" + product_santa_cap_with_pompon: + code: "pompon_santa_cap" + slug: "pompon-santa-cap" + name: "Santa cap with pompon" + main_taxon: "caps" + taxons: + - 'caps' + - 'santa_caps' + - 'pompon_santa_caps' + images: + - { path: '@SyliusCoreBundle/Resources/fixtures/caps/cap_01.jpg', type: 'main' } + channels: + - "FASHION_WEB" + catalog_promotion_random: name: catalog_promotion options: random: 10 prototype: rules: - - type: "has_taxon" - configuration: - taxons: - - "jeans" + - type: "has_taxon" + configuration: + taxons: + - "jeans" + + catalog_promotion_tshirts: + name: catalog_promotion + options: + custom: + thirts: + code: "thirts_50_off" + name: "-90% for tshirts (except mens)" + priority: 1000 + exclusive: true + starts_at: "now" + ends_at: "+14 day" + enabled: true + discount: 90.00 + rules: + - type: "has_taxon" + configuration: + taxons: + - "t_shirts" + - type: "has_not_taxon" + configuration: + taxons: + - "mens_t_shirts" + channels: + - "FASHION_WEB" - catalog_promotion_custom: + catalog_promotion_caps: name: catalog_promotion options: custom: @@ -39,10 +105,10 @@ sylius_fixtures: enabled: true discount: 20.00 rules: - - type: "has_taxon" - configuration: - taxons: - - "caps" + - type: "has_taxon" + configuration: + taxons: + - "caps" accidentally_disabled: code: "accidentally_disabled" name: "Accidentally disabled catalog promotion" @@ -51,10 +117,10 @@ sylius_fixtures: enabled: false discount: 10.00 rules: - - type: "has_taxon" - configuration: - taxons: - - "caps" + - type: "has_taxon" + configuration: + taxons: + - "caps" ny_caps_50_off: code: "ny_caps_50_off" @@ -67,12 +133,16 @@ sylius_fixtures: enabled: true discount: 50.00 rules: - - type: "has_taxon" - configuration: - taxons: - - "caps" + - type: "has_taxon" + configuration: + taxons: + - "caps" + - type: "has_not_taxon" + configuration: + taxons: + - "pompon_santa_caps" channels: - - "FASHION_WEB" + - "FASHION_WEB" ny_santa_cap_75_off: code: "ny_santa_cap_75_off" @@ -85,11 +155,11 @@ sylius_fixtures: enabled: true discount: 75.00 rules: - - type: "contains_product" - configuration: - product: "santa-cap" + - type: "contains_product" + configuration: + product: "santa_cap" channels: - - "FASHION_WEB" + - "FASHION_WEB" bf_santa_cap_75_off: code: "bf_santa_cap_75_off" @@ -102,9 +172,9 @@ sylius_fixtures: enabled: true discount: 75.00 rules: - - type: "contains_products" - configuration: - products: - - "santa-cap" + - type: "contains_products" + configuration: + products: + - "santa_cap" channels: - - "FASHION_WEB" + - "FASHION_WEB" diff --git a/src/Resources/config/services/form.xml b/src/Resources/config/services/form.xml index c989597..f9d569d 100644 --- a/src/Resources/config/services/form.xml +++ b/src/Resources/config/services/form.xml @@ -50,6 +50,12 @@ + + + + + diff --git a/src/Resources/config/services/rule.xml b/src/Resources/config/services/rule.xml index b47bde7..abba052 100644 --- a/src/Resources/config/services/rule.xml +++ b/src/Resources/config/services/rule.xml @@ -6,12 +6,22 @@ + + + + + + taxonRepository = $taxonRepository; + } + + public function filter(QueryBuilder $queryBuilder, array $configuration): void + { + $value = self::getConfigurationValue('taxons', $configuration); + Assert::isArray($value); + + $taxons = array_map(function (string $taxonCode): TaxonInterface { + /** @var TaxonInterface|null $taxon */ + $taxon = $this->taxonRepository->findOneBy(['code' => $taxonCode]); + Assert::notNull($taxon); + + return $taxon; + }, $value); + + $rootAlias = $this->getRootAlias($queryBuilder); + $productVariantAlias = self::generateAlias('pv'); + $productTaxonAlias = self::generateAlias('pt'); + $parameter = self::generateParameter('exclude_taxons'); + + $em = $queryBuilder->getEntityManager(); + $subQueryBuilder = $em->createQuery( + sprintf('SELECT %s.id ', $productVariantAlias) . + sprintf('FROM %s AS %s ', ProductVariant::class, $productVariantAlias) . + sprintf('LEFT JOIN %s AS %s WITH %s.product=%s.product ', ProductTaxon::class, $productTaxonAlias, $productTaxonAlias, $productVariantAlias) . + sprintf('WHERE %s.taxon IN (:%s)', $productTaxonAlias, $parameter) + ); + + /** @psalm-suppress PossiblyNullArgument */ + $queryBuilder + ->andWhere(sprintf('%s.id NOT IN (%s)', $rootAlias, $subQueryBuilder->getDQL())) + ->setParameter($parameter, $taxons) + ; + } +} diff --git a/src/Rule/HasTaxonRule.php b/src/Rule/HasTaxonRule.php index 4262c05..fff3f14 100644 --- a/src/Rule/HasTaxonRule.php +++ b/src/Rule/HasTaxonRule.php @@ -6,33 +6,53 @@ use Doctrine\ORM\QueryBuilder; use function sprintf; +use Sylius\Component\Core\Model\ProductTaxon; +use Sylius\Component\Core\Model\ProductVariant; +use Sylius\Component\Core\Model\TaxonInterface; +use Sylius\Component\Taxonomy\Repository\TaxonRepositoryInterface; use Webmozart\Assert\Assert; final class HasTaxonRule extends Rule { public const TYPE = 'has_taxon'; + private TaxonRepositoryInterface $taxonRepository; + + public function __construct(TaxonRepositoryInterface $taxonRepository) + { + $this->taxonRepository = $taxonRepository; + } + public function filter(QueryBuilder $queryBuilder, array $configuration): void { $value = self::getConfigurationValue('taxons', $configuration); Assert::isArray($value); $rootAlias = $this->getRootAlias($queryBuilder); - $productAlias = self::generateAlias('product'); - $productTaxonsAlias = self::generateAlias('product_taxons'); - $taxonAlias = self::generateAlias('taxon'); - $parameter = self::generateParameter('taxon_codes'); + $productVariantAlias = self::generateAlias('pv'); + $productTaxonAlias = self::generateAlias('pt'); + $parameter = self::generateParameter('include_taxons'); + + $taxons = array_map(function (string $taxonCode): TaxonInterface { + /** @var TaxonInterface|null $taxon */ + $taxon = $this->taxonRepository->findOneBy(['code' => $taxonCode]); + Assert::notNull($taxon); + + return $taxon; + }, $value); + + $em = $queryBuilder->getEntityManager(); + $subQueryBuilder = $em->createQuery( + sprintf('SELECT %s.id ', $productVariantAlias) . + sprintf('FROM %s AS %s ', ProductVariant::class, $productVariantAlias) . + sprintf('LEFT JOIN %s AS %s WITH %s.product=%s.product ', ProductTaxon::class, $productTaxonAlias, $productTaxonAlias, $productVariantAlias) . + sprintf('WHERE %s.taxon IN (:%s)', $productTaxonAlias, $parameter) + ); + /** @psalm-suppress PossiblyNullArgument */ $queryBuilder - ->join(sprintf('%s.product', $rootAlias), $productAlias) - ->join(sprintf('%s.productTaxons', $productAlias), $productTaxonsAlias) - ->join(sprintf('%s.taxon', $productTaxonsAlias), $taxonAlias) - ->andWhere(sprintf( - '%s.code IN (:%s)', - $taxonAlias, - $parameter - )) - ->setParameter($parameter, $value) + ->andWhere(sprintf('%s.id IN (%s)', $rootAlias, $subQueryBuilder->getDQL())) + ->setParameter($parameter, $taxons) ; } } diff --git a/src/Rule/Rule.php b/src/Rule/Rule.php index 59d327c..7fa8972 100644 --- a/src/Rule/Rule.php +++ b/src/Rule/Rule.php @@ -4,6 +4,7 @@ namespace Setono\SyliusCatalogPromotionPlugin\Rule; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use InvalidArgumentException; use RuntimeException; @@ -30,6 +31,30 @@ protected function getRootAlias(QueryBuilder $queryBuilder): string return $rootAlias; } + /** + * @return string Generated or existing alias + */ + protected function join(QueryBuilder $queryBuilder, string $join, string $aliasPrefix): string + { + /** @psalm-var array> $existingJoins */ + $existingJoins = $queryBuilder->getDQLPart('join'); + + $rootAlias = $this->getRootAlias($queryBuilder); + Assert::keyExists($existingJoins, $rootAlias); + + /** @var Join $existingJoin */ + foreach ($existingJoins[$rootAlias] as $existingJoin) { + if ($existingJoin->getJoin() === $join) { + return $existingJoin->getAlias(); + } + } + + $alias = self::generateAlias($aliasPrefix); + $queryBuilder->join($join, $alias); + + return $alias; + } + /** * @return mixed|null */ diff --git a/tests/Application/templates/bundles/SyliusShopBundle/Product/Box/_content.html.twig b/tests/Application/templates/bundles/SyliusShopBundle/Product/Box/_content.html.twig new file mode 100644 index 0000000..85abb99 --- /dev/null +++ b/tests/Application/templates/bundles/SyliusShopBundle/Product/Box/_content.html.twig @@ -0,0 +1,39 @@ +{% import "@SyliusShop/Common/Macro/money.html.twig" as money %} + +{# @var \Sylius\Component\Core\Model\ProductInterface product #} + +
+ +
+
+
+
{{ 'sylius.ui.view_more'|trans }}
+
+
+
+ {% include '@SyliusShop/Product/_mainImage.html.twig' with {'product': product} %} +
+
+ {{ product.name }} + {% if not product.variants.empty() %} + {# @var \Sylius\Component\Core\Model\ProductVariant variant #} + {% set variant = product|sylius_resolve_variant %} + + {# @var \Tests\Setono\SyliusCatalogPromotionPlugin\Application\Entity\ChannelPricing price #} + {% set price = variant.channelPricingForChannel(sylius.channel) %} + + {% if price.hasDiscount %} + + {{ money.convertAndFormat(price.originalPrice) }} + + + {{ money.convertAndFormat(price.price) }} + + {% else %} +
+ {{ money.convertAndFormat(price.price) }} +
+ {% endif %} + {% endif %} +
+
diff --git a/tests/Application/templates/bundles/SyliusShopBundle/Product/Show/_price.html.twig b/tests/Application/templates/bundles/SyliusShopBundle/Product/Show/_price.html.twig new file mode 100644 index 0000000..72d4e9d --- /dev/null +++ b/tests/Application/templates/bundles/SyliusShopBundle/Product/Show/_price.html.twig @@ -0,0 +1,25 @@ +{% import "@SyliusShop/Common/Macro/money.html.twig" as money %} + +{# @var \Sylius\Component\Core\Model\ProductInterface product #} +{% if not product.variants.empty() %} + {# @var \Sylius\Component\Core\Model\ProductVariant variant #} + {% set variant = product|sylius_resolve_variant %} + + {# @var \Tests\Setono\SyliusCatalogPromotionPlugin\Application\Entity\ChannelPricing price #} + {% set price = variant.channelPricingForChannel(sylius.channel) %} + + {% if price.hasDiscount %} + + + {{ money.convertAndFormat(price.originalPrice) }} + + + {{ money.convertAndFormat(price.price) }} + + + {% else %} + + {{ money.convertAndFormat(price.price) }} + + {% endif %} +{% endif %}