From 25b9c2047ca6edeee2e5f286e70bbff1747f564a Mon Sep 17 00:00:00 2001 From: Maxime Huran Date: Tue, 12 Dec 2023 18:01:54 +0100 Subject: [PATCH 1/7] Manage multiple coupons in order --- dist/src/Entity/Order/Order.php | 49 ++++++++++++ dist/src/Entity/Order/OrderInterface.php | 19 +++++ dist/src/Entity/Promotion/Promotion.php | 6 ++ dist/src/Migrations/Version20231218092305.php | 42 +++++++++++ src/Entity/AfterTaxAwareTrait.php | 8 +- src/Entity/PromotionCouponsAwareInterface.php | 27 +++++++ src/Entity/PromotionCouponsAwareTrait.php | 58 +++++++++++++++ src/Form/Extension/CartTypeExtension.php | 74 +++++++++++++++++++ src/Form/Type/PromotionCouponType.php | 36 +++++++++ ...bjectCouponEligibilityCheckerDecorator.php | 68 +++++++++++++++++ .../Modifier/OrderPromotionsUsageModifier.php | 52 +++++++++++++ .../Validator/PromotionSubjectCoupons.php | 24 ++++++ .../PromotionSubjectCouponsValidator.php | 58 +++++++++++++++ .../ActivePromotionsByChannelProvider.php | 55 ++++++++++++++ src/Resources/config/services.yaml | 3 + src/Resources/config/ui/cart.yaml | 10 +++ src/Resources/config/validation/Order.yaml | 8 ++ src/Resources/translations/messages.en.yml | 3 + src/Resources/translations/messages.fr.yml | 3 + .../Shop/Cart/Summary/_coupons.html.twig | 32 ++++++++ 20 files changed, 628 insertions(+), 7 deletions(-) create mode 100644 dist/src/Entity/Order/Order.php create mode 100644 dist/src/Entity/Order/OrderInterface.php create mode 100644 dist/src/Migrations/Version20231218092305.php create mode 100644 src/Entity/PromotionCouponsAwareInterface.php create mode 100644 src/Entity/PromotionCouponsAwareTrait.php create mode 100644 src/Form/Extension/CartTypeExtension.php create mode 100644 src/Form/Type/PromotionCouponType.php create mode 100644 src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php create mode 100644 src/Promotion/Modifier/OrderPromotionsUsageModifier.php create mode 100644 src/Promotion/Validator/PromotionSubjectCoupons.php create mode 100644 src/Promotion/Validator/PromotionSubjectCouponsValidator.php create mode 100644 src/Provider/ActivePromotionsByChannelProvider.php create mode 100644 src/Resources/config/ui/cart.yaml create mode 100644 src/Resources/config/validation/Order.yaml create mode 100644 src/Resources/views/Shop/Cart/Summary/_coupons.html.twig diff --git a/dist/src/Entity/Order/Order.php b/dist/src/Entity/Order/Order.php new file mode 100644 index 0000000..6f6b358 --- /dev/null +++ b/dist/src/Entity/Order/Order.php @@ -0,0 +1,49 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Entity\Order; + +use Doctrine\ORM\Mapping as ORM; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareTrait; +use Sylius\Component\Core\Model\Order as BaseOrder; +use Sylius\Component\Promotion\Model\PromotionCouponInterface; + +/** + * @ORM\Entity + * @ORM\Table(name="sylius_order") + */ +#[ORM\Entity] +#[ORM\Table(name: 'sylius_order')] +class Order extends BaseOrder implements OrderInterface +{ + use PromotionCouponsAwareTrait; + + /** + * @ORM\ManyToMany(targetEntity=PromotionCouponInterface::class) + * @ORM\JoinTable(name="monsieurbiz_advanced_promotion_order_promotion_coupon", + * joinColumns={@ORM\JoinColumn(name="order_id", referencedColumnName="id")}, + * inverseJoinColumns={@ORM\JoinColumn(name="promotion_coupon_id", referencedColumnName="id")} + * ) + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: PromotionCouponInterface::class)] + #[ORM\JoinTable(name: 'monsieurbiz_advanced_promotion_order_promotion_coupon')] + #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'promotion_coupon_id', referencedColumnName: 'id')] + private $promotionCoupons; + + public function __construct() + { + parent::__construct(); + $this->initializePromotionCoupons(); + } +} diff --git a/dist/src/Entity/Order/OrderInterface.php b/dist/src/Entity/Order/OrderInterface.php new file mode 100644 index 0000000..86cd62f --- /dev/null +++ b/dist/src/Entity/Order/OrderInterface.php @@ -0,0 +1,19 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Entity\Order; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; +use Sylius\Component\Core\Model\OrderInterface as BaseOrderInterface; + +interface OrderInterface extends BaseOrderInterface, PromotionCouponsAwareInterface +{ +} diff --git a/dist/src/Entity/Promotion/Promotion.php b/dist/src/Entity/Promotion/Promotion.php index 5406b96..fc072a1 100644 --- a/dist/src/Entity/Promotion/Promotion.php +++ b/dist/src/Entity/Promotion/Promotion.php @@ -24,4 +24,10 @@ class Promotion extends BasePromotion implements PromotionInterface { use AfterTaxAwareTrait; + + /** + * @ORM\Column(name="after_tax", type="boolean", nullable=false, options={"default": false}) + */ + #[ORM\Column(name: 'after_tax', type: 'boolean', nullable: false, options: ['default' => false])] + private bool $afterTax = false; } diff --git a/dist/src/Migrations/Version20231218092305.php b/dist/src/Migrations/Version20231218092305.php new file mode 100644 index 0000000..45cbe5f --- /dev/null +++ b/dist/src/Migrations/Version20231218092305.php @@ -0,0 +1,42 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Migrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +/** + * Auto-generated Migration: Please modify to your needs! + */ +final class Version20231218092305 extends AbstractMigration +{ + public function getDescription(): string + { + return ''; + } + + public function up(Schema $schema): void + { + // this up() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE monsieurbiz_advanced_promotion_order_promotion_coupon (order_id INT NOT NULL, promotion_coupon_id INT NOT NULL, INDEX IDX_5C4132AF8D9F6D38 (order_id), INDEX IDX_5C4132AF17B24436 (promotion_coupon_id), PRIMARY KEY(order_id, promotion_coupon_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE monsieurbiz_advanced_promotion_order_promotion_coupon ADD CONSTRAINT FK_5C4132AF8D9F6D38 FOREIGN KEY (order_id) REFERENCES sylius_order (id)'); + $this->addSql('ALTER TABLE monsieurbiz_advanced_promotion_order_promotion_coupon ADD CONSTRAINT FK_5C4132AF17B24436 FOREIGN KEY (promotion_coupon_id) REFERENCES sylius_promotion_coupon (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE monsieurbiz_advanced_promotion_order_promotion_coupon DROP FOREIGN KEY FK_5C4132AF8D9F6D38'); + $this->addSql('ALTER TABLE monsieurbiz_advanced_promotion_order_promotion_coupon DROP FOREIGN KEY FK_5C4132AF17B24436'); + $this->addSql('DROP TABLE monsieurbiz_advanced_promotion_order_promotion_coupon'); + } +} diff --git a/src/Entity/AfterTaxAwareTrait.php b/src/Entity/AfterTaxAwareTrait.php index 2a2851a..92e1957 100644 --- a/src/Entity/AfterTaxAwareTrait.php +++ b/src/Entity/AfterTaxAwareTrait.php @@ -11,15 +11,9 @@ namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity; -use Doctrine\ORM\Mapping as ORM; - trait AfterTaxAwareTrait { - /** - * @ORM\Column(name="after_tax", type="boolean", nullable=false, options={"default": false}) - */ - #[ORM\Column(name: 'after_tax', type: 'boolean', nullable: false, options: ['default' => false])] - protected bool $afterTax = false; + private bool $afterTax = false; public function isAfterTax(): bool { diff --git a/src/Entity/PromotionCouponsAwareInterface.php b/src/Entity/PromotionCouponsAwareInterface.php new file mode 100644 index 0000000..0b523ac --- /dev/null +++ b/src/Entity/PromotionCouponsAwareInterface.php @@ -0,0 +1,27 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity; + +use Doctrine\Common\Collections\Collection; +use Sylius\Component\Core\Model\PromotionCouponInterface; + +interface PromotionCouponsAwareInterface +{ + /** @return Collection */ + public function getPromotionCoupons(): Collection; + + public function hasPromotionCoupon(PromotionCouponInterface $promotionCoupon): bool; + + public function addPromotionCoupon(PromotionCouponInterface $promotionCoupon): void; + + public function removePromotionCoupon(PromotionCouponInterface $promotionCoupon): void; +} diff --git a/src/Entity/PromotionCouponsAwareTrait.php b/src/Entity/PromotionCouponsAwareTrait.php new file mode 100644 index 0000000..a142d92 --- /dev/null +++ b/src/Entity/PromotionCouponsAwareTrait.php @@ -0,0 +1,58 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Sylius\Component\Core\Model\PromotionCouponInterface; + +trait PromotionCouponsAwareTrait +{ + /** @var Collection */ + private $promotionCoupons; + + private function initializePromotionCoupons(): void + { + $this->promotionCoupons = new ArrayCollection(); + } + + /** @return Collection */ + public function getPromotionCoupons(): Collection + { + return $this->promotionCoupons; + } + + public function hasPromotionCoupon(PromotionCouponInterface $promotionCoupon): bool + { + foreach ($this->promotionCoupons as $currentPromotionCoupon) { + if ($currentPromotionCoupon === $promotionCoupon) { + return true; + } + } + + return false; + } + + public function addPromotionCoupon(PromotionCouponInterface $promotionCoupon): void + { + if (!$this->hasPromotionCoupon($promotionCoupon)) { + $this->promotionCoupons->add($promotionCoupon); + } + } + + public function removePromotionCoupon(PromotionCouponInterface $promotionCoupon): void + { + if ($this->hasPromotionCoupon($promotionCoupon)) { + $this->promotionCoupons->removeElement($promotionCoupon); + } + } +} diff --git a/src/Form/Extension/CartTypeExtension.php b/src/Form/Extension/CartTypeExtension.php new file mode 100644 index 0000000..1e50beb --- /dev/null +++ b/src/Form/Extension/CartTypeExtension.php @@ -0,0 +1,74 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Form\Extension; + +use Sylius\Bundle\OrderBundle\Form\Type\CartType; +use Sylius\Bundle\PromotionBundle\Form\Type\PromotionCouponToCodeType; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +final class CartTypeExtension extends AbstractTypeExtension +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('promotionCoupons', CollectionType::class, [ + 'entry_type' => PromotionCouponToCodeType::class, + 'entry_options' => [ + 'attr' => [ + 'form' => 'sylius_cart', + ], + ], + 'required' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'delete_empty' => true, + 'button_add_label' => 'monsieurbiz_sylius_advanced_promotion.coupons.add_coupon', + 'attr' => [ + 'class' => 'monsieurbiz-coupons', + ], + ]) + ; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setNormalizer('validation_groups', fn (Options $options, array $validationGroups) => function (FormInterface $form) use ($validationGroups) { + foreach ($form->get('promotionCoupons') as $promotionCoupon) { + if ((bool) $promotionCoupon->getNormData()) { // Validate the coupon if it was sent + $validationGroups[] = 'monsieurbiz_advanced_promotion_coupon'; + + break; + } + } + + return $validationGroups; + }); + } + + public static function getExtendedTypes(): iterable + { + return [ + CartType::class, + ]; + } +} diff --git a/src/Form/Type/PromotionCouponType.php b/src/Form/Type/PromotionCouponType.php new file mode 100644 index 0000000..d04160f --- /dev/null +++ b/src/Form/Type/PromotionCouponType.php @@ -0,0 +1,36 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Form\Type; + +use Sylius\Bundle\PromotionBundle\Form\Type\PromotionCouponToCodeType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; + +final class PromotionCouponType extends AbstractType +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('promotionCoupon', PromotionCouponToCodeType::class, [ + 'label' => 'sylius.form.cart.coupon', + 'required' => false, + 'by_reference' => false, + 'attr' => [ + 'form' => 'sylius_cart', + ], + ]) + ; + } +} diff --git a/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php b/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php new file mode 100644 index 0000000..089d8b9 --- /dev/null +++ b/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php @@ -0,0 +1,68 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Decorator; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionCouponEligibilityCheckerInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionSubjectCouponEligibilityChecker; +use Sylius\Component\Promotion\Model\PromotionInterface; +use Sylius\Component\Promotion\Model\PromotionSubjectInterface; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * @SuppressWarnings(PHPMD.LongClassName) + */ +#[AsDecorator('sylius.promotion_subject_coupon_eligibility_checker')] +final class PromotionSubjectCouponEligibilityCheckerDecorator implements PromotionEligibilityCheckerInterface +{ + public function __construct( + #[AutowireDecorated] + private readonly PromotionSubjectCouponEligibilityChecker $promotionSubjectCouponEligibilityChecker, + #[Autowire('@sylius.promotion_coupon_eligibility_checker')] + private readonly PromotionCouponEligibilityCheckerInterface $promotionCouponEligibilityChecker + ) { + } + + /** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool + { + if ($this->promotionSubjectCouponEligibilityChecker->isEligible($promotionSubject, $promotion)) { + return true; + } + + // Process our custom check if not eligible by decorated checker + if (!$promotion->isCouponBased()) { + return true; + } + + if (!$promotionSubject instanceof PromotionCouponsAwareInterface) { + return false; + } + + // Loop on order promotions with coupon to check if one is eligible + $promotionCoupons = $promotionSubject->getPromotionCoupons(); + foreach ($promotionCoupons as $promotionCoupon) { + if ($promotion !== $promotionCoupon->getPromotion()) { + continue; + } + + return $this->promotionCouponEligibilityChecker->isEligible($promotionSubject, $promotionCoupon); + } + + return false; + } +} diff --git a/src/Promotion/Modifier/OrderPromotionsUsageModifier.php b/src/Promotion/Modifier/OrderPromotionsUsageModifier.php new file mode 100644 index 0000000..e00a241 --- /dev/null +++ b/src/Promotion/Modifier/OrderPromotionsUsageModifier.php @@ -0,0 +1,52 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Modifier; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; +use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifierInterface; + +final class OrderPromotionsUsageModifier implements OrderPromotionsUsageModifierInterface +{ + public function increment(OrderInterface $order): void + { + if (!$order instanceof PromotionCouponsAwareInterface) { + return; + } + + // Same as Sylius but with multiple coupons + // @see Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifier + foreach ($order->getPromotionCoupons() as $promotionCoupon) { + $promotionCoupon->incrementUsed(); + } + } + + /** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function decrement(OrderInterface $order): void + { + if (!$order instanceof PromotionCouponsAwareInterface) { + return; + } + + // Same as Sylius but with multiple coupons + // @see Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifier + foreach ($order->getPromotionCoupons() as $promotionCoupon) { + if (OrderInterface::STATE_CANCELLED === $order->getState() && !$promotionCoupon->isReusableFromCancelledOrders()) { + continue; + } + + $promotionCoupon->decrementUsed(); + } + } +} diff --git a/src/Promotion/Validator/PromotionSubjectCoupons.php b/src/Promotion/Validator/PromotionSubjectCoupons.php new file mode 100644 index 0000000..2d10867 --- /dev/null +++ b/src/Promotion/Validator/PromotionSubjectCoupons.php @@ -0,0 +1,24 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator; + +use Symfony\Component\Validator\Constraint; + +final class PromotionSubjectCoupons extends Constraint +{ + public string $message = 'sylius.promotion_coupon.is_invalid'; + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Promotion/Validator/PromotionSubjectCouponsValidator.php b/src/Promotion/Validator/PromotionSubjectCouponsValidator.php new file mode 100644 index 0000000..7de801c --- /dev/null +++ b/src/Promotion/Validator/PromotionSubjectCouponsValidator.php @@ -0,0 +1,58 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface; +use Sylius\Component\Promotion\Model\PromotionSubjectInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Webmozart\Assert\Assert; + +final class PromotionSubjectCouponsValidator extends ConstraintValidator +{ + public function __construct( + #[Autowire('@sylius.promotion_eligibility_checker')] + private PromotionEligibilityCheckerInterface $promotionEligibilityChecker + ) { + } + + /** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function validate(mixed $value, Constraint $constraint): void + { + Assert::isInstanceOf($constraint, PromotionSubjectCoupons::class); + + if (!$value instanceof PromotionCouponsAwareInterface) { + return; + } + + foreach ($value->getPromotionCoupons() as $promotionCoupon) { + if (null === ($promotion = $promotionCoupon->getPromotion())) { + $this->context->buildViolation($constraint->message)->atPath('promotionCoupons')->addViolation(); + + continue; + } + + /** @var PromotionSubjectInterface $value */ + Assert::isInstanceOf($value, PromotionSubjectInterface::class); + + if ($this->promotionEligibilityChecker->isEligible($value, $promotion)) { + continue; + } + + $this->context->buildViolation($constraint->message)->atPath('promotionCoupons')->addViolation(); + } + } +} diff --git a/src/Provider/ActivePromotionsByChannelProvider.php b/src/Provider/ActivePromotionsByChannelProvider.php new file mode 100644 index 0000000..8dc83b8 --- /dev/null +++ b/src/Provider/ActivePromotionsByChannelProvider.php @@ -0,0 +1,55 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Provider; + +use Doctrine\Common\Collections\ArrayCollection; +use InvalidArgumentException; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; +use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Core\Repository\PromotionRepositoryInterface; +use Sylius\Component\Promotion\Model\PromotionSubjectInterface; +use Sylius\Component\Promotion\Provider\PreQualifiedPromotionsProviderInterface; +use Sylius\Component\Resource\Exception\UnexpectedTypeException; + +final class ActivePromotionsByChannelProvider implements PreQualifiedPromotionsProviderInterface +{ + public function __construct(private PromotionRepositoryInterface $promotionRepository) + { + } + + /** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function getPromotions(PromotionSubjectInterface $subject): array + { + if (!$subject instanceof OrderInterface) { + throw new UnexpectedTypeException($subject, OrderInterface::class); + } + + $channel = $subject->getChannel(); + if (null === $channel) { + throw new InvalidArgumentException('Order has no channel, but it should.'); + } + + $promotionCoupons = new ArrayCollection(); + if ($subject instanceof PromotionCouponsAwareInterface) { + $promotionCoupons = $subject->getPromotionCoupons(); + } + + // We add our condition on 0 promotion coupons + if (null === $subject->getPromotionCoupon() && 0 === $promotionCoupons->count()) { + return $this->promotionRepository->findActiveNonCouponBasedByChannel($channel); + } + + return $this->promotionRepository->findActiveByChannel($channel); + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 743d965..db32b63 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -30,3 +30,6 @@ services: $promotionProcessor: '@MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Processor\AfterTaxPromotionProcessor' tags: - { name: 'sylius.order_processor', priority: 5 } # Tax processor is 10 + + sylius.active_promotions_provider: + class: MonsieurBiz\SyliusAdvancedPromotionPlugin\Provider\ActivePromotionsByChannelProvider diff --git a/src/Resources/config/ui/cart.yaml b/src/Resources/config/ui/cart.yaml new file mode 100644 index 0000000..2a47a24 --- /dev/null +++ b/src/Resources/config/ui/cart.yaml @@ -0,0 +1,10 @@ + +sylius_ui: + events: + sylius.shop.cart.coupon: + blocks: + content: + enabled: false + monsieurbiz_advanced_promotion_coupons: + template: "@MonsieurBizSyliusAdvancedPromotionPlugin/Shop/Cart/Summary/_coupons.html.twig" + priority: 10 diff --git a/src/Resources/config/validation/Order.yaml b/src/Resources/config/validation/Order.yaml new file mode 100644 index 0000000..9054d74 --- /dev/null +++ b/src/Resources/config/validation/Order.yaml @@ -0,0 +1,8 @@ +App\Entity\Order\Order: + constraints: + - MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator\PromotionSubjectCoupons: + groups: [ monsieurbiz_advanced_promotion_coupon ] + # properties: + # promotionCoupons: + # - NotNull: + # groups: [ monsieurbiz_advanced_promotion_coupon ] diff --git a/src/Resources/translations/messages.en.yml b/src/Resources/translations/messages.en.yml index f0cb878..203b109 100644 --- a/src/Resources/translations/messages.en.yml +++ b/src/Resources/translations/messages.en.yml @@ -2,3 +2,6 @@ monsieurbiz_sylius_advanced_promotion: promotion: applied_after_tax: Applied after tax advanced_promotion_configuration: Advanced promotion configuration + coupons: + add_coupon: Add coupon + apply_coupons: Apply coupons diff --git a/src/Resources/translations/messages.fr.yml b/src/Resources/translations/messages.fr.yml index d5722a7..fc4eb98 100644 --- a/src/Resources/translations/messages.fr.yml +++ b/src/Resources/translations/messages.fr.yml @@ -2,3 +2,6 @@ monsieurbiz_sylius_advanced_promotion: promotion: applied_after_tax: Appliqué après la taxe advanced_promotion_configuration: Configuration de la promotion avancée + coupons: + add_coupon: Ajouter un coupon + apply_coupons: Appliquer les coupons diff --git a/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig b/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig new file mode 100644 index 0000000..2f221ed --- /dev/null +++ b/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig @@ -0,0 +1,32 @@ +{% if form.promotionCoupon.vars.value|default(null) %} + {% include '@SyliusShop/Cart/Summary/_coupon.html.twig' %} +{% else %} +
+
+ {{ form_widget(form.promotionCoupons) }} +
+ {{ form_errors(form.promotionCoupons) }} +
+
+ +
+
+ + +{% endif %} From 7fdbc9692f03d1fbfc5543b01ed60c1c7325fd04 Mon Sep 17 00:00:00 2001 From: Maxime Huran Date: Mon, 18 Dec 2023 14:12:39 +0100 Subject: [PATCH 2/7] Improve coupons validation --- src/Entity/PromotionCouponsAwareInterface.php | 2 +- src/Entity/PromotionCouponsAwareTrait.php | 2 +- src/Form/Extension/CartTypeExtension.php | 22 ++++++- src/Form/Type/PromotionCouponType.php | 3 - ...bjectCouponEligibilityCheckerDecorator.php | 2 +- .../Modifier/OrderPromotionsUsageModifier.php | 5 +- .../Validator/PromotionSubjectCoupons.php | 24 -------- .../PromotionSubjectCouponsValidator.php | 58 ------------------- src/Resources/config/validation/Order.yaml | 8 --- .../Shop/Cart/Summary/_coupons.html.twig | 13 ++++- src/Resources/views/Shop/Form/theme.html.twig | 58 +++++++++++++++++++ 11 files changed, 97 insertions(+), 100 deletions(-) delete mode 100644 src/Promotion/Validator/PromotionSubjectCoupons.php delete mode 100644 src/Promotion/Validator/PromotionSubjectCouponsValidator.php delete mode 100644 src/Resources/config/validation/Order.yaml create mode 100644 src/Resources/views/Shop/Form/theme.html.twig diff --git a/src/Entity/PromotionCouponsAwareInterface.php b/src/Entity/PromotionCouponsAwareInterface.php index 0b523ac..f4fa6e5 100644 --- a/src/Entity/PromotionCouponsAwareInterface.php +++ b/src/Entity/PromotionCouponsAwareInterface.php @@ -16,7 +16,7 @@ interface PromotionCouponsAwareInterface { - /** @return Collection */ + /** @return Collection */ public function getPromotionCoupons(): Collection; public function hasPromotionCoupon(PromotionCouponInterface $promotionCoupon): bool; diff --git a/src/Entity/PromotionCouponsAwareTrait.php b/src/Entity/PromotionCouponsAwareTrait.php index a142d92..bbd2048 100644 --- a/src/Entity/PromotionCouponsAwareTrait.php +++ b/src/Entity/PromotionCouponsAwareTrait.php @@ -25,7 +25,7 @@ private function initializePromotionCoupons(): void $this->promotionCoupons = new ArrayCollection(); } - /** @return Collection */ + /** @return Collection */ public function getPromotionCoupons(): Collection { return $this->promotionCoupons; diff --git a/src/Form/Extension/CartTypeExtension.php b/src/Form/Extension/CartTypeExtension.php index 1e50beb..0d479cb 100644 --- a/src/Form/Extension/CartTypeExtension.php +++ b/src/Form/Extension/CartTypeExtension.php @@ -13,12 +13,15 @@ use Sylius\Bundle\OrderBundle\Form\Type\CartType; use Sylius\Bundle\PromotionBundle\Form\Type\PromotionCouponToCodeType; +use Sylius\Component\Core\Model\PromotionCouponInterface; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; final class CartTypeExtension extends AbstractTypeExtension { @@ -34,11 +37,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'form' => 'sylius_cart', ], + 'constraints' => [ + new Assert\Callback( + [$this, 'validatePromotionEntry'], + ['monsieurbiz_advanced_promotion_coupon'] + ), + ], ], 'required' => false, 'allow_add' => true, 'allow_delete' => true, - 'delete_empty' => true, 'button_add_label' => 'monsieurbiz_sylius_advanced_promotion.coupons.add_coupon', 'attr' => [ 'class' => 'monsieurbiz-coupons', @@ -47,6 +55,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ; } + public function validatePromotionEntry(?PromotionCouponInterface $entry, ExecutionContextInterface $context): void + { + if (null !== $entry) { + return; + } + + $context + ->buildViolation('sylius.promotion_coupon.is_invalid') + ->addViolation() + ; + } + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ diff --git a/src/Form/Type/PromotionCouponType.php b/src/Form/Type/PromotionCouponType.php index d04160f..5c0799c 100644 --- a/src/Form/Type/PromotionCouponType.php +++ b/src/Form/Type/PromotionCouponType.php @@ -27,9 +27,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'sylius.form.cart.coupon', 'required' => false, 'by_reference' => false, - 'attr' => [ - 'form' => 'sylius_cart', - ], ]) ; } diff --git a/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php b/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php index 089d8b9..d7a89b8 100644 --- a/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php +++ b/src/Promotion/Decorator/PromotionSubjectCouponEligibilityCheckerDecorator.php @@ -56,7 +56,7 @@ public function isEligible(PromotionSubjectInterface $promotionSubject, Promotio // Loop on order promotions with coupon to check if one is eligible $promotionCoupons = $promotionSubject->getPromotionCoupons(); foreach ($promotionCoupons as $promotionCoupon) { - if ($promotion !== $promotionCoupon->getPromotion()) { + if (!$promotionCoupon || $promotion !== $promotionCoupon->getPromotion()) { continue; } diff --git a/src/Promotion/Modifier/OrderPromotionsUsageModifier.php b/src/Promotion/Modifier/OrderPromotionsUsageModifier.php index e00a241..6ef4d8d 100644 --- a/src/Promotion/Modifier/OrderPromotionsUsageModifier.php +++ b/src/Promotion/Modifier/OrderPromotionsUsageModifier.php @@ -26,6 +26,9 @@ public function increment(OrderInterface $order): void // Same as Sylius but with multiple coupons // @see Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifier foreach ($order->getPromotionCoupons() as $promotionCoupon) { + if (!$promotionCoupon) { + continue; + } $promotionCoupon->incrementUsed(); } } @@ -42,7 +45,7 @@ public function decrement(OrderInterface $order): void // Same as Sylius but with multiple coupons // @see Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifier foreach ($order->getPromotionCoupons() as $promotionCoupon) { - if (OrderInterface::STATE_CANCELLED === $order->getState() && !$promotionCoupon->isReusableFromCancelledOrders()) { + if (!$promotionCoupon || OrderInterface::STATE_CANCELLED === $order->getState() && !$promotionCoupon->isReusableFromCancelledOrders()) { continue; } diff --git a/src/Promotion/Validator/PromotionSubjectCoupons.php b/src/Promotion/Validator/PromotionSubjectCoupons.php deleted file mode 100644 index 2d10867..0000000 --- a/src/Promotion/Validator/PromotionSubjectCoupons.php +++ /dev/null @@ -1,24 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE.txt - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator; - -use Symfony\Component\Validator\Constraint; - -final class PromotionSubjectCoupons extends Constraint -{ - public string $message = 'sylius.promotion_coupon.is_invalid'; - - public function getTargets(): string - { - return self::CLASS_CONSTRAINT; - } -} diff --git a/src/Promotion/Validator/PromotionSubjectCouponsValidator.php b/src/Promotion/Validator/PromotionSubjectCouponsValidator.php deleted file mode 100644 index 7de801c..0000000 --- a/src/Promotion/Validator/PromotionSubjectCouponsValidator.php +++ /dev/null @@ -1,58 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE.txt - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator; - -use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\PromotionCouponsAwareInterface; -use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface; -use Sylius\Component\Promotion\Model\PromotionSubjectInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\ConstraintValidator; -use Webmozart\Assert\Assert; - -final class PromotionSubjectCouponsValidator extends ConstraintValidator -{ - public function __construct( - #[Autowire('@sylius.promotion_eligibility_checker')] - private PromotionEligibilityCheckerInterface $promotionEligibilityChecker - ) { - } - - /** - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - public function validate(mixed $value, Constraint $constraint): void - { - Assert::isInstanceOf($constraint, PromotionSubjectCoupons::class); - - if (!$value instanceof PromotionCouponsAwareInterface) { - return; - } - - foreach ($value->getPromotionCoupons() as $promotionCoupon) { - if (null === ($promotion = $promotionCoupon->getPromotion())) { - $this->context->buildViolation($constraint->message)->atPath('promotionCoupons')->addViolation(); - - continue; - } - - /** @var PromotionSubjectInterface $value */ - Assert::isInstanceOf($value, PromotionSubjectInterface::class); - - if ($this->promotionEligibilityChecker->isEligible($value, $promotion)) { - continue; - } - - $this->context->buildViolation($constraint->message)->atPath('promotionCoupons')->addViolation(); - } - } -} diff --git a/src/Resources/config/validation/Order.yaml b/src/Resources/config/validation/Order.yaml deleted file mode 100644 index 9054d74..0000000 --- a/src/Resources/config/validation/Order.yaml +++ /dev/null @@ -1,8 +0,0 @@ -App\Entity\Order\Order: - constraints: - - MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Validator\PromotionSubjectCoupons: - groups: [ monsieurbiz_advanced_promotion_coupon ] - # properties: - # promotionCoupons: - # - NotNull: - # groups: [ monsieurbiz_advanced_promotion_coupon ] diff --git a/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig b/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig index 2f221ed..bc7e98b 100644 --- a/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig +++ b/src/Resources/views/Shop/Cart/Summary/_coupons.html.twig @@ -1,11 +1,11 @@ +{% form_theme form '@MonsieurBizSyliusAdvancedPromotionPlugin/Shop/Form/theme.html.twig' %} + {% if form.promotionCoupon.vars.value|default(null) %} {% include '@SyliusShop/Cart/Summary/_coupon.html.twig' %} {% else %}
{{ form_widget(form.promotionCoupons) }} -
- {{ form_errors(form.promotionCoupons) }}