diff --git a/features/shop/promotion/applying_promotion_rules/receiving_discount_based_on_total_of_items_from_specific_taxon.feature b/features/shop/promotion/applying_promotion_rules/receiving_discount_based_on_total_of_items_from_specific_taxon.feature index da9aa675b0..fc4a0aefb9 100644 --- a/features/shop/promotion/applying_promotion_rules/receiving_discount_based_on_total_of_items_from_specific_taxon.feature +++ b/features/shop/promotion/applying_promotion_rules/receiving_discount_based_on_total_of_items_from_specific_taxon.feature @@ -56,3 +56,48 @@ Feature: Receiving discount based on total of items from specific taxon When I check the details of my cart Then my cart total should be "$125.00" And my discount should be "-$15.00" + + @api @ui + Scenario: Receiving discount on order while buying product from child taxon of promoted taxon when child taxons are included + Given the "T-Shirts" taxon has child taxon "Polo T-Shirts" + And the store has a product "Polo T-Shirt" priced at "$100.00" + And it belongs to "Polo T-Shirts" + And the promotion gives "$20.00" off if order contains products classified as "T-Shirts" with a minimum value of "$50.00", including child taxons + And I added product "Polo T-Shirt" to the cart + When I check the details of my cart + Then my cart total should be "$80.00" + And my discount should be "-$20.00" + + @api @ui + Scenario: Receiving no discount on order while buying product from child taxon of promoted taxon which not fits price criteria + Given the "T-Shirts" taxon has child taxon "Polo T-Shirts" + And the store has a product "Polo T-Shirt" priced at "$30.00" + And it belongs to "Polo T-Shirts" + And the promotion gives "$20.00" off if order contains products classified as "T-Shirts" with a minimum value of "$50.00", including child taxons + And I added product "Polo T-Shirt" to the cart + When I check the details of my cart + Then my cart total should be "$30.00" + And there should be no discount applied + + @api @ui + Scenario: Receiving discount on order while buying products from both parent and child taxon which fit price criteria + Given the "T-Shirts" taxon has child taxon "Polo T-Shirts" + And the store has a product "Polo T-Shirt" priced at "$60.00" + And it belongs to "Polo T-Shirts" + And the promotion gives "$10.00" off if order contains products classified as "T-Shirts" with a minimum value of "$50.00", including child taxons + And I added product "PHP T-Shirt" to the cart + And I added product "Polo T-Shirt" to the cart + When I check the details of my cart + Then my cart total should be "$150.00" + And my discount should be "-$10.00" + + @api @ui + Scenario: Receiving no discount on order while buying product from child taxon when child taxons are excluded from the rule + Given the "T-Shirts" taxon has child taxon "Polo T-Shirts" + And the store has a product "Polo T-Shirt" priced at "$100.00" + And it belongs to "Polo T-Shirts" + And the promotion gives "$20.00" off if order contains products classified as "T-Shirts" with a minimum value of "$50.00" + And I added product "Polo T-Shirt" to the cart + When I check the details of my cart + Then my cart total should be "$100.00" + And there should be no discount applied diff --git a/src/Sylius/Behat/Context/Setup/PromotionContext.php b/src/Sylius/Behat/Context/Setup/PromotionContext.php index 64a3a179b2..f129186b18 100644 --- a/src/Sylius/Behat/Context/Setup/PromotionContext.php +++ b/src/Sylius/Behat/Context/Setup/PromotionContext.php @@ -635,6 +635,19 @@ final readonly class PromotionContext implements Context $this->createFixedPromotion($promotion, $discount, [], $rule); } + #[Given('/^([^"]+) gives ("(?:€|£|\$)[^"]+") off if order contains products (classified as "[^"]+") with a minimum value of ("(?:€|£|\$)[^"]+"), including child taxons$/')] + public function thePromotionGivesOffIfOrderContainsProductsClassifiedAsAndPricedAtIncludingChildTaxons( + PromotionInterface $promotion, + int $discount, + TaxonInterface $taxon, + int $amount, + ): void { + $channelCode = $this->getChannelCode(); + $rule = $this->ruleFactory->createItemsFromTaxonTotal($channelCode, $taxon->getCode(), $amount, true); + + $this->createFixedPromotion($promotion, $discount, [], $rule); + } + #[Given('/^([^"]+) gives ("(?:€|£|\$)[^"]+") off customer\'s (\d)(?:st|nd|rd|th) order$/')] public function itGivesFixedOffCustomersNthOrder(PromotionInterface $promotion, int $discount, int $nth): void { diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/TotalOfItemsFromTaxonConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/TotalOfItemsFromTaxonConfigurationType.php index acedd0d083..5df4532646 100644 --- a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/TotalOfItemsFromTaxonConfigurationType.php +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/TotalOfItemsFromTaxonConfigurationType.php @@ -19,6 +19,7 @@ use Sylius\Bundle\TaxonomyBundle\Form\Type\TaxonAutocompleteChoiceType; use Sylius\Component\Core\Model\TaxonInterface; use Sylius\Resource\Doctrine\Persistence\RepositoryInterface; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\ReversedTransformer; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -40,6 +41,10 @@ final class TotalOfItemsFromTaxonConfigurationType extends AbstractType 'label' => 'sylius.form.promotion_rule.total_of_items_from_taxon.amount', 'currency' => $options['currency'], ]) + ->add('include_child_taxons', CheckboxType::class, [ + 'label' => 'sylius.form.promotion_rule.total_of_items_from_taxon.include_child_taxons', + 'required' => false, + ]) ; $builder->get('taxon')->addModelTransformer( diff --git a/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml b/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml index 4c97e99907..6b1ff5dd68 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml +++ b/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml @@ -205,8 +205,9 @@ sylius: product: products: Products total_of_items_from_taxon: - taxon: Taxon amount: Amount + include_child_taxons: Include products from child taxons + taxon: Taxon payment_method: channels: Channels product_attribute: diff --git a/src/Sylius/Component/Core/Factory/PromotionRuleFactory.php b/src/Sylius/Component/Core/Factory/PromotionRuleFactory.php index 30647441c4..9e2d420266 100644 --- a/src/Sylius/Component/Core/Factory/PromotionRuleFactory.php +++ b/src/Sylius/Component/Core/Factory/PromotionRuleFactory.php @@ -54,13 +54,16 @@ final class PromotionRuleFactory implements PromotionRuleFactoryInterface return $this->createPromotionRule(HasTaxonRuleChecker::TYPE, ['taxons' => $taxons]); } - public function createItemsFromTaxonTotal(string $channelCode, string $taxonCode, int $amount): PromotionRuleInterface - { + public function createItemsFromTaxonTotal( + string $channelCode, + string $taxonCode, + int $amount, + bool $includeChildTaxons = false, + ): PromotionRuleInterface { return $this->createPromotionRule( TotalOfItemsFromTaxonRuleChecker::TYPE, - [$channelCode => ['taxon' => $taxonCode, 'amount' => $amount]], - ) - ; + [$channelCode => ['amount' => $amount, 'include_child_taxons' => $includeChildTaxons, 'taxon' => $taxonCode]], + ); } public function createNthOrder(int $nth): PromotionRuleInterface diff --git a/src/Sylius/Component/Core/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleChecker.php b/src/Sylius/Component/Core/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleChecker.php index 3799d8c57f..cf0ab06cca 100644 --- a/src/Sylius/Component/Core/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleChecker.php +++ b/src/Sylius/Component/Core/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleChecker.php @@ -15,6 +15,7 @@ namespace Sylius\Component\Core\Promotion\Checker\Rule; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\OrderItemInterface; +use Sylius\Component\Core\Model\ProductInterface; use Sylius\Component\Core\Model\TaxonInterface; use Sylius\Component\Promotion\Checker\Rule\RuleCheckerInterface; use Sylius\Component\Promotion\Exception\UnsupportedTypeException; @@ -56,15 +57,43 @@ final class TotalOfItemsFromTaxonRuleChecker implements RuleCheckerInterface return false; } + $includeChildTaxons = $configuration['include_child_taxons'] ?? false; $itemsWithTaxonTotal = 0; /** @var OrderItemInterface $item */ foreach ($subject->getItems() as $item) { - if ($item->getProduct()->hasTaxon($targetTaxon)) { + $belongs = $includeChildTaxons + ? $this->productBelongsToTaxonOrDescendant($item->getProduct(), $targetTaxon) + : $this->productBelongsToTaxon($item->getProduct(), $targetTaxon); + if ($belongs) { $itemsWithTaxonTotal += $item->getTotal(); } } return $itemsWithTaxonTotal >= $configuration['amount']; } + + private function productBelongsToTaxon(?ProductInterface $product, TaxonInterface $targetTaxon): bool + { + if ($product === null) { + return false; + } + + return $product->getTaxons()->contains($targetTaxon); + } + + private function productBelongsToTaxonOrDescendant(?ProductInterface $product, TaxonInterface $targetTaxon): bool + { + if ($product === null) { + return false; + } + + foreach ($product->getTaxons() as $taxon) { + if ($taxon === $targetTaxon || $taxon->getAncestors()->contains($targetTaxon)) { + return true; + } + } + + return false; + } } diff --git a/src/Sylius/Component/Core/tests/Factory/PromotionRuleFactoryTest.php b/src/Sylius/Component/Core/tests/Factory/PromotionRuleFactoryTest.php index b3a68205b0..2dcafe887b 100644 --- a/src/Sylius/Component/Core/tests/Factory/PromotionRuleFactoryTest.php +++ b/src/Sylius/Component/Core/tests/Factory/PromotionRuleFactoryTest.php @@ -84,11 +84,20 @@ final class PromotionRuleFactoryTest extends TestCase { $this->decoratedFactory->expects($this->once())->method('createNew')->willReturn($this->rule); $this->rule->expects($this->once())->method('setType')->with(TotalOfItemsFromTaxonRuleChecker::TYPE); - $this->rule->expects($this->once())->method('setConfiguration')->with(['WEB_US' => ['taxon' => 'spears', 'amount' => 1000]]); + $this->rule->expects($this->once())->method('setConfiguration')->with(['WEB_US' => ['amount' => 1000, 'include_child_taxons' => false, 'taxon' => 'spears']]); $this->assertSame($this->rule, $this->factory->createItemsFromTaxonTotal('WEB_US', 'spears', 1000)); } + public function testShouldCreateTotalOfItemsFromTaxonRuleWithChildTaxons(): void + { + $this->decoratedFactory->expects($this->once())->method('createNew')->willReturn($this->rule); + $this->rule->expects($this->once())->method('setType')->with(TotalOfItemsFromTaxonRuleChecker::TYPE); + $this->rule->expects($this->once())->method('setConfiguration')->with(['WEB_US' => ['amount' => 1000, 'include_child_taxons' => true, 'taxon' => 'spears']]); + + $this->assertSame($this->rule, $this->factory->createItemsFromTaxonTotal('WEB_US', 'spears', 1000, true)); + } + public function testShouldCreateNthOrderRule(): void { $this->decoratedFactory->expects($this->once())->method('createNew')->willReturn($this->rule); diff --git a/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleCheckerTest.php b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleCheckerTest.php index 0b51ffb4f4..f737356395 100644 --- a/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleCheckerTest.php +++ b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/TotalOfItemsFromTaxonRuleCheckerTest.php @@ -85,12 +85,12 @@ final class TotalOfItemsFromTaxonRuleCheckerTest extends TestCase ->with(['code' => 'bows']) ->willReturn($this->bows); $this->compositeBowItem->expects($this->once())->method('getProduct')->willReturn($this->compositeBow); - $this->compositeBow->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(true); + $this->compositeBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); $this->compositeBowItem->expects($this->once())->method('getTotal')->willReturn(5000); $this->longswordItem->expects($this->once())->method('getProduct')->willReturn($this->longsword); - $this->longsword->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(false); + $this->longsword->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([])); $this->reflexBowItem->expects($this->once())->method('getProduct')->willReturn($this->reflexBow); - $this->reflexBow->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(true); + $this->reflexBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); $this->reflexBowItem->expects($this->once())->method('getTotal')->willReturn(9000); $this->assertTrue( @@ -112,10 +112,10 @@ final class TotalOfItemsFromTaxonRuleCheckerTest extends TestCase ->with(['code' => 'bows']) ->willReturn($this->bows); $this->compositeBowItem->expects($this->once())->method('getProduct')->willReturn($this->compositeBow); - $this->compositeBow->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(true); + $this->compositeBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); $this->compositeBowItem->expects($this->once())->method('getTotal')->willReturn(5000); $this->reflexBowItem->expects($this->once())->method('getProduct')->willReturn($this->reflexBow); - $this->reflexBow->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(true); + $this->reflexBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); $this->reflexBowItem->expects($this->once())->method('getTotal')->willReturn(5000); $this->assertTrue( @@ -123,6 +123,62 @@ final class TotalOfItemsFromTaxonRuleCheckerTest extends TestCase ); } + public function testShouldRecognizeSubjectAsEligibleIfItHasItemsFromChildTaxonOfConfiguredTaxon(): void + { + $recurveBows = $this->createMock(TaxonInterface::class); + $recurveBows->method('getAncestors')->willReturn(new ArrayCollection([$this->bows])); + + $recurveBowItem = $this->createMock(OrderItemInterface::class); + $recurveBow = $this->createMock(ProductInterface::class); + + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->order->expects($this->once())->method('getItems')->willReturn(new ArrayCollection([ + $this->compositeBowItem, + $recurveBowItem, + ])); + $this->taxonRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['code' => 'bows']) + ->willReturn($this->bows); + $this->compositeBowItem->expects($this->once())->method('getProduct')->willReturn($this->compositeBow); + $this->compositeBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); + $this->compositeBowItem->expects($this->once())->method('getTotal')->willReturn(5000); + $recurveBowItem->expects($this->once())->method('getProduct')->willReturn($recurveBow); + $recurveBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$recurveBows])); + $recurveBowItem->expects($this->once())->method('getTotal')->willReturn(5000); + + $this->assertTrue( + $this->ruleChecker->isEligible($this->order, ['WEB_US' => ['taxon' => 'bows', 'amount' => 10000, 'include_child_taxons' => true]]), + ); + } + + public function testShouldNotRecognizeSubjectAsEligibleIfItHasItemsOnlyFromChildTaxonWhenChildTaxonsExcluded(): void + { + $recurveBows = $this->createMock(TaxonInterface::class); + + $recurveBowItem = $this->createMock(OrderItemInterface::class); + $recurveBow = $this->createMock(ProductInterface::class); + + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->order->expects($this->once())->method('getItems')->willReturn(new ArrayCollection([ + $recurveBowItem, + ])); + $this->taxonRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['code' => 'bows']) + ->willReturn($this->bows); + $recurveBowItem->expects($this->once())->method('getProduct')->willReturn($recurveBow); + $recurveBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$recurveBows])); + + $this->assertFalse( + $this->ruleChecker->isEligible($this->order, ['WEB_US' => ['taxon' => 'bows', 'amount' => 5000, 'include_child_taxons' => false]]), + ); + } + public function testShouldNotRecognizeSubjectAsEligibleIfItemsFromRequiredTaxonHasTooLowTotal(): void { $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); @@ -137,10 +193,10 @@ final class TotalOfItemsFromTaxonRuleCheckerTest extends TestCase ->with(['code' => 'bows']) ->willReturn($this->bows); $this->compositeBowItem->expects($this->once())->method('getProduct')->willReturn($this->compositeBow); - $this->compositeBow->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(true); + $this->compositeBow->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([$this->bows])); $this->compositeBowItem->expects($this->once())->method('getTotal')->willReturn(5000); $this->longswordItem->expects($this->once())->method('getProduct')->willReturn($this->longsword); - $this->longsword->expects($this->once())->method('hasTaxon')->with($this->bows)->willReturn(false); + $this->longsword->expects($this->once())->method('getTaxons')->willReturn(new ArrayCollection([])); $this->assertFalse( $this->ruleChecker->isEligible($this->order, ['WEB_US' => ['taxon' => 'bows', 'amount' => 10000]]),