├── LICENSE ├── composer-dependency-analyser.php ├── composer.json ├── features ├── applying_promotions │ ├── applying_exclusive_promotions.feature │ ├── applying_multiple_promotions_to_one_product.feature │ ├── applying_promotions_applicable_for_channel_on_multichannel_store.feature │ ├── applying_promotions_applicable_for_channel_on_single_channel_store.feature │ ├── applying_promotions_on_products_from_specific_taxon.feature │ ├── applying_promotions_on_specific_products.feature │ └── applying_promotions_with_expiration_date.feature ├── cli │ └── process_catalog_promotions.feature └── managing_catalog_promotions │ ├── adding_catalog_promotion.feature │ ├── adding_catalog_promotion_with_action.feature │ ├── adding_catalog_promotion_with_rule.feature │ ├── browsing_catalog_promotions.feature │ ├── catalog_promotion_unique_code_validation.feature │ ├── catalog_promotion_validation.feature │ ├── deleting_catalog_promotion.feature │ ├── deleting_multiple_catalog_promotions.feature │ ├── editing_catalog_promotion.feature │ └── sorting_discounts_by_priority.feature ├── infection.json.dist ├── rector.php └── src ├── Applicator ├── RuntimePromotionsApplicator.php └── RuntimePromotionsApplicatorInterface.php ├── Calculator └── ProductVariantPricesCalculator.php ├── Checker ├── OnSale │ ├── OnSaleChecker.php │ └── OnSaleCheckerInterface.php ├── PreQualification │ ├── CompositePreQualificationChecker.php │ ├── PreQualificationCheckerInterface.php │ ├── Rule │ │ ├── ContainsProductRuleChecker.php │ │ ├── ContainsProductsRuleChecker.php │ │ ├── HasNotTaxonRuleChecker.php │ │ ├── HasTaxonRuleChecker.php │ │ └── RuleCheckerInterface.php │ └── RulesPreQualificationChecker.php └── Runtime │ ├── ChannelContextRuntimeChecker.php │ ├── CompositeRuntimeChecker.php │ ├── DateRuntimeChecker.php │ ├── EnabledRuntimeChecker.php │ └── RuntimeCheckerInterface.php ├── Command ├── PruneCatalogPromotionUpdatesCommand.php └── UpdateCommand.php ├── Context └── StaticChannelContext.php ├── Controller └── UpdateAllAction.php ├── DataProvider ├── ProductDataProvider.php └── ProductDataProviderInterface.php ├── DependencyInjection ├── Compiler │ ├── OverrideProductVariantPricesCalculatorPass.php │ └── RegisterRulesAndRuleCheckersPass.php ├── Configuration.php └── SetonoSyliusCatalogPromotionExtension.php ├── Event ├── CatalogPromotionsAppliedEvent.php └── DataProviderQueryBuilderCreatedEvent.php ├── EventSubscriber ├── AddAdminMenuSubscriber.php ├── UpdateCatalogPromotionSubscriber.php └── UpdateProductSubscriber.php ├── Factory ├── CatalogPromotionRuleFactory.php ├── CatalogPromotionRuleFactoryInterface.php ├── CatalogPromotionUpdateFactory.php └── CatalogPromotionUpdateFactoryInterface.php ├── Fixture ├── CatalogPromotionFixture.php └── Factory │ ├── CatalogPromotionExampleFactory.php │ └── CatalogPromotionRuleExampleFactory.php ├── Form └── Type │ ├── CatalogPromotionRuleChoiceType.php │ ├── CatalogPromotionRuleCollectionType.php │ ├── CatalogPromotionRuleType.php │ ├── CatalogPromotionType.php │ └── Rule │ ├── ContainsProductConfigurationType.php │ ├── ContainsProductsConfigurationType.php │ ├── HasNotTaxonConfigurationType.php │ └── HasTaxonConfigurationType.php ├── Message ├── Command │ ├── AsyncCommandInterface.php │ ├── CheckCatalogPromotionUpdate.php │ ├── ProcessCatalogPromotionUpdate.php │ ├── StartCatalogPromotionUpdate.php │ └── UpdateProducts.php └── CommandHandler │ ├── CheckCatalogPromotionUpdateHandler.php │ ├── MessageBuffer.php │ ├── ProcessCatalogPromotionUpdateHandler.php │ ├── StartCatalogPromotionUpdateHandler.php │ └── UpdateProductsHandler.php ├── Model ├── CatalogPromotion.php ├── CatalogPromotionInterface.php ├── CatalogPromotionRule.php ├── CatalogPromotionRuleInterface.php ├── CatalogPromotionUpdate.php ├── CatalogPromotionUpdateInterface.php ├── ProductInterface.php └── ProductTrait.php ├── Repository ├── CatalogPromotionRepository.php └── CatalogPromotionRepositoryInterface.php ├── Resources ├── config │ ├── app │ │ └── fixtures.yaml │ ├── doctrine │ │ └── model │ │ │ ├── CatalogPromotion.orm.xml │ │ │ ├── CatalogPromotionRule.orm.xml │ │ │ └── CatalogPromotionUpdate.orm.xml │ ├── routes.yaml │ ├── routes │ │ └── admin.yaml │ ├── services.xml │ ├── services │ │ ├── applicator.xml │ │ ├── calculator.xml │ │ ├── checker.xml │ │ ├── command.xml │ │ ├── context.xml │ │ ├── controller.xml │ │ ├── data_provider.xml │ │ ├── event_subscriber.xml │ │ ├── factory.xml │ │ ├── fixture.xml │ │ ├── form.xml │ │ ├── message.xml │ │ ├── registry.xml │ │ └── validator.xml │ └── validation │ │ ├── CatalogPromotion.xml │ │ └── CatalogPromotionRule.xml ├── translations │ ├── flashes.en.yaml │ ├── messages.en.yaml │ └── validators.en.yaml └── views │ └── admin │ ├── catalog_promotion │ ├── _form.html.twig │ └── _javascripts.html.twig │ ├── catalog_promotion_update │ ├── _javascripts.html.twig │ └── label │ │ └── state │ │ ├── completed.html.twig │ │ ├── failed.html.twig │ │ ├── pending.html.twig │ │ └── processing.html.twig │ └── grid │ └── field │ ├── _channels.html.twig │ ├── catalog_promotion_list.html.twig │ ├── discount.html.twig │ ├── ends_at.html.twig │ ├── error.html.twig │ ├── product_list.html.twig │ ├── products_updated.html.twig │ └── starts_at.html.twig ├── SetonoSyliusCatalogPromotionPlugin.php ├── Validator └── Constraints │ ├── CatalogPromotionDateRange.php │ └── CatalogPromotionDateRangeValidator.php └── Workflow └── CatalogPromotionUpdateWorkflow.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Acme 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer-dependency-analyser.php: -------------------------------------------------------------------------------- 1 | addPathToExclude(__DIR__ . '/tests') 8 | ->ignoreErrorsOnPackage('fakerphp/faker', [ErrorType::SHADOW_DEPENDENCY]) 9 | ->ignoreErrorsOnPackage('twig/extra-bundle', [ErrorType::UNUSED_DEPENDENCY]) // We use this to register the StringExtension 10 | ->ignoreErrorsOnPackage('twig/string-extra', [ErrorType::UNUSED_DEPENDENCY]) // This is the StringExtension that we use for the 'u' filter 11 | ->ignoreErrorsOnPackage('twig/twig', [ErrorType::UNUSED_DEPENDENCY]) // Obviously we use this 12 | ; 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setono/sylius-catalog-promotion-plugin", 3 | "description": "Catalog promotion plugin for Sylius", 4 | "license": "MIT", 5 | "type": "sylius-plugin", 6 | "keywords": [ 7 | "sylius", 8 | "sylius-plugin", 9 | "catalog", 10 | "setono", 11 | "promotions" 12 | ], 13 | "require": { 14 | "php": ">=8.1", 15 | "doctrine/collections": "^1.8", 16 | "doctrine/orm": "^2.10", 17 | "doctrine/persistence": "^2.5 || ^3.4", 18 | "eventsauce/backoff": "^1.2", 19 | "knplabs/knp-menu": "^3.0", 20 | "ocramius/doctrine-batch-utils": "^2.4", 21 | "psr/clock": "^1.0", 22 | "psr/event-dispatcher": "^1.0", 23 | "setono/composite-compiler-pass": "^1.2", 24 | "setono/doctrine-orm-trait": "^1.1", 25 | "sylius/channel": "^1.0", 26 | "sylius/channel-bundle": "^1.0", 27 | "sylius/core": "^1.10.8", 28 | "sylius/core-bundle": "^1.0", 29 | "sylius/product-bundle": "^1.0", 30 | "sylius/promotion-bundle": "^1.0", 31 | "sylius/registry": "^1.6", 32 | "sylius/resource-bundle": "^1.8", 33 | "sylius/taxonomy-bundle": "^1.0", 34 | "sylius/ui-bundle": "^1.0", 35 | "symfony/config": "^5.4 || ^6.4 || ^7.0", 36 | "symfony/console": "^5.4 || ^6.4 || ^7.0", 37 | "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", 38 | "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", 39 | "symfony/form": "^5.4 || ^6.4 || ^7.0", 40 | "symfony/http-foundation": "^5.4 || ^6.4 || ^7.0", 41 | "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", 42 | "symfony/messenger": "^5.4 || ^6.4 || ^7.0", 43 | "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", 44 | "symfony/routing": "^5.4 || ^6.4 || ^7.0", 45 | "symfony/uid": "^5.4 || ^6.4 || ^7.0", 46 | "symfony/validator": "^5.4 || ^6.4 || ^7.0", 47 | "symfony/workflow": "^5.4 || ^6.4 || ^7.0", 48 | "twig/extra-bundle": "^2.12 || ^3.0", 49 | "twig/string-extra": "^2.12 || ^3.0", 50 | "twig/twig": "^2.12 || ^3.0", 51 | "webmozart/assert": "^1.11" 52 | }, 53 | "require-dev": { 54 | "api-platform/core": "^2.7.16", 55 | "babdev/pagerfanta-bundle": "^3.8", 56 | "behat/behat": "^3.14", 57 | "doctrine/doctrine-bundle": "^2.11", 58 | "infection/infection": "^0.27.11", 59 | "jms/serializer-bundle": "^4.2", 60 | "lexik/jwt-authentication-bundle": "^2.17", 61 | "matthiasnoback/symfony-config-test": "^4.3 || ^5.1", 62 | "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.1", 63 | "phpspec/prophecy-phpunit": "^2.3", 64 | "phpunit/phpunit": "^9.6.20", 65 | "psalm/plugin-phpunit": "^0.18.4", 66 | "psalm/plugin-symfony": "^5.2", 67 | "setono/code-quality-pack": "^2.8.1", 68 | "shipmonk/composer-dependency-analyser": "^1.6", 69 | "sylius/sylius": "~1.12.19", 70 | "symfony/debug-bundle": "^5.4 || ^6.4 || ^7.0", 71 | "symfony/dotenv": "^5.4 || ^6.4 || ^7.0", 72 | "symfony/intl": "^5.4 || ^6.4 || ^7.0", 73 | "symfony/property-info": "^5.4 || ^6.4 || ^7.0", 74 | "symfony/serializer": "^5.4 || ^6.4 || ^7.0", 75 | "symfony/web-profiler-bundle": "^5.4 || ^6.4 || ^7.0", 76 | "symfony/webpack-encore-bundle": "^1.17.2", 77 | "willdurand/negotiation": "^3.1" 78 | }, 79 | "prefer-stable": true, 80 | "autoload": { 81 | "psr-4": { 82 | "Setono\\SyliusCatalogPromotionPlugin\\": "src/" 83 | } 84 | }, 85 | "autoload-dev": { 86 | "psr-4": { 87 | "Setono\\SyliusCatalogPromotionPlugin\\Tests\\": "tests/" 88 | }, 89 | "classmap": [ 90 | "tests/Application/Kernel.php" 91 | ] 92 | }, 93 | "config": { 94 | "allow-plugins": { 95 | "dealerdirect/phpcodesniffer-composer-installer": false, 96 | "ergebnis/composer-normalize": true, 97 | "infection/extension-installer": true, 98 | "symfony/thanks": false 99 | }, 100 | "sort-packages": true 101 | }, 102 | "scripts": { 103 | "analyse": "psalm", 104 | "check-style": "ecs check", 105 | "fix-style": "ecs check --fix", 106 | "phpunit": "phpunit" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_exclusive_promotions.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_discounts 2 | Feature: Receiving discount from most prioritized exclusive discount 3 | As an Administrator 4 | I want exclusive discounts applied in prioritized order 5 | 6 | Background: 7 | Given the store operates on a single channel in "United States" 8 | And the store has "Mugs" taxonomy 9 | And the store has a product "1L Mug" priced at "$100.00" 10 | And this product belongs to "Mugs" 11 | 12 | @ui 13 | Scenario: Receiving exclusive product discount from discount with greater priority 14 | # This discount shouldn't be applied 15 | Given there is a discount "50% off for mugs" 16 | And it gives "50%" off on a "1L Mug" product 17 | # And this one - should 18 | And there is an exclusive discount "10% off for 1L mugs" with priority 1 19 | And it gives "10%" off on a "1L Mug" product 20 | And there is an exclusive discount "20% off for 1L mugs" with priority 2 21 | And it gives "20%" off on a "1L Mug" product 22 | When I reassign discounts 23 | Then its price should become "$80.00" 24 | 25 | @ui 26 | Scenario: Receiving exclusive taxonomy discount from discount with greater priority 27 | # This discount shouldn't be applied 28 | Given there is a discount "50% off for mugs" 29 | And it gives "50%" off on every product classified as "Mugs" 30 | # And this one - should 31 | And there is an exclusive discount "10% off for 1L mugs" with priority 1 32 | And it gives "10%" off on every product classified as "Mugs" 33 | And there is an exclusive discount "20% off for 1L mugs" with priority 2 34 | And it gives "30%" off on every product classified as "Mugs" 35 | When I reassign discounts 36 | Then its price should become "$70.00" 37 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_multiple_promotions_to_one_product.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Applying multiple catalog promotions to one product 3 | As an Administrator 4 | I product's price decrease based on all catalog promotions matching it 5 | 6 | Background: 7 | Given the store operates on a single channel in "United States" 8 | And the store classifies its products as "T-Shirts" and "Mugs" 9 | And the store has a product "PHP T-Shirt" priced at "$100.00" 10 | And it belongs to "T-Shirts" 11 | And the store has a product "PHP Mug" priced at "$20.00" 12 | And it belongs to "Mugs" 13 | And there is a catalog promotion "-50% for ALL" 14 | And it gives "50%" off on every product classified as "T-Shirts" or "Mugs" 15 | And there is a catalog promotion "T-Shirts promo" 16 | And it gives "20%" off on every product classified as "T-Shirts" 17 | 18 | @ui 19 | Scenario: Receiving discount 20 | When I reassign catalog promotions 21 | Then price of product "PHP T-Shirt" should become "$40.00" 22 | And price of product "PHP Mug" should become "$10.00" 23 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_promotions_applicable_for_channel_on_multichannel_store.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Applying promotions only to configured channel prices 3 | As an Administrator 4 | I want discount applied only to specified channel prices 5 | 6 | Background: 7 | Given the store also operates on a channel named "United States" 8 | And the store also operates on another channel named "Canada" 9 | And the store has a product "PHP T-Shirt" priced at "$100.00" in "United States" channel 10 | And this product is also priced at "$110" in "Canada" channel 11 | And there is a catalog promotion "Holiday promo" applicable for "United States" channel 12 | And it gives "10%" off on a "PHP T-Shirt" product 13 | 14 | @ui 15 | Scenario: Receiving discount only on "United States" channel price 16 | When I reassign catalog promotions 17 | Then price of product "PHP T-Shirt" on channel "United States" should become "$90.00" 18 | And price of product "PHP T-Shirt" on channel "Canada" still should be "$110.00" 19 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_promotions_applicable_for_channel_on_single_channel_store.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Applying only catalog promotions enabled for given channel 3 | As an Administrator 4 | I want to have only available catalog promotions applied to products prices 5 | 6 | Background: 7 | Given the store operates on a single channel in the "United States" named "Web" 8 | And the store has a product "PHP T-Shirt" priced at "$100.00" 9 | And there is a catalog promotion "Holiday promo" 10 | And it gives "10%" off on that product 11 | 12 | @ui 13 | Scenario: Receiving discount when catalog promotion enabled for current channel 14 | When I reassign catalog promotions 15 | And its price should become "$90.00" 16 | And its original price should become "$100.00" 17 | 18 | @ui 19 | Scenario: Not receiving discount when catalog promotion is disabled for current channel 20 | Given the catalog promotion was disabled for the channel "Web" 21 | When I reassign catalog promotions 22 | Then its price still should be "$100.00" 23 | And its original price should become "$100.00" 24 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_promotions_on_products_from_specific_taxon.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Receiving percentage discount on products from specific taxon 3 | As an Administrator 4 | I want assign discounts to products from specific taxons 5 | 6 | Background: 7 | Given the store operates on a single channel in "United States" 8 | And the store classifies its products as "T-Shirts" and "Mugs" 9 | And the store has a product "PHP T-Shirt" priced at "$100.00" 10 | And it belongs to "T-Shirts" 11 | And the store has a product "PHP Mug" priced at "$20.00" 12 | And it belongs to "Mugs" 13 | And there is a catalog promotion "T-Shirts promo" 14 | And it gives "20%" off on every product classified as "T-Shirts" 15 | 16 | @ui 17 | Scenario: Receiving percentage discount only on items from specific taxon 18 | When I reassign catalog promotions 19 | Then price of product "PHP T-Shirt" should become "$80.00" 20 | And price of product "PHP Mug" still should be "$20.00" 21 | 22 | @ui 23 | Scenario: Receiving different discounts on products from different taxons 24 | Given there is a catalog promotion "Mugs promo" 25 | And it gives "50%" off on every product classified as "Mugs" 26 | When I reassign catalog promotions 27 | Then price of product "PHP T-Shirt" should become "$80.00" 28 | And price of product "PHP Mug" still should be "$10.00" 29 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_promotions_on_specific_products.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Receiving percentage discount on specific products 3 | As an Administrator 4 | I want assign discounts to specific products 5 | 6 | Background: 7 | Given the store operates on a single channel in "United States" 8 | And the store classifies its products as "T-Shirts" and "Mugs" 9 | And the store has a product "PHP T-Shirt" priced at "$100.00" 10 | And the store has a product "PHP Mug" priced at "$20.00" 11 | And there is a catalog promotion "T-Shirts promo" 12 | And it gives "20%" off on a "PHP T-Shirt" product 13 | 14 | @ui 15 | Scenario: Receiving percentage discount only on items from specific taxon 16 | When I reassign catalog promotions 17 | Then price of product "PHP T-Shirt" should become "$80.00" 18 | And price of product "PHP Mug" still should be "$20.00" 19 | 20 | @ui 21 | Scenario: Receiving different discounts on products from different taxons 22 | Given there is a catalog promotion "Mugs promo" 23 | And it gives "50%" off on a "PHP Mug" product 24 | When I reassign catalog promotions 25 | Then price of product "PHP T-Shirt" should become "$80.00" 26 | And price of product "PHP Mug" still should be "$10.00" 27 | -------------------------------------------------------------------------------- /features/applying_promotions/applying_promotions_with_expiration_date.feature: -------------------------------------------------------------------------------- 1 | @setono_sylius_catalog_promotion_applying_promotions 2 | Feature: Applying catalog promotion with an expiration date 3 | As a Visitor 4 | I want to have catalog promotion's discounts applied to products only if catalog promotion is valid 5 | 6 | Background: 7 | Given the store operates on a single channel in "United States" 8 | And the store has a product "PHP T-Shirt" priced at "$100.00" 9 | And there is a catalog promotion "Christmas sale" 10 | And it gives "10%" off on that product 11 | 12 | @ui 13 | Scenario: Receiving a discount from a catalog promotion which does not expire 14 | Given this catalog promotion expires tomorrow 15 | When I reassign catalog promotions 16 | And its price should become "$90.00" 17 | 18 | @ui 19 | Scenario: Receiving no discount from a valid but expired catalog promotion 20 | Given this catalog promotion has already expired 21 | When I reassign catalog promotions 22 | Then its price still should be "$100.00" 23 | 24 | @ui 25 | Scenario: Receiving a discount from a catalog promotion which has already started 26 | Given this catalog promotion has started yesterday 27 | When I reassign catalog promotions 28 | And its price should become "$90.00" 29 | 30 | @ui 31 | Scenario: Receiving no discount from a catalog promotion that has not been started yet 32 | Given this catalog promotion starts tomorrow 33 | When I reassign catalog promotions 34 | Then its price still should be "$100.00" 35 | -------------------------------------------------------------------------------- /features/cli/process_catalog_promotions.feature: -------------------------------------------------------------------------------- 1 | @processing_catalog_promotions @cli 2 | Feature: Process catalog promotions via cli 3 | In order to calculate prices on all products 4 | As a Developer 5 | I want to process all catalog promotions 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | 10 | Scenario: Process catalog promotions 11 | Given the store classifies its products as "T-Shirts" and "Other" 12 | And there is a disabled catalog promotion for all products with a 50% discount 13 | And there is a catalog promotion for all products with a 20% discount 14 | And there is a catalog promotion for taxon "T-Shirts" with a 10% discount 15 | And the store has a product "PHP T-Shirt" priced at "$100.00" 16 | And it belongs to "T-Shirts" 17 | And the store has a product "Javascript T-Shirt" priced at "$10.00" 18 | And it belongs to "T-Shirts" 19 | And the store has a product "Desert Eagle" priced at "$2000.00" 20 | And it belongs to "Other" 21 | When I run the process command 22 | Then the command should finish successfully 23 | And the price of product "Desert Eagle" should be "$1600.00" and the original price should be "$2000.00" 24 | And the price of product "PHP T-Shirt" should be "$72.00" and the original price should be "$100.00" 25 | And the price of product "Javascript T-Shirt" should be "$7.20" and the original price should be "$10.00" 26 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/adding_catalog_promotion.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Adding a new catalog promotion 3 | In order to sell more by creating discount incentives for customers 4 | As an Administrator 5 | I want to add a new catalog promotion 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And I am logged in as an administrator 10 | 11 | @ui 12 | Scenario: Adding a new catalog promotion 13 | Given I want to create a new catalog promotion 14 | When I specify its code as "20_OFF" 15 | And I name it "20% off" 16 | And I specify 20% action percent 17 | And I add it 18 | Then I should be notified that it has been successfully created 19 | And the "20% off" catalog promotion should appear in the registry 20 | 21 | @ui 22 | Scenario: Adding a new exclusive discount 23 | Given I want to create a new catalog promotion 24 | When I specify its code as "20_OFF" 25 | And I name it "20% off" 26 | And I make it exclusive 27 | And I specify 20% action percent 28 | And I add it 29 | Then I should be notified that it has been successfully created 30 | And the "20% off" catalog promotion should be exclusive 31 | 32 | @ui 33 | Scenario: Adding a new channels discount 34 | Given I want to create a new catalog promotion 35 | When I specify its code as "20_OFF" 36 | And I name it "20% off" 37 | And I make it applicable for the "United States" channel 38 | And I specify 20% action percent 39 | And I add it 40 | Then I should be notified that it has been successfully created 41 | And the "20% off" catalog promotion should be applicable for the "United States" channel 42 | 43 | @ui 44 | Scenario: Adding a discount with start and end date 45 | Given I want to create a new catalog promotion 46 | When I specify its code as "20_OFF" 47 | And I name it "20% off" 48 | And I make it available from "21.04.2022" to "21.05.2022" 49 | And I specify 20% action percent 50 | And I add it 51 | Then I should be notified that it has been successfully created 52 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/adding_catalog_promotion_with_action.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Adding a new catalog promotion with action 3 | In order to give possibility to pay specifically less price for some goods 4 | As an Administrator 5 | I want to add a new catalog promotion with action to the registry 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And I am logged in as an administrator 10 | 11 | @ui 12 | Scenario: Adding a new catalog promotion with fixed discount 13 | When I want to create a new catalog promotion 14 | And I specify its code as "10_off_for_all_mugs" 15 | And I name it "10% off for all mugs!" 16 | And I specify 10% action percent 17 | And I add it 18 | Then I should be notified that it has been successfully created 19 | And the "10% off for all mugs!" catalog promotion should appear in the registry 20 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/adding_catalog_promotion_with_rule.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Adding a new discount with rule 3 | In order to give possibility to pay less for some goods based on specific configuration 4 | As an Administrator 5 | I want to add a new discount with rule to the registry 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And the store classifies its products as "T-Shirts" and "Mugs" 10 | And I am logged in as an administrator 11 | 12 | @ui @javascript 13 | Scenario: Adding a new catalog promotion with taxon rule 14 | Given I want to create a new catalog promotion 15 | When I specify its code as "HOLIDAY_SALE" 16 | And I name it "Holiday sale" 17 | And I specify 10% action percent 18 | And I add the "Product having one of taxons" rule configured with "T-Shirts" or "Mugs" 19 | And I add it 20 | Then I should be notified that it has been successfully created 21 | And the "Holiday sale" catalog promotion should appear in the registry 22 | 23 | @ui @javascript 24 | Scenario: Adding a new discount with contains products rule 25 | Given the store has a product "PHP T-Shirt" priced at "$100.00" 26 | And the store has a product "PHP Mug" priced at "$100.00" 27 | And I want to create a new catalog promotion 28 | When I specify its code as "PHP_PROMO" 29 | And I name it "PHP Promo" 30 | And I specify 10% action percent 31 | And I add the "Product is one of" rule configured with the "PHP T-Shirt" or "PHP Mug" product 32 | And I add it 33 | Then I should be notified that it has been successfully created 34 | And the "PHP Promo" catalog promotion should appear in the registry 35 | 36 | @ui @javascript 37 | Scenario: Adding a new discount with contains product rule 38 | Given the store has a product "PHP T-Shirt" priced at "$100.00" 39 | And I want to create a new catalog promotion 40 | When I specify its code as "PHP_TSHIRT_PROMO" 41 | And I name it "PHP T-Shirt discount" 42 | And I specify 10% action percent 43 | And I add the "Product is" rule configured with the "PHP T-Shirt" product 44 | And I add it 45 | Then I should be notified that it has been successfully created 46 | And the "PHP T-Shirt discount" catalog promotion should appear in the registry 47 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/browsing_catalog_promotions.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Browsing catalog promotions 3 | In order to see all catalog promotions 4 | As an Administrator 5 | I want to browse existing catalog promotions 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Basic promotion" 10 | And I am logged in as an administrator 11 | 12 | @ui 13 | Scenario: Browsing catalog promotions 14 | Given I want to browse catalog promotions 15 | Then I should see a single catalog promotion in the list 16 | And the "Basic promotion" catalog promotion should exist in the registry 17 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/catalog_promotion_unique_code_validation.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Catalog promotion unique code validation 3 | In order to uniquely identify catalog promotions 4 | As an Administrator 5 | I want to be prevented from adding two catalog promotions with the same code 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Catalog promotion" identified by "CATALOG_PROMOTION" code 10 | And I am logged in as an administrator 11 | 12 | @ui 13 | Scenario: Trying to add catalog promotion with taken code 14 | Given I want to create a new catalog promotion 15 | When I specify its code as "CATALOG_PROMOTION" 16 | And I name it "New catalog promotion" 17 | And I try to add it 18 | Then I should be notified that catalog promotion with this code already exists 19 | And there should still be only one catalog promotion with code "CATALOG_PROMOTION" 20 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/catalog_promotion_validation.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Catalog promotion validation 3 | In order to avoid making mistakes when managing a catalog promotion 4 | As an Administrator 5 | I want to be prevented from adding it without specifying required fields 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And I am logged in as an administrator 10 | 11 | @ui 12 | Scenario: Trying to add a new catalog promotion without specifying its code 13 | Given I want to create a new catalog promotion 14 | When I name it "No-VAT discount" 15 | And I do not specify its code 16 | And I try to add it 17 | Then I should be notified that code is required 18 | And catalog promotion with name "No-VAT discount" should not be added 19 | 20 | @ui 21 | Scenario: Trying to add a new discount without specifying its name 22 | Given I want to create a new catalog promotion 23 | When I specify its code as "no_vat_discount" 24 | But I do not name it 25 | And I try to add it 26 | Then I should be notified that name is required 27 | And catalog promotion with code "no_vat_discount" should not be added 28 | 29 | @ui 30 | Scenario: Adding a discount with start date set up after end date 31 | Given I want to create a new catalog promotion 32 | When I specify its code as "FULL_METAL_PROMO" 33 | And I name it "Full metal discount" 34 | And I make it available from "24.12.2017" to "12.12.2017" 35 | And I try to add it 36 | Then I should be notified that catalog promotion cannot end before it start 37 | 38 | @ui 39 | Scenario: Trying to remove name from existing discount 40 | Given there is a catalog promotion "Christmas sale" 41 | And I want to modify this catalog promotion 42 | When I remove its name 43 | And I try to save my changes 44 | Then I should be notified that name is required 45 | And this catalog promotion should still be named "Christmas sale" 46 | 47 | @ui 48 | Scenario: Trying to add start later then end date for existing discount 49 | Given there is a catalog promotion "Christmas sale" 50 | And I want to modify this catalog promotion 51 | And I make it available from "24.12.2017" to "12.12.2017" 52 | And I try to save my changes 53 | Then I should be notified that catalog promotion cannot end before it start 54 | 55 | @ui 56 | Scenario: Trying to add a new discount with a wrong percentage discount 57 | Given I want to create a new catalog promotion 58 | When I specify its code as "christmas_sale" 59 | And I name it "Christmas sale" 60 | And I specify 120% action percent 61 | And I try to add it 62 | Then I should be notified that catalog promotion discount range is 0% to 100% 63 | And catalog promotion with name "Christmas sale" should not be added 64 | 65 | @ui 66 | Scenario: Trying to add a new discount with a negative percentage discount 67 | Given I want to create a new catalog promotion 68 | When I specify its code as "christmas_sale" 69 | And I name it "Christmas sale" 70 | And I specify -20% action percent 71 | And I try to add it 72 | Then I should be notified that catalog promotion discount range is 0% to 100% 73 | And catalog promotion with name "Christmas sale" should not be added 74 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/deleting_catalog_promotion.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Deleting a catalog promotion 3 | In order to remove test, obsolete or incorrect catalog promotions 4 | As an Administrator 5 | I want to be able to delete a catalog promotion from the registry 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Christmas sale" 10 | And I am logged in as an administrator 11 | 12 | @ui 13 | Scenario: Deleted catalog promotion should disappear from the registry 14 | When I delete a "Christmas sale" catalog promotion 15 | Then I should be notified that it has been successfully deleted 16 | And this catalog promotion should no longer exist in the catalog promotion registry 17 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/deleting_multiple_catalog_promotions.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Deleting multiple catalog promotions 3 | In order to remove test, obsolete or incorrect catalog promotions in an efficient way 4 | As an Administrator 5 | I want to be able to delete multiple catalog promotions at once from the registry 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Christmas sale" 10 | And there is also a catalog promotion "New Year sale" 11 | And there is also a catalog promotion "Easter sale" 12 | And I am logged in as an administrator 13 | 14 | # TODO Fix javascript builds in github actions before activating this feature 15 | # @ui @javascript 16 | # Scenario: Deleting multiple discounts at once 17 | # When I browse discounts 18 | # And I check the "Christmas sale" discount 19 | # And I check also the "New Year sale" discount 20 | # And I delete them 21 | # Then I should be notified that they have been successfully deleted 22 | # And I should see a single discount in the list 23 | # And I should see the discount "Easter sale" in the list 24 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/editing_catalog_promotion.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Editing catalog promotion 3 | In order to change discount details 4 | As an Administrator 5 | I want to be able to edit a discount 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Christmas sale" with priority 0 10 | And there is a catalog promotion "Holiday sale" with priority 1 11 | And I am logged in as an administrator 12 | 13 | @ui 14 | Scenario: Seeing disabled code field when editing discount 15 | When I want to modify a "Christmas sale" catalog promotion 16 | Then the code field should be disabled 17 | 18 | @ui 19 | Scenario: Editing discount exclusiveness 20 | Given I want to modify a "Christmas sale" catalog promotion 21 | When I make it exclusive 22 | And I save my changes 23 | Then I should be notified that it has been successfully edited 24 | And the "Christmas sale" catalog promotion should be exclusive 25 | 26 | @ui 27 | Scenario: Editing discounts channels 28 | Given I want to modify a "Christmas sale" catalog promotion 29 | When I make it applicable for the "United States" channel 30 | And I save my changes 31 | Then I should be notified that it has been successfully edited 32 | And the "Christmas sale" catalog promotion should be applicable for the "United States" channel 33 | 34 | @ui 35 | Scenario: Editing a discount with start and end date 36 | Given I want to modify a "Christmas sale" catalog promotion 37 | When I make it available from "12.12.2017" to "24.12.2017" 38 | And I save my changes 39 | Then I should be notified that it has been successfully edited 40 | And the "Christmas sale" catalog promotion should be available from "12.12.2017" to "24.12.2017" 41 | 42 | @ui 43 | Scenario: Editing discount after adding a new channel 44 | Given this catalog promotion gives "10%" discount 45 | When the store also operates on another channel named "EU-WEB" 46 | Then I should be able to modify a "Christmas sale" catalog promotion 47 | 48 | @ui 49 | Scenario: Remove priority from existing discount 50 | Given I want to modify a "Christmas sale" catalog promotion 51 | When I remove its priority 52 | And I save my changes 53 | Then I should be notified that it has been successfully edited 54 | And the "Christmas sale" catalog promotion should have priority 1 55 | -------------------------------------------------------------------------------- /features/managing_catalog_promotions/sorting_discounts_by_priority.feature: -------------------------------------------------------------------------------- 1 | @managing_catalog_promotions 2 | Feature: Sorting listed catalog promotions by priority 3 | In order to change the order by which catalog promotions are used 4 | As an Administrator 5 | I want to sort catalog promotions by their priority 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | And there is a catalog promotion "Honour Harambe" with priority 2 10 | And there is a catalog promotion "Gimme An Owl" with priority 1 11 | And there is a catalog promotion "Pugs For Everyone" with priority 0 12 | And I am logged in as an administrator 13 | 14 | @ui 15 | Scenario: discounts are sorted by priority in descending order by default 16 | When I want to browse catalog promotions 17 | Then I should see 3 catalog promotions on the list 18 | And the first catalog promotion on the list should have name "Honour Harambe" 19 | And the last catalog promotion on the list should have name "Pugs For Everyone" 20 | 21 | @ui 22 | Scenario: discount's default priority is 0 which puts it at the bottom of the list 23 | Given there is a catalog promotion "Flying Pigs" 24 | When I want to browse catalog promotions 25 | Then I should see 4 catalog promotions on the list 26 | And the last catalog promotion on the list should have name "Flying Pigs" 27 | 28 | @ui 29 | Scenario: discount added with priority -1 is set at the top of the list 30 | Given there is a catalog promotion "Flying Pigs" with priority -1 31 | When I want to browse catalog promotions 32 | Then I should see 4 catalog promotions on the list 33 | And the first catalog promotion on the list should have name "Flying Pigs" 34 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php://stderr", 9 | "github": true, 10 | "stryker": { 11 | "badge": "1.x" 12 | } 13 | }, 14 | "minMsi": 13.73, 15 | "minCoveredMsi": 100.00 16 | } 17 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | cacheClass(FileCacheStorage::class); 11 | $rectorConfig->cacheDirectory('./.build/rector'); 12 | 13 | $rectorConfig->paths([ 14 | __DIR__ . '/src', 15 | __DIR__ . '/tests', 16 | ]); 17 | 18 | $rectorConfig->skip([ 19 | __DIR__ . '/tests/Application', 20 | ]); 21 | 22 | $rectorConfig->sets([ 23 | LevelSetList::UP_TO_PHP_81 24 | ]); 25 | }; 26 | -------------------------------------------------------------------------------- /src/Applicator/RuntimePromotionsApplicator.php: -------------------------------------------------------------------------------- 1 | */ 21 | private array $catalogPromotionCache = []; 22 | 23 | public function __construct( 24 | ManagerRegistry $managerRegistry, 25 | private readonly RuntimeCheckerInterface $runtimeChecker, 26 | // todo make this optional to speed up the application process 27 | private readonly EventDispatcherInterface $eventDispatcher, 28 | /** @var class-string $catalogPromotionClass */ 29 | private readonly string $catalogPromotionClass, 30 | ) { 31 | $this->managerRegistry = $managerRegistry; 32 | } 33 | 34 | public function apply(ProductInterface $product, int $price, int $originalPrice = null): int 35 | { 36 | $originalPrice = $originalPrice ?? $price; 37 | $manuallyDiscounted = $price < $originalPrice; 38 | 39 | $catalogPromotions = $product->getPreQualifiedCatalogPromotions(); 40 | 41 | if ([] === $catalogPromotions) { 42 | return $price; 43 | } 44 | 45 | $catalogPromotions = $this->provideEligibleCatalogPromotions($catalogPromotions, $manuallyDiscounted); 46 | foreach ($catalogPromotions as $catalogPromotion) { 47 | if (!$catalogPromotion->isManuallyDiscountedProductsExcluded() && $catalogPromotion->isUsingOriginalPriceAsBase()) { 48 | $price = $originalPrice; 49 | 50 | break; 51 | } 52 | } 53 | 54 | $multiplier = 1.0; 55 | 56 | $appliedCatalogPromotions = []; 57 | foreach ($catalogPromotions as $catalogPromotion) { 58 | $multiplier *= 1 - $catalogPromotion->getDiscount(); 59 | 60 | $appliedCatalogPromotions[] = $catalogPromotion; 61 | } 62 | 63 | if ([] !== $appliedCatalogPromotions) { 64 | $this->eventDispatcher->dispatch(new CatalogPromotionsAppliedEvent($product, $appliedCatalogPromotions)); 65 | } 66 | 67 | return (int) floor($price * $multiplier); 68 | } 69 | 70 | /** 71 | * @param list $catalogPromotions 72 | * 73 | * @return list 74 | */ 75 | private function provideEligibleCatalogPromotions(array $catalogPromotions, bool $manuallyDiscounted): array 76 | { 77 | $eligiblePromotions = []; 78 | $eligibleExclusivePromotions = []; 79 | 80 | foreach ($catalogPromotions as $catalogPromotion) { 81 | $catalogPromotion = $this->cacheCatalogPromotion($catalogPromotion); 82 | if (null === $catalogPromotion) { 83 | continue; 84 | } 85 | 86 | if ($manuallyDiscounted && $catalogPromotion->isManuallyDiscountedProductsExcluded()) { 87 | continue; 88 | } 89 | 90 | if (!$this->runtimeChecker->isEligible($catalogPromotion)) { 91 | continue; 92 | } 93 | 94 | $eligiblePromotions[] = $catalogPromotion; 95 | 96 | if ($catalogPromotion->isExclusive()) { 97 | $eligibleExclusivePromotions[$catalogPromotion->getPriority()] = $catalogPromotion; 98 | } 99 | } 100 | 101 | if ([] !== $eligibleExclusivePromotions) { 102 | krsort($eligibleExclusivePromotions, \SORT_NUMERIC); 103 | 104 | return [reset($eligibleExclusivePromotions)]; 105 | } 106 | 107 | return $eligiblePromotions; 108 | } 109 | 110 | private function cacheCatalogPromotion(string $catalogPromotion): ?CatalogPromotionInterface 111 | { 112 | if (!array_key_exists($catalogPromotion, $this->catalogPromotionCache) || (null !== $this->catalogPromotionCache[$catalogPromotion] && !$this->getManager($this->catalogPromotionClass)->contains($this->catalogPromotionCache[$catalogPromotion]))) { 113 | $this->catalogPromotionCache[$catalogPromotion] = $this->getRepository($this->catalogPromotionClass, CatalogPromotionRepositoryInterface::class)->findOneByCode($catalogPromotion); 114 | } 115 | 116 | return $this->catalogPromotionCache[$catalogPromotion]; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Applicator/RuntimePromotionsApplicatorInterface.php: -------------------------------------------------------------------------------- 1 | */ 19 | private array $persistedPricesCache = []; 20 | 21 | /** @var array */ 22 | private array $computedPriceCache = []; 23 | 24 | public function __construct(private readonly RuntimePromotionsApplicatorInterface $runtimePromotionsApplicator) 25 | { 26 | } 27 | 28 | public function calculate(ProductVariantInterface $productVariant, array $context): int 29 | { 30 | $cacheKey = self::generateCacheKey($productVariant, $context); 31 | if (!array_key_exists($cacheKey, $this->computedPriceCache)) { 32 | $this->computedPriceCache[$cacheKey] = $this->getPrice($productVariant, $context); 33 | } 34 | 35 | return $this->computedPriceCache[$cacheKey]; 36 | } 37 | 38 | public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int 39 | { 40 | return $this->getPersistedPrices($productVariant, $context)['originalPrice']; 41 | } 42 | 43 | private function getPrice(ProductVariantInterface $productVariant, array $context): int 44 | { 45 | $prices = $this->getPersistedPrices($productVariant, $context); 46 | 47 | $product = $productVariant->getProduct(); 48 | if (!$product instanceof ProductInterface || !$product->hasPreQualifiedCatalogPromotions()) { 49 | return $prices['price']; 50 | } 51 | 52 | return max($prices['minimumPrice'], $this->runtimePromotionsApplicator->apply( 53 | $product, 54 | $prices['price'], 55 | $prices['originalPrice'], 56 | )); 57 | } 58 | 59 | /** 60 | * @return array{price: int, originalPrice: int, minimumPrice: int} 61 | */ 62 | private function getPersistedPrices(ProductVariantInterface $productVariant, array $context): array 63 | { 64 | $cacheKey = self::generateCacheKey($productVariant, $context); 65 | 66 | if (!isset($this->persistedPricesCache[$cacheKey])) { 67 | // todo remove these assertions when this issue is fixed: https://github.com/vimeo/psalm/issues/11248 68 | Assert::keyExists($context, 'channel'); 69 | Assert::isInstanceOf($context['channel'], ChannelInterface::class); 70 | 71 | $channelPricing = $productVariant->getChannelPricingForChannel($context['channel']); 72 | 73 | if (null === $channelPricing) { 74 | throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']); 75 | } 76 | 77 | $price = $channelPricing->getPrice(); 78 | if (null === $price) { 79 | throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']); 80 | } 81 | 82 | $this->persistedPricesCache[$cacheKey] = [ 83 | 'price' => $price, 84 | 'originalPrice' => $channelPricing->getOriginalPrice() ?? $price, 85 | 'minimumPrice' => self::getMinimumPrice($channelPricing), 86 | ]; 87 | } 88 | 89 | return $this->persistedPricesCache[$cacheKey]; 90 | } 91 | 92 | private static function getMinimumPrice(ChannelPricingInterface $channelPricing): int 93 | { 94 | $minimumPrice = 0; 95 | 96 | if (method_exists($channelPricing, 'getMinimumPrice')) { 97 | /** @var mixed $minimumPrice */ 98 | $minimumPrice = $channelPricing->getMinimumPrice(); 99 | } 100 | 101 | /** @psalm-suppress RedundantCondition */ 102 | Assert::integer($minimumPrice); 103 | 104 | return $minimumPrice; 105 | } 106 | 107 | /** 108 | * @psalm-assert ChannelInterface $context['channel'] 109 | */ 110 | private static function generateCacheKey(ProductVariantInterface $productVariant, array $context): string 111 | { 112 | Assert::keyExists($context, 'channel'); 113 | Assert::isInstanceOf($context['channel'], ChannelInterface::class); 114 | 115 | return sprintf('%s%s', (string) $context['channel']->getCode(), (string) $productVariant->getCode()); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Checker/OnSale/OnSaleChecker.php: -------------------------------------------------------------------------------- 1 | channelContext->getChannel(); 25 | Assert::isInstanceOf($channel, ChannelInterface::class); 26 | 27 | return match (true) { 28 | $product instanceof ProductInterface => $this->checkProduct($product, $channel), 29 | $product instanceof ProductVariantInterface => $this->checkVariant($product, $channel), 30 | }; 31 | } 32 | 33 | private function checkProduct(ProductInterface $product, ChannelInterface $channel): bool 34 | { 35 | /** @var ProductVariantInterface $variant */ 36 | foreach ($product->getEnabledVariants() as $variant) { 37 | if ($this->checkVariant($variant, $channel)) { 38 | return true; 39 | } 40 | } 41 | 42 | return false; 43 | } 44 | 45 | private function checkVariant(ProductVariantInterface $variant, ChannelInterface $channel): bool 46 | { 47 | $channelPricing = $variant->getChannelPricingForChannel($channel); 48 | if (null === $channelPricing) { 49 | return false; 50 | } 51 | 52 | if ($channelPricing->isPriceReduced()) { 53 | return true; 54 | } 55 | 56 | $product = $variant->getProduct(); 57 | Assert::isInstanceOf($product, ProductInterface::class); 58 | 59 | $price = (int) $channelPricing->getPrice(); 60 | 61 | $appliedPrice = $this->runtimePromotionsApplicator->apply($product, $price, $channelPricing->getOriginalPrice()); 62 | 63 | $comparePrice = $channelPricing->getOriginalPrice() ?? $price; 64 | 65 | return $appliedPrice < $comparePrice; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Checker/OnSale/OnSaleCheckerInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class CompositePreQualificationChecker extends CompositeService implements PreQualificationCheckerInterface 15 | { 16 | public function isPreQualified(ProductInterface $product, CatalogPromotionInterface $catalogPromotion): bool 17 | { 18 | foreach ($this->services as $service) { 19 | if (!$service->isPreQualified($product, $catalogPromotion)) { 20 | return false; 21 | } 22 | } 23 | 24 | return true; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Checker/PreQualification/PreQualificationCheckerInterface.php: -------------------------------------------------------------------------------- 1 | getCode(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Checker/PreQualification/Rule/ContainsProductsRuleChecker.php: -------------------------------------------------------------------------------- 1 | getCode(), $configuration['products'], true); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Checker/PreQualification/Rule/HasNotTaxonRuleChecker.php: -------------------------------------------------------------------------------- 1 | getTaxons() as $taxon) { 21 | if (in_array($taxon->getCode(), $configuration['taxons'], true)) { 22 | return false; 23 | } 24 | } 25 | 26 | return !in_array($product->getMainTaxon()?->getCode(), $configuration['taxons'], true); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Checker/PreQualification/Rule/HasTaxonRuleChecker.php: -------------------------------------------------------------------------------- 1 | getTaxons() as $taxon) { 21 | if (in_array($taxon->getCode(), $configuration['taxons'], true)) { 22 | return true; 23 | } 24 | } 25 | 26 | return in_array($product->getMainTaxon()?->getCode(), $configuration['taxons'], true); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Checker/PreQualification/Rule/RuleCheckerInterface.php: -------------------------------------------------------------------------------- 1 | hasRules()) { 23 | return true; 24 | } 25 | 26 | foreach ($catalogPromotion->getRules() as $rule) { 27 | if (!$this->isEligibleToRule($product, $rule)) { 28 | return false; 29 | } 30 | } 31 | 32 | return true; 33 | } 34 | 35 | private function isEligibleToRule(ProductInterface $product, CatalogPromotionRuleInterface $rule): bool 36 | { 37 | /** @var RuleCheckerInterface|object $checker */ 38 | $checker = $this->ruleRegistry->get((string) $rule->getType()); 39 | Assert::isInstanceOf($checker, RuleCheckerInterface::class); 40 | 41 | return $checker->isEligible($product, $rule->getConfiguration()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Checker/Runtime/ChannelContextRuntimeChecker.php: -------------------------------------------------------------------------------- 1 | channelContext->getChannel(); 21 | 22 | $collection = $catalogPromotion->getChannels(); 23 | if ($collection instanceof Selectable) { 24 | return $collection->matching(Criteria::create()->andWhere(Criteria::expr()->eq('code', $channel->getCode())))->count() > 0; 25 | } 26 | 27 | return $collection->contains($channel); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Checker/Runtime/CompositeRuntimeChecker.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class CompositeRuntimeChecker extends CompositeService implements RuntimeCheckerInterface 14 | { 15 | public function isEligible(CatalogPromotionInterface $catalogPromotion): bool 16 | { 17 | foreach ($this->services as $service) { 18 | if (!$service->isEligible($catalogPromotion)) { 19 | return false; 20 | } 21 | } 22 | 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Checker/Runtime/DateRuntimeChecker.php: -------------------------------------------------------------------------------- 1 | clock?->now() ?? new \DateTimeImmutable(); 19 | 20 | $startsAt = $catalogPromotion->getStartsAt(); 21 | if (null !== $startsAt && $startsAt > $now) { 22 | return false; 23 | } 24 | 25 | $endsAt = $catalogPromotion->getEndsAt(); 26 | if (null !== $endsAt && $endsAt < $now) { 27 | return false; 28 | } 29 | 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Checker/Runtime/EnabledRuntimeChecker.php: -------------------------------------------------------------------------------- 1 | isEnabled(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Checker/Runtime/RuntimeCheckerInterface.php: -------------------------------------------------------------------------------- 1 | $catalogPromotionUpdateClass */ 26 | private readonly string $catalogPromotionUpdateClass, 27 | private readonly string $threshold = '-2 days', 28 | ) { 29 | parent::__construct(); 30 | 31 | $this->managerRegistry = $managerRegistry; 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int 35 | { 36 | $this 37 | ->getManager($this->catalogPromotionUpdateClass) 38 | ->createQueryBuilder() 39 | ->delete($this->catalogPromotionUpdateClass, 'o') 40 | ->andWhere('o.createdAt < :threshold') 41 | ->setParameter('threshold', new \DateTimeImmutable($this->threshold)) 42 | ->getQuery() 43 | ->execute() 44 | ; 45 | 46 | return 0; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Command/UpdateCommand.php: -------------------------------------------------------------------------------- 1 | commandBus->dispatch(new StartCatalogPromotionUpdate(triggeredBy: sprintf('Running command "%s"', (string) self::getDefaultName()))); 28 | 29 | return 0; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Context/StaticChannelContext.php: -------------------------------------------------------------------------------- 1 | channel) { 23 | throw new ChannelNotFoundException('Static channel is not set'); 24 | } 25 | 26 | return $this->channel; 27 | } 28 | 29 | public function setChannel(?ChannelInterface $channel): void 30 | { 31 | $this->channel = $channel; 32 | } 33 | 34 | /** 35 | * @throws ChannelNotFoundException if the channel with the given code doesn't exist 36 | */ 37 | public function setChannelCode(string $channelCode): void 38 | { 39 | $channel = $this->channelRepository->findOneByCode($channelCode); 40 | if (null === $channel) { 41 | throw new ChannelNotFoundException(sprintf('Channel with code "%s" does not exist', $channelCode)); 42 | } 43 | 44 | $this->channel = $channel; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Controller/UpdateAllAction.php: -------------------------------------------------------------------------------- 1 | commandBus->dispatch(new StartCatalogPromotionUpdate(triggeredBy: 'Clicking "Update all" button inside admin interface')); 25 | 26 | $session = $request->getSession(); 27 | if ($session instanceof Session) { 28 | $session->getFlashBag()->add('success', 'setono_sylius_catalog_promotion.update_all_success'); 29 | } 30 | 31 | return new RedirectResponse($this->getUrl($request)); 32 | } 33 | 34 | private function getUrl(Request $request): string 35 | { 36 | $referrer = $request->headers->get('referer'); 37 | if (null !== $referrer && '' !== $referrer) { 38 | return $referrer; 39 | } 40 | 41 | return $this->urlGenerator->generate('setono_sylius_catalog_promotion_admin_catalog_promotion_index'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DataProvider/ProductDataProvider.php: -------------------------------------------------------------------------------- 1 | $productClass */ 24 | private readonly string $productClass, 25 | ) { 26 | $this->managerRegistry = $managerRegistry; 27 | } 28 | 29 | public function getIds(array $ids = []): \Generator|array 30 | { 31 | $qb = $this->createQueryBuilder($ids)->select('DISTINCT product.id'); 32 | 33 | /** @var SelectBatchIteratorAggregate $iterator */ 34 | $iterator = SelectBatchIteratorAggregate::fromQuery($qb->getQuery(), 500); 35 | 36 | yield from $iterator; 37 | } 38 | 39 | public function getProducts(array $ids): array 40 | { 41 | /** @var list $products */ 42 | $products = $this->createQueryBuilder($ids)->getQuery()->getResult(); 43 | 44 | return $products; 45 | } 46 | 47 | private function createQueryBuilder(array $ids = []): QueryBuilder 48 | { 49 | $qb = $this 50 | ->getManager($this->productClass) 51 | ->createQueryBuilder() 52 | ->select('product') 53 | ->from($this->productClass, 'product') 54 | ; 55 | 56 | if ([] !== $ids) { 57 | $qb->andWhere('product.id IN (:ids)') 58 | ->setParameter('ids', $ids); 59 | } 60 | 61 | $this->eventDispatcher->dispatch(new DataProviderQueryBuilderCreatedEvent($qb)); 62 | 63 | return $qb; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/DataProvider/ProductDataProviderInterface.php: -------------------------------------------------------------------------------- 1 | $ids If provided, only return ids that are in this array 16 | * 17 | * @return iterable 18 | */ 19 | public function getIds(array $ids = []): iterable; 20 | 21 | /** 22 | * @return iterable 23 | */ 24 | public function getProducts(array $ids): iterable; 25 | } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/OverrideProductVariantPricesCalculatorPass.php: -------------------------------------------------------------------------------- 1 | has('sylius.calculator.product_variant_price')) { 20 | return; 21 | } 22 | 23 | $container->setAlias('sylius.calculator.product_variant_price', ProductVariantPricesCalculator::class); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/RegisterRulesAndRuleCheckersPass.php: -------------------------------------------------------------------------------- 1 | getDefinition('setono_sylius_catalog_promotion.registry.rule_checker'); 18 | $formRegistry = $container->getDefinition('setono_sylius_catalog_promotion.form_registry.rule'); 19 | 20 | /** @var array $formToLabelMap */ 21 | $formToLabelMap = []; 22 | 23 | /** 24 | * @var string $id 25 | * @var array $tags 26 | */ 27 | foreach ($container->findTaggedServiceIds('setono_sylius_catalog_promotion.rule_checker') as $id => $tags) { 28 | /** @var array $attributes */ 29 | foreach ($tags as $attributes) { 30 | if (!isset($attributes['type'], $attributes['label'], $attributes['form_type'])) { 31 | throw new InvalidArgumentException('Tagged rule checker `' . $id . '` needs to have `type`, `form_type` and `label` attributes.'); 32 | } 33 | 34 | Assert::stringNotEmpty($attributes['type']); 35 | Assert::stringNotEmpty($attributes['label']); 36 | Assert::stringNotEmpty($attributes['form_type']); 37 | 38 | $formToLabelMap[$attributes['type']] = $attributes['label']; 39 | $registry->addMethodCall('register', [$attributes['type'], new Reference($id)]); 40 | $formRegistry->addMethodCall('add', [$attributes['type'], 'default', $attributes['form_type']]); 41 | } 42 | } 43 | 44 | $container->setParameter('setono_sylius_catalog_promotion.catalog_promotion_rules', $formToLabelMap); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 27 | 28 | $this->addResourcesSection($rootNode); 29 | 30 | return $treeBuilder; 31 | } 32 | 33 | private function addResourcesSection(ArrayNodeDefinition $node): void 34 | { 35 | /** @psalm-suppress MixedMethodCall,PossiblyUndefinedMethod,UndefinedInterfaceMethod,PossiblyNullReference,UndefinedMethod */ 36 | $node 37 | ->children() 38 | ->arrayNode('resources') 39 | ->addDefaultsIfNotSet() 40 | ->children() 41 | ->arrayNode('catalog_promotion') 42 | ->addDefaultsIfNotSet() 43 | ->children() 44 | ->variableNode('options')->end() 45 | ->arrayNode('classes') 46 | ->addDefaultsIfNotSet() 47 | ->children() 48 | ->scalarNode('model')->defaultValue(CatalogPromotion::class)->cannotBeEmpty()->end() 49 | ->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end() 50 | ->scalarNode('repository')->defaultValue(CatalogPromotionRepository::class)->cannotBeEmpty()->end() 51 | ->scalarNode('factory')->defaultValue(Factory::class)->end() 52 | ->scalarNode('form')->defaultValue(CatalogPromotionType::class)->cannotBeEmpty()->end() 53 | ->end() 54 | ->end() 55 | ->end() 56 | ->end() 57 | ->arrayNode('catalog_promotion_rule') 58 | ->addDefaultsIfNotSet() 59 | ->children() 60 | ->variableNode('options')->end() 61 | ->arrayNode('classes') 62 | ->addDefaultsIfNotSet() 63 | ->children() 64 | ->scalarNode('model')->defaultValue(CatalogPromotionRule::class)->cannotBeEmpty()->end() 65 | ->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end() 66 | ->scalarNode('repository')->cannotBeEmpty()->end() 67 | ->scalarNode('factory')->defaultValue(Factory::class)->end() 68 | ->scalarNode('form')->defaultValue(CatalogPromotionRuleType::class)->cannotBeEmpty()->end() 69 | ->end() 70 | ->end() 71 | ->end() 72 | ->end() 73 | ->arrayNode('catalog_promotion_update') 74 | ->addDefaultsIfNotSet() 75 | ->children() 76 | ->variableNode('options')->end() 77 | ->arrayNode('classes') 78 | ->addDefaultsIfNotSet() 79 | ->children() 80 | ->scalarNode('model')->defaultValue(CatalogPromotionUpdate::class)->cannotBeEmpty()->end() 81 | ->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end() 82 | ->scalarNode('repository')->cannotBeEmpty()->end() 83 | ->scalarNode('factory')->defaultValue(Factory::class)->end() 84 | ; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Event/CatalogPromotionsAppliedEvent.php: -------------------------------------------------------------------------------- 1 | $catalogPromotions */ 18 | public readonly array $catalogPromotions, 19 | ) { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Event/DataProviderQueryBuilderCreatedEvent.php: -------------------------------------------------------------------------------- 1 | 'add', 17 | ]; 18 | } 19 | 20 | public function add(MenuBuilderEvent $event): void 21 | { 22 | $menu = $event->getMenu(); 23 | 24 | $marketingSubmenu = $menu->getChild('marketing'); 25 | if (!$marketingSubmenu instanceof ItemInterface) { 26 | return; 27 | } 28 | 29 | $marketingSubmenu 30 | // This will override the Sylius menu item with the same name 31 | ->addChild('catalog_promotions', [ 32 | 'route' => 'setono_sylius_catalog_promotion_admin_catalog_promotion_index', 33 | ]) 34 | ->setAttribute('type', 'link') 35 | ->setLabel('setono_sylius_catalog_promotion.menu.admin.main.marketing.catalog_promotions') 36 | ->setLabelAttributes([ 37 | 'icon' => 'tasks', 38 | ]) 39 | ; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/EventSubscriber/UpdateCatalogPromotionSubscriber.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private array $catalogPromotions = []; 28 | 29 | public function __construct(private readonly MessageBusInterface $commandBus) 30 | { 31 | } 32 | 33 | public static function getSubscribedEvents(): array 34 | { 35 | return [ 36 | 'setono_sylius_catalog_promotion.catalog_promotion.post_create' => 'update', 37 | 'setono_sylius_catalog_promotion.catalog_promotion.post_update' => 'update', 38 | KernelEvents::TERMINATE => 'dispatch', 39 | ConsoleEvents::TERMINATE => 'dispatch', 40 | ]; 41 | } 42 | 43 | public function update(ResourceControllerEvent $event): void 44 | { 45 | $this->addCatalogPromotion($event->getSubject()); 46 | } 47 | 48 | public function postPersist(LifecycleEventArgs $eventArgs): void 49 | { 50 | $this->addCatalogPromotion($eventArgs->getObject()); 51 | } 52 | 53 | public function postUpdate(LifecycleEventArgs $eventArgs): void 54 | { 55 | $this->addCatalogPromotion($eventArgs->getObject()); 56 | } 57 | 58 | public function dispatch(): void 59 | { 60 | if ([] === $this->catalogPromotions) { 61 | return; 62 | } 63 | 64 | $this->commandBus->dispatch(new StartCatalogPromotionUpdate( 65 | catalogPromotions: $this->catalogPromotions, 66 | triggeredBy: sprintf( 67 | 'The update/creation of the following catalog promotions: "%s"', 68 | implode('", "', array_map(static fn (CatalogPromotionInterface $catalogPromotion): string => (string) ($catalogPromotion->getName() ?? $catalogPromotion->getCode()), $this->catalogPromotions)), 69 | ), 70 | )); 71 | 72 | $this->catalogPromotions = []; 73 | } 74 | 75 | private function addCatalogPromotion(mixed $catalogPromotion): void 76 | { 77 | if (!$catalogPromotion instanceof CatalogPromotionInterface) { 78 | return; 79 | } 80 | 81 | $this->catalogPromotions[(string) $catalogPromotion->getCode()] = $catalogPromotion; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/EventSubscriber/UpdateProductSubscriber.php: -------------------------------------------------------------------------------- 1 | */ 20 | private array $preUpdateCandidates = []; 21 | 22 | /** 23 | * An array of products to update indexed by id 24 | * 25 | * @var array 26 | */ 27 | private array $products = []; 28 | 29 | public function __construct(private readonly MessageBusInterface $commandBus) 30 | { 31 | } 32 | 33 | public static function getSubscribedEvents(): array 34 | { 35 | return [ 36 | 'sylius.product.post_create' => 'updateProduct', 37 | 'sylius.product.post_update' => 'updateProduct', 38 | KernelEvents::TERMINATE => 'dispatch', 39 | ConsoleEvents::TERMINATE => 'dispatch', 40 | ]; 41 | } 42 | 43 | public function updateProduct(ResourceControllerEvent $event): void 44 | { 45 | $this->addProduct($event->getSubject()); 46 | } 47 | 48 | public function postPersist(LifecycleEventArgs $eventArgs): void 49 | { 50 | $this->addProduct($eventArgs->getObject()); 51 | } 52 | 53 | public function preUpdate(PreUpdateEventArgs $eventArgs): void 54 | { 55 | $obj = $eventArgs->getObject(); 56 | if (!$obj instanceof ProductInterface) { 57 | return; 58 | } 59 | 60 | $changeSet = $eventArgs->getEntityChangeSet(); 61 | 62 | // We don't want to start a catalog promotion update if the only change are these two fields because they are 63 | // most likely changed during an update of all or some catalog promotions in the first place 64 | if (count($changeSet) === 2 && isset($changeSet['updatedAt'], $changeSet['preQualifiedCatalogPromotions'])) { 65 | return; 66 | } 67 | 68 | $this->preUpdateCandidates[(int) $obj->getId()] = $obj; 69 | } 70 | 71 | public function postUpdate(LifecycleEventArgs $eventArgs): void 72 | { 73 | $obj = $eventArgs->getObject(); 74 | if (!$obj instanceof ProductInterface) { 75 | return; 76 | } 77 | 78 | if (!isset($this->preUpdateCandidates[(int) $obj->getId()])) { 79 | return; 80 | } 81 | 82 | $this->addProduct($obj); 83 | } 84 | 85 | public function dispatch(): void 86 | { 87 | if ([] === $this->products) { 88 | return; 89 | } 90 | 91 | $this->commandBus->dispatch(new StartCatalogPromotionUpdate( 92 | products: $this->products, 93 | triggeredBy: sprintf( 94 | 'The update/creation of the following products: "%s"', 95 | implode('", "', array_map(static fn (ProductInterface $product): string => (string) $product->getCode(), $this->products)), 96 | ), 97 | )); 98 | 99 | $this->products = []; 100 | } 101 | 102 | private function addProduct(mixed $product): void 103 | { 104 | if (!$product instanceof ProductInterface) { 105 | return; 106 | } 107 | 108 | $this->products[(int) $product->getId()] = $product; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Factory/CatalogPromotionRuleFactory.php: -------------------------------------------------------------------------------- 1 | decoratedFactory->createNew(); 25 | Assert::isInstanceOf($obj, CatalogPromotionRuleInterface::class); 26 | 27 | return $obj; 28 | } 29 | 30 | public function createByType(string $type, array $configuration, bool $strict = false): CatalogPromotionRuleInterface 31 | { 32 | switch ($type) { 33 | case HasTaxonRuleChecker::TYPE: 34 | Assert::keyExists($configuration, 'taxons'); 35 | Assert::isArray($configuration['taxons']); 36 | 37 | return $this->createHasTaxon($configuration['taxons']); 38 | case HasNotTaxonRuleChecker::TYPE: 39 | Assert::keyExists($configuration, 'taxons'); 40 | Assert::isArray($configuration['taxons']); 41 | 42 | return $this->createHasNotTaxon($configuration['taxons']); 43 | case ContainsProductRuleChecker::TYPE: 44 | Assert::keyExists($configuration, 'product'); 45 | Assert::string($configuration['product']); 46 | 47 | return $this->createContainsProduct($configuration['product']); 48 | case ContainsProductsRuleChecker::TYPE: 49 | Assert::keyExists($configuration, 'products'); 50 | Assert::isArray($configuration['products']); 51 | 52 | return $this->createContainsProducts($configuration['products']); 53 | } 54 | 55 | if ($strict) { 56 | throw new InvalidArgumentException(sprintf( 57 | 'Type must be one of: %s', 58 | implode(', ', array_keys($this->rules)), 59 | )); 60 | } 61 | 62 | return $this->createPromotionRule($type, $configuration); 63 | } 64 | 65 | public function createHasTaxon(array $taxonCodes): CatalogPromotionRuleInterface 66 | { 67 | Assert::allString($taxonCodes); 68 | 69 | return $this->createPromotionRule( 70 | HasTaxonRuleChecker::TYPE, 71 | ['taxons' => $taxonCodes], 72 | ); 73 | } 74 | 75 | public function createHasNotTaxon(array $taxonCodes): CatalogPromotionRuleInterface 76 | { 77 | Assert::allString($taxonCodes); 78 | 79 | return $this->createPromotionRule( 80 | HasNotTaxonRuleChecker::TYPE, 81 | ['taxons' => $taxonCodes], 82 | ); 83 | } 84 | 85 | public function createContainsProduct(string $productCode): CatalogPromotionRuleInterface 86 | { 87 | return $this->createPromotionRule( 88 | ContainsProductRuleChecker::TYPE, 89 | ['product' => $productCode], 90 | ); 91 | } 92 | 93 | public function createContainsProducts(array $productCodes): CatalogPromotionRuleInterface 94 | { 95 | Assert::allString($productCodes); 96 | 97 | return $this->createPromotionRule( 98 | ContainsProductsRuleChecker::TYPE, 99 | ['products' => $productCodes], 100 | ); 101 | } 102 | 103 | private function createPromotionRule(string $type, array $configuration): CatalogPromotionRuleInterface 104 | { 105 | $rule = $this->createNew(); 106 | $rule->setType($type); 107 | $rule->setConfiguration($configuration); 108 | 109 | return $rule; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Factory/CatalogPromotionRuleFactoryInterface.php: -------------------------------------------------------------------------------- 1 | decorated->createNew(); 21 | Assert::isInstanceOf($obj, CatalogPromotionUpdateInterface::class); 22 | 23 | return $obj; 24 | } 25 | 26 | public function createFromMessage(StartCatalogPromotionUpdate $message): CatalogPromotionUpdateInterface 27 | { 28 | $obj = $this->createNew(); 29 | $obj->setCatalogPromotions($message->catalogPromotions); 30 | $obj->setProducts($message->products); 31 | $obj->setTriggeredBy($message->triggeredBy); 32 | 33 | return $obj; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Factory/CatalogPromotionUpdateFactoryInterface.php: -------------------------------------------------------------------------------- 1 | children() 22 | ->scalarNode('code')->cannotBeEmpty()->end() 23 | ->scalarNode('name')->cannotBeEmpty()->end() 24 | ->scalarNode('description')->cannotBeEmpty()->end() 25 | 26 | ->scalarNode('priority')->cannotBeEmpty()->end() 27 | ->booleanNode('exclusive')->end() 28 | 29 | ->scalarNode('starts_at')->cannotBeEmpty()->end() 30 | ->scalarNode('ends_at')->cannotBeEmpty()->end() 31 | ->booleanNode('enabled')->end() 32 | 33 | ->floatNode('discount')->end() 34 | 35 | ->scalarNode('created_at')->cannotBeEmpty()->end() 36 | ->scalarNode('updated_at')->cannotBeEmpty()->end() 37 | 38 | ->arrayNode('rules') 39 | ->arrayPrototype() 40 | ->children() 41 | ->scalarNode('type')->cannotBeEmpty()->end() 42 | ->variableNode('configuration')->cannotBeEmpty()->end() 43 | ->end() 44 | ->end() 45 | ->end() 46 | ->arrayNode('channels')->scalarPrototype()->end() 47 | ; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Fixture/Factory/CatalogPromotionExampleFactory.php: -------------------------------------------------------------------------------- 1 | faker = \Faker\Factory::create(); 35 | $this->optionsResolver = new OptionsResolver(); 36 | 37 | $this->configureOptions($this->optionsResolver); 38 | } 39 | 40 | public function create(array $options = []): CatalogPromotionInterface 41 | { 42 | $options = $this->optionsResolver->resolve($options); 43 | 44 | /** @var CatalogPromotionInterface|null $catalogPromotion */ 45 | $catalogPromotion = $this->catalogPromotionRepository->findOneBy(['code' => $options['code']]); 46 | if (null === $catalogPromotion) { 47 | /** @var CatalogPromotionInterface $catalogPromotion */ 48 | $catalogPromotion = $this->catalogPromotionFactory->createNew(); 49 | } 50 | 51 | $catalogPromotion->setCode($options['code']); 52 | $catalogPromotion->setName($options['name']); 53 | $catalogPromotion->setDescription($options['description']); 54 | 55 | $catalogPromotion->setPriority((int) $options['priority']); 56 | $catalogPromotion->setExclusive($options['exclusive']); 57 | 58 | if (isset($options['starts_at'])) { 59 | $catalogPromotion->setStartsAt(new DateTime($options['starts_at'])); 60 | } 61 | 62 | if (isset($options['ends_at'])) { 63 | $catalogPromotion->setEndsAt(new DateTime($options['ends_at'])); 64 | } 65 | $catalogPromotion->setEnabled($options['enabled']); 66 | 67 | foreach ($options['channels'] as $channel) { 68 | $catalogPromotion->addChannel($channel); 69 | } 70 | 71 | foreach ($options['rules'] as $ruleOptions) { 72 | /** @var CatalogPromotionRuleInterface $catalogPromotionRule */ 73 | $catalogPromotionRule = $this->catalogPromotionRuleExampleFactory->create($ruleOptions); 74 | $catalogPromotion->addRule($catalogPromotionRule); 75 | } 76 | 77 | $catalogPromotion->setDiscount($options['discount']); 78 | 79 | $catalogPromotion->setCreatedAt($options['created_at']); 80 | $catalogPromotion->setUpdatedAt($options['updated_at']); 81 | 82 | return $catalogPromotion; 83 | } 84 | 85 | protected function configureOptions(OptionsResolver $resolver): void 86 | { 87 | $resolver 88 | ->setDefault('code', static function (Options $options): string { 89 | return StringInflector::nameToCode($options['name']); 90 | }) 91 | ->setDefault('name', function (Options $options): string { 92 | /** @var string $text */ 93 | $text = $this->faker->words(3, true); 94 | 95 | return $text; 96 | }) 97 | ->setDefault('description', function (Options $options): string { 98 | return $this->faker->sentence(); 99 | }) 100 | 101 | ->setDefault('priority', 0) 102 | ->setAllowedTypes('priority', 'int') 103 | 104 | ->setDefault('exclusive', function (Options $options): bool { 105 | return $this->faker->boolean(25); 106 | }) 107 | 108 | ->setDefault('starts_at', null) 109 | ->setAllowedTypes('starts_at', ['null', 'string']) 110 | ->setDefault('ends_at', null) 111 | ->setAllowedTypes('ends_at', ['null', 'string']) 112 | 113 | ->setDefault('enabled', function (Options $options): bool { 114 | return $this->faker->boolean(90); 115 | }) 116 | 117 | ->setDefault('discount', function (Options $options): float { 118 | return $this->faker->randomFloat(3, 0, 100); 119 | }) 120 | ->setNormalizer('discount', static function (Options $options, $value): float { 121 | if ($value >= 0 && $value <= 100) { 122 | $value = $value / 100; 123 | } 124 | 125 | Assert::range($value, 0, 1, 'Discount can be set in 0..100 range'); 126 | 127 | return $value; 128 | }) 129 | ->setAllowedTypes('discount', ['int', 'float']) 130 | 131 | ->setDefault('created_at', null) 132 | ->setAllowedTypes('created_at', ['null', DateTimeInterface::class]) 133 | ->setDefault('updated_at', null) 134 | ->setAllowedTypes('updated_at', ['null', DateTimeInterface::class]) 135 | 136 | ->setDefined('rules') 137 | ->setNormalizer('rules', static function (Options $options, array $rules): array { 138 | if (count($rules) === 0) { 139 | return [[]]; 140 | } 141 | 142 | return $rules; 143 | }) 144 | 145 | ->setDefault('channels', LazyOption::all($this->channelRepository)) 146 | ->setAllowedTypes('channels', 'array') 147 | ->setNormalizer('channels', LazyOption::findBy($this->channelRepository, 'code')) 148 | ; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Fixture/Factory/CatalogPromotionRuleExampleFactory.php: -------------------------------------------------------------------------------- 1 | optionsResolver = new OptionsResolver(); 21 | 22 | $this->configureOptions($this->optionsResolver); 23 | } 24 | 25 | public function create(array $options = []): CatalogPromotionRuleInterface 26 | { 27 | $options = $this->optionsResolver->resolve($options); 28 | 29 | return $this->catalogPromotionRuleFactory->createByType( 30 | $options['type'], 31 | $options['configuration'], 32 | ); 33 | } 34 | 35 | protected function configureOptions(OptionsResolver $resolver): void 36 | { 37 | $resolver 38 | ->setDefault('type', function (): string { 39 | $codes = array_keys($this->catalogPromotionRules); 40 | 41 | return $codes[array_rand($codes)]; 42 | }) 43 | ->setDefined('configuration') 44 | ->setAllowedTypes('configuration', ['string', 'array']) 45 | ; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Form/Type/CatalogPromotionRuleChoiceType.php: -------------------------------------------------------------------------------- 1 | rules = $rules; 19 | } 20 | 21 | public function configureOptions(OptionsResolver $resolver): void 22 | { 23 | $resolver->setDefaults([ 24 | 'choices' => array_flip($this->rules), 25 | ]); 26 | } 27 | 28 | public function getParent(): string 29 | { 30 | return ChoiceType::class; 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_choice'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Form/Type/CatalogPromotionRuleCollectionType.php: -------------------------------------------------------------------------------- 1 | setDefault('entry_type', CatalogPromotionRuleType::class); 17 | } 18 | 19 | public function getBlockPrefix(): string 20 | { 21 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_collection'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Form/Type/CatalogPromotionRuleType.php: -------------------------------------------------------------------------------- 1 | $validationGroups 21 | */ 22 | public function __construct(private readonly FormTypeRegistryInterface $formTypeRegistry, string $dataClass, array $validationGroups = []) 23 | { 24 | parent::__construct($dataClass, $validationGroups); 25 | } 26 | 27 | public function buildForm(FormBuilderInterface $builder, array $options): void 28 | { 29 | $builder 30 | ->add('type', CatalogPromotionRuleChoiceType::class, [ 31 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion_rule.type', 32 | 'attr' => [ 33 | 'data-form-collection' => 'update', 34 | ], 35 | ]) 36 | ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { 37 | $type = $this->getRegistryIdentifier($event->getForm(), $event->getData()); 38 | if (null === $type) { 39 | return; 40 | } 41 | 42 | $this->addConfigurationFields($event->getForm(), (string) $this->formTypeRegistry->get($type, 'default')); 43 | }) 44 | ->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { 45 | $type = $this->getRegistryIdentifier($event->getForm(), $event->getData()); 46 | if (null === $type) { 47 | return; 48 | } 49 | 50 | $event->getForm()->get('type')->setData($type); 51 | }) 52 | ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { 53 | /** @var mixed $data */ 54 | $data = $event->getData(); 55 | Assert::isArray($data); 56 | 57 | if (!isset($data['type'])) { 58 | return; 59 | } 60 | 61 | Assert::string($data['type']); 62 | 63 | $this->addConfigurationFields($event->getForm(), (string) $this->formTypeRegistry->get($data['type'], 'default')); 64 | }) 65 | ; 66 | } 67 | 68 | public function configureOptions(OptionsResolver $resolver): void 69 | { 70 | parent::configureOptions($resolver); 71 | 72 | $resolver 73 | ->setDefault('configuration_type', null) 74 | ->setAllowedTypes('configuration_type', ['string', 'null']) 75 | ; 76 | } 77 | 78 | public function getBlockPrefix(): string 79 | { 80 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule'; 81 | } 82 | 83 | protected function addConfigurationFields(FormInterface $form, string $configurationType): void 84 | { 85 | $form->add('configuration', $configurationType, [ 86 | 'label' => false, 87 | ]); 88 | } 89 | 90 | /** 91 | * @param mixed $data 92 | */ 93 | protected function getRegistryIdentifier(FormInterface $form, $data = null): ?string 94 | { 95 | if ($data instanceof CatalogPromotionRuleInterface && null !== $data->getType()) { 96 | return $data->getType(); 97 | } 98 | 99 | if ($form->getConfig()->hasOption('configuration_type')) { 100 | $res = $form->getConfig()->getOption('configuration_type'); 101 | Assert::nullOrString($res); 102 | 103 | return $res; 104 | } 105 | 106 | return null; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Form/Type/CatalogPromotionType.php: -------------------------------------------------------------------------------- 1 | add('channels', ChannelChoiceType::class, [ 24 | 'multiple' => true, 25 | 'expanded' => true, 26 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.channels', 27 | 'required' => false, 28 | ]) 29 | ->add('name', TextType::class, [ 30 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.name', 31 | ]) 32 | ->add('description', TextareaType::class, [ 33 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.description', 34 | 'required' => false, 35 | ]) 36 | ->add('discount', PercentType::class, [ 37 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.discount', 38 | 'scale' => 3, 39 | 'required' => true, 40 | ]) 41 | ->add('exclusive', CheckboxType::class, [ 42 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.exclusive', 43 | 'required' => false, 44 | ]) 45 | ->add('manuallyDiscountedProductsExcluded', CheckboxType::class, [ 46 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.manually_discounted_products_excluded', 47 | 'required' => false, 48 | ]) 49 | ->add('usingOriginalPriceAsBase', CheckboxType::class, [ 50 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.using_original_price_as_base', 51 | 'required' => false, 52 | ]) 53 | ->add('startsAt', DateTimeType::class, [ 54 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.starts_at', 55 | 'date_widget' => 'single_text', 56 | 'time_widget' => 'single_text', 57 | 'required' => false, 58 | ]) 59 | ->add('endsAt', DateTimeType::class, [ 60 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.ends_at', 61 | 'date_widget' => 'single_text', 62 | 'time_widget' => 'single_text', 63 | 'required' => false, 64 | ]) 65 | ->add('enabled', CheckboxType::class, [ 66 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.enabled', 67 | 'required' => false, 68 | ]) 69 | ->add('priority', IntegerType::class, [ 70 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.priority', 71 | 'required' => false, 72 | ]) 73 | ->add('rules', CatalogPromotionRuleCollectionType::class, [ 74 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.rules', 75 | 'button_add_label' => 'setono_sylius_catalog_promotion.form.catalog_promotion.add_rule', 76 | 'required' => false, 77 | ]) 78 | ->addEventSubscriber(new AddCodeFormSubscriber()) 79 | ; 80 | } 81 | 82 | public function getBlockPrefix(): string 83 | { 84 | return 'setono_sylius_catalog_promotion__catalog_promotion'; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Form/Type/Rule/ContainsProductConfigurationType.php: -------------------------------------------------------------------------------- 1 | productRepository = $productRepository; 21 | } 22 | 23 | public function buildForm(FormBuilderInterface $builder, array $options): void 24 | { 25 | $builder 26 | ->add('product', ProductAutocompleteChoiceType::class, [ 27 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion_rule.contains_product_configuration.product', 28 | ]) 29 | ; 30 | 31 | $builder->get('product')->addModelTransformer( 32 | new ReversedTransformer(new ResourceToIdentifierTransformer($this->productRepository, 'code')), 33 | ); 34 | } 35 | 36 | public function getBlockPrefix(): string 37 | { 38 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_contains_product_configuration'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Form/Type/Rule/ContainsProductsConfigurationType.php: -------------------------------------------------------------------------------- 1 | productsToCodesTransformer = $productsToCodesTransformer; 19 | } 20 | 21 | public function buildForm(FormBuilderInterface $builder, array $options): void 22 | { 23 | $builder 24 | ->add('products', ProductAutocompleteChoiceType::class, [ 25 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion_rule.contains_products_configuration.products', 26 | 'multiple' => true, 27 | ]) 28 | ; 29 | 30 | $builder->get('products')->addModelTransformer($this->productsToCodesTransformer); 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_contains_products_configuration'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Form/Type/Rule/HasNotTaxonConfigurationType.php: -------------------------------------------------------------------------------- 1 | taxonsToCodesTransformer = $taxonsToCodesTransformer; 19 | } 20 | 21 | public function buildForm(FormBuilderInterface $builder, array $options): void 22 | { 23 | $builder 24 | ->add('taxons', TaxonAutocompleteChoiceType::class, [ 25 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion_rule.has_taxon_configuration.taxons', 26 | 'multiple' => true, 27 | ]) 28 | ; 29 | 30 | $builder->get('taxons')->addModelTransformer($this->taxonsToCodesTransformer); 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_has_not_taxon_configuration'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Form/Type/Rule/HasTaxonConfigurationType.php: -------------------------------------------------------------------------------- 1 | taxonsToCodesTransformer = $taxonsToCodesTransformer; 19 | } 20 | 21 | public function buildForm(FormBuilderInterface $builder, array $options): void 22 | { 23 | $builder 24 | ->add('taxons', TaxonAutocompleteChoiceType::class, [ 25 | 'label' => 'setono_sylius_catalog_promotion.form.catalog_promotion_rule.has_taxon_configuration.taxons', 26 | 'multiple' => true, 27 | ]) 28 | ; 29 | 30 | $builder->get('taxons')->addModelTransformer($this->taxonsToCodesTransformer); 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'setono_sylius_catalog_promotion__catalog_promotion_rule_has_taxon_configuration'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Message/Command/AsyncCommandInterface.php: -------------------------------------------------------------------------------- 1 | catalogPromotionUpdate = (int) $catalogPromotionUpdate->getId(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Message/Command/ProcessCatalogPromotionUpdate.php: -------------------------------------------------------------------------------- 1 | catalogPromotionUpdate = $catalogPromotionUpdate instanceof CatalogPromotionUpdateInterface ? (int) $catalogPromotionUpdate->getId() : $catalogPromotionUpdate; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Message/Command/StartCatalogPromotionUpdate.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public readonly array $catalogPromotions; 21 | 22 | /** 23 | * A list of product ids to process. If empty, all products will be processed 24 | * 25 | * @var list 26 | */ 27 | public readonly array $products; 28 | 29 | /** 30 | * @param array $catalogPromotions 31 | * @param array $products 32 | */ 33 | public function __construct( 34 | array $catalogPromotions = [], 35 | array $products = [], 36 | /** If you want to give information about what started the update, you can provide a string here */ 37 | public readonly ?string $triggeredBy = null, 38 | ) { 39 | $this->catalogPromotions = array_values(array_unique(array_map( 40 | static fn (string|CatalogPromotionInterface $catalogPromotion) => $catalogPromotion instanceof CatalogPromotionInterface ? (string) $catalogPromotion->getCode() : $catalogPromotion, 41 | $catalogPromotions, 42 | ))); 43 | 44 | $this->products = array_values(array_unique(array_map( 45 | static fn (int|ProductInterface $product) => $product instanceof ProductInterface ? (int) $product->getId() : $product, 46 | $products, 47 | ))); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Message/Command/UpdateProducts.php: -------------------------------------------------------------------------------- 1 | $productIds */ 22 | public readonly array $productIds, 23 | /** @var list $catalogPromotions */ 24 | public readonly array $catalogPromotions, 25 | ) { 26 | $this->catalogPromotionUpdate = (int) $catalogPromotionUpdate->getId(); 27 | $this->messageId = (string) Uuid::v4(); 28 | $catalogPromotionUpdate->addMessageId($this->messageId); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Message/CommandHandler/CheckCatalogPromotionUpdateHandler.php: -------------------------------------------------------------------------------- 1 | $catalogPromotionUpdateClass */ 27 | private readonly string $catalogPromotionUpdateClass, 28 | private readonly int $maxTries = 10, 29 | private readonly int $maxRetrySeconds = 43_200, // 12 hours 30 | ) { 31 | $this->managerRegistry = $managerRegistry; 32 | } 33 | 34 | public function __invoke(CheckCatalogPromotionUpdate $message): void 35 | { 36 | ++$message->tries; 37 | 38 | $catalogPromotionUpdate = $this->getCatalogPromotionUpdate($message->catalogPromotionUpdate); 39 | 40 | if ($catalogPromotionUpdate->getState() !== CatalogPromotionUpdateInterface::STATE_PROCESSING) { 41 | throw new UnrecoverableMessageHandlingException(sprintf( 42 | 'Catalog promotion update with id %d is not in the "%s" state', 43 | $message->catalogPromotionUpdate, 44 | CatalogPromotionUpdateInterface::STATE_PROCESSING, 45 | )); 46 | } 47 | 48 | try { 49 | // todo I think the 'retry logic' belongs in some middleware where we can get the retry count from the envelope 50 | if (!$catalogPromotionUpdate->hasAllMessagesBeenProcessed()) { 51 | if ($message->tries >= $this->maxTries) { 52 | throw new UnrecoverableMessageHandlingException(sprintf( 53 | 'Catalog promotion update with id %s has not processed all messages after %d tries', 54 | $message->catalogPromotionUpdate, 55 | $this->maxTries, 56 | )); 57 | } 58 | 59 | $createdAt = $catalogPromotionUpdate->getCreatedAt(); 60 | Assert::notNull($createdAt); 61 | $createdAt = \DateTimeImmutable::createFromInterface($createdAt); 62 | 63 | if (($this->clock?->now() ?? new \DateTimeImmutable()) >= $createdAt->add(new \DateInterval(sprintf('PT%dS', $this->maxRetrySeconds)))) { 64 | throw new UnrecoverableMessageHandlingException(sprintf( 65 | 'Catalog promotion update with id %d has not processed all messages after %d seconds', 66 | $message->catalogPromotionUpdate, 67 | $this->maxRetrySeconds, 68 | )); 69 | } 70 | 71 | throw new RecoverableMessageHandlingException(sprintf( 72 | 'Catalog promotion update with id %s has not processed all messages', 73 | $message->catalogPromotionUpdate, 74 | )); 75 | } 76 | 77 | $this->catalogPromotionUpdateWorkflow->apply($catalogPromotionUpdate, CatalogPromotionUpdateWorkflow::TRANSITION_COMPLETE); 78 | } catch (UnrecoverableMessageHandlingException $e) { 79 | $this->catalogPromotionUpdateWorkflow->apply($catalogPromotionUpdate, CatalogPromotionUpdateWorkflow::TRANSITION_FAIL); 80 | 81 | $catalogPromotionUpdate->setError($e->getMessage()); 82 | 83 | throw $e; 84 | } finally { 85 | $manager = $this->getManager($this->catalogPromotionUpdateClass); 86 | $manager->flush(); 87 | } 88 | } 89 | 90 | private function getCatalogPromotionUpdate(int $id): CatalogPromotionUpdateInterface 91 | { 92 | $catalogPromotionUpdate = $this->getManager($this->catalogPromotionUpdateClass)->find($this->catalogPromotionUpdateClass, $id); 93 | if (null === $catalogPromotionUpdate) { 94 | throw new UnrecoverableMessageHandlingException(sprintf('Catalog promotion update with id %d not found', $id)); 95 | } 96 | 97 | return $catalogPromotionUpdate; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Message/CommandHandler/MessageBuffer.php: -------------------------------------------------------------------------------- 1 | */ 19 | private array $buffer = []; 20 | 21 | public function __construct( 22 | private readonly int $bufferSize, 23 | /** @var Closure(list):void $callback */ 24 | private readonly Closure $callback, 25 | ) { 26 | } 27 | 28 | /** 29 | * @param T $item 30 | */ 31 | public function push(mixed $item): void 32 | { 33 | $this->buffer[] = $item; 34 | ++$this->count; 35 | 36 | if ($this->count >= $this->bufferSize) { 37 | $this->flush(); 38 | } 39 | } 40 | 41 | public function flush(): void 42 | { 43 | if ($this->count > 0) { 44 | ($this->callback)($this->buffer); 45 | $this->buffer = []; 46 | $this->count = 0; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Message/CommandHandler/ProcessCatalogPromotionUpdateHandler.php: -------------------------------------------------------------------------------- 1 | $catalogPromotionUpdateClass */ 29 | private readonly string $catalogPromotionUpdateClass, 30 | private readonly int $bufferSize = 100, 31 | ) { 32 | $this->managerRegistry = $managerRegistry; 33 | } 34 | 35 | public function __invoke(ProcessCatalogPromotionUpdate $message): void 36 | { 37 | $catalogPromotionUpdate = $this->getCatalogPromotionUpdate($message->catalogPromotionUpdate); 38 | 39 | if ($catalogPromotionUpdate->getState() !== CatalogPromotionUpdateInterface::STATE_PENDING) { 40 | throw new UnrecoverableMessageHandlingException(sprintf('Catalog promotion update with id %s is not in the "%s" state', $message->catalogPromotionUpdate, CatalogPromotionUpdateInterface::STATE_PENDING)); 41 | } 42 | 43 | $manager = $this->getManager($this->catalogPromotionUpdateClass); 44 | 45 | try { 46 | $this->catalogPromotionUpdateWorkflow->apply($catalogPromotionUpdate, CatalogPromotionUpdateWorkflow::TRANSITION_PROCESS); 47 | $manager->flush(); 48 | 49 | /** 50 | * @psalm-suppress MixedArgumentTypeCoercion 51 | * 52 | * @var MessageBuffer $buffer 53 | */ 54 | $buffer = new MessageBuffer( 55 | $this->bufferSize, 56 | fn (array $productIds) => $this->commandBus->dispatch(new UpdateProducts( 57 | catalogPromotionUpdate: $catalogPromotionUpdate, 58 | productIds: $productIds, 59 | catalogPromotions: $catalogPromotionUpdate->getCatalogPromotions(), 60 | )), 61 | ); 62 | 63 | $i = 0; 64 | foreach ($this->productDataProvider->getIds($catalogPromotionUpdate->getProducts()) as $id) { 65 | $buffer->push($id); 66 | 67 | ++$i; 68 | } 69 | 70 | $buffer->flush(); 71 | 72 | // Because we re-fetch the catalog promotion update below, we will save the message ids here 73 | // todo would be more clean to do it with middleware I guess, but would require x times more calls to the database... 74 | $messageIds = $catalogPromotionUpdate->getMessageIds(); 75 | 76 | // We need to re-fetch the catalog promotion update because it might 77 | // have become detached from the UnitOfWork inside the data provider above 78 | $catalogPromotionUpdate = $this->getCatalogPromotionUpdate($message->catalogPromotionUpdate); 79 | $catalogPromotionUpdate->setEstimatedNumberOfProductsToUpdate($i); 80 | $catalogPromotionUpdate->setMessageIds($messageIds); 81 | 82 | $this->commandBus->dispatch(new CheckCatalogPromotionUpdate($catalogPromotionUpdate)); 83 | } catch (\Throwable $e) { 84 | $this->catalogPromotionUpdateWorkflow->apply($catalogPromotionUpdate, CatalogPromotionUpdateWorkflow::TRANSITION_FAIL); 85 | $catalogPromotionUpdate->setError($e->getMessage()); 86 | 87 | throw $e; 88 | } finally { 89 | $manager->flush(); 90 | } 91 | } 92 | 93 | private function getCatalogPromotionUpdate(int $id): CatalogPromotionUpdateInterface 94 | { 95 | $catalogPromotionUpdate = $this->getManager($this->catalogPromotionUpdateClass)->find($this->catalogPromotionUpdateClass, $id); 96 | if (null === $catalogPromotionUpdate) { 97 | throw new UnrecoverableMessageHandlingException(sprintf('Catalog promotion update with id %s not found', $id)); 98 | } 99 | 100 | return $catalogPromotionUpdate; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Message/CommandHandler/StartCatalogPromotionUpdateHandler.php: -------------------------------------------------------------------------------- 1 | managerRegistry = $managerRegistry; 26 | } 27 | 28 | public function __invoke(StartCatalogPromotionUpdate $message): void 29 | { 30 | $catalogPromotionUpdate = $this->catalogPromotionUpdateFactory->createFromMessage($message); 31 | 32 | $manager = $this->getManager($catalogPromotionUpdate); 33 | $manager->persist($catalogPromotionUpdate); 34 | $manager->flush(); 35 | 36 | $this->commandBus->dispatch(new ProcessCatalogPromotionUpdate($catalogPromotionUpdate)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Model/CatalogPromotionInterface.php: -------------------------------------------------------------------------------- 1 | */ 58 | public function getRules(): Collection; 59 | 60 | public function hasRules(): bool; 61 | 62 | public function hasRule(CatalogPromotionRuleInterface $rule): bool; 63 | 64 | public function addRule(CatalogPromotionRuleInterface $rule): void; 65 | 66 | public function removeRule(CatalogPromotionRuleInterface $rule): void; 67 | 68 | public function getDiscount(): float; 69 | 70 | public function setDiscount(float $discount): void; 71 | } 72 | -------------------------------------------------------------------------------- /src/Model/CatalogPromotionRule.php: -------------------------------------------------------------------------------- 1 | id; 20 | } 21 | 22 | public function getType(): ?string 23 | { 24 | return $this->type; 25 | } 26 | 27 | public function setType(string $type): void 28 | { 29 | $this->type = $type; 30 | } 31 | 32 | public function getConfiguration(): array 33 | { 34 | return $this->configuration; 35 | } 36 | 37 | public function setConfiguration(array $configuration): void 38 | { 39 | $this->configuration = $configuration; 40 | } 41 | 42 | public function getCatalogPromotion(): ?CatalogPromotionInterface 43 | { 44 | return $this->catalogPromotion; 45 | } 46 | 47 | public function setCatalogPromotion(?CatalogPromotionInterface $catalogPromotion): void 48 | { 49 | $this->catalogPromotion = $catalogPromotion; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Model/CatalogPromotionRuleInterface.php: -------------------------------------------------------------------------------- 1 | |null */ 23 | protected ?array $catalogPromotions = null; 24 | 25 | /** @var list|null */ 26 | protected ?array $products = null; 27 | 28 | protected ?string $triggeredBy = null; 29 | 30 | protected ?int $estimatedNumberOfProductsToUpdate = null; 31 | 32 | protected int $productsUpdated = 0; 33 | 34 | /** @var list */ 35 | protected ?array $messageIds = null; 36 | 37 | /** @var list */ 38 | protected ?array $processedMessageIds = null; 39 | 40 | protected ?\DateTimeImmutable $finishedAt = null; 41 | 42 | public function getId(): ?int 43 | { 44 | return $this->id; 45 | } 46 | 47 | public function getVersion(): ?int 48 | { 49 | return $this->version; 50 | } 51 | 52 | public function setVersion(?int $version): void 53 | { 54 | $this->version = (int) $version; 55 | } 56 | 57 | public function getState(): string 58 | { 59 | return $this->state; 60 | } 61 | 62 | public function setState(string $state): void 63 | { 64 | $this->state = $state; 65 | } 66 | 67 | /** 68 | * @return list 69 | */ 70 | public static function getStates(): array 71 | { 72 | return [ 73 | self::STATE_PENDING, 74 | self::STATE_PROCESSING, 75 | self::STATE_COMPLETED, 76 | self::STATE_FAILED, 77 | ]; 78 | } 79 | 80 | public function getError(): ?string 81 | { 82 | return $this->error; 83 | } 84 | 85 | public function setError(?string $error): void 86 | { 87 | $this->error = $error; 88 | } 89 | 90 | public function getCatalogPromotions(): array 91 | { 92 | return $this->catalogPromotions ?? []; 93 | } 94 | 95 | public function setCatalogPromotions(?array $catalogPromotions): void 96 | { 97 | if ([] === $catalogPromotions) { 98 | $catalogPromotions = null; 99 | } 100 | 101 | $this->catalogPromotions = $catalogPromotions; 102 | } 103 | 104 | public function getProducts(): array 105 | { 106 | return $this->products ?? []; 107 | } 108 | 109 | public function setProducts(?array $products): void 110 | { 111 | if ([] === $products) { 112 | $products = null; 113 | } 114 | 115 | $this->products = $products; 116 | } 117 | 118 | public function getTriggeredBy(): ?string 119 | { 120 | return $this->triggeredBy; 121 | } 122 | 123 | public function setTriggeredBy(?string $triggeredBy): void 124 | { 125 | $this->triggeredBy = $triggeredBy; 126 | } 127 | 128 | public function getEstimatedNumberOfProductsToUpdate(): ?int 129 | { 130 | return $this->estimatedNumberOfProductsToUpdate; 131 | } 132 | 133 | public function setEstimatedNumberOfProductsToUpdate(int $estimatedNumberOfProductsToUpdate): void 134 | { 135 | $this->estimatedNumberOfProductsToUpdate = $estimatedNumberOfProductsToUpdate; 136 | } 137 | 138 | public function getProductsUpdated(): int 139 | { 140 | return $this->productsUpdated; 141 | } 142 | 143 | public function setProductsUpdated(int $productsUpdated): void 144 | { 145 | $this->productsUpdated = $productsUpdated; 146 | } 147 | 148 | public function incrementProductsUpdated(int $increment = 1): void 149 | { 150 | $this->productsUpdated += $increment; 151 | } 152 | 153 | public function getMessageIds(): array 154 | { 155 | return $this->messageIds ?? []; 156 | } 157 | 158 | public function addMessageId(string $messageId): void 159 | { 160 | if (null === $this->messageIds) { 161 | $this->messageIds = []; 162 | } 163 | 164 | $this->messageIds[] = $messageId; 165 | } 166 | 167 | public function setMessageIds(array $messageIds): void 168 | { 169 | if ([] === $messageIds) { 170 | $messageIds = null; 171 | } 172 | 173 | $this->messageIds = $messageIds; 174 | } 175 | 176 | public function addProcessedMessageId(string $messageId): void 177 | { 178 | if (null === $this->processedMessageIds) { 179 | $this->processedMessageIds = []; 180 | } 181 | 182 | $this->processedMessageIds[] = $messageId; 183 | } 184 | 185 | public function hasAllMessagesBeenProcessed(): bool 186 | { 187 | if (null === $this->messageIds || [] === $this->messageIds) { 188 | return true; 189 | } 190 | 191 | if (null === $this->processedMessageIds || [] === $this->processedMessageIds) { 192 | return false; 193 | } 194 | 195 | return [] === array_diff($this->messageIds, $this->processedMessageIds); 196 | } 197 | 198 | public function getFinishedAt(): ?\DateTimeImmutable 199 | { 200 | return $this->finishedAt; 201 | } 202 | 203 | public function setFinishedAt(\DateTimeImmutable $finishedAt): void 204 | { 205 | $this->finishedAt = $finishedAt; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Model/CatalogPromotionUpdateInterface.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | public function getCatalogPromotions(): array; 37 | 38 | /** 39 | * @param list|null $catalogPromotions 40 | */ 41 | public function setCatalogPromotions(?array $catalogPromotions): void; 42 | 43 | /** 44 | * A list of product ids that the update is for. If empty, all products will be updated 45 | * 46 | * @return list 47 | */ 48 | public function getProducts(): array; 49 | 50 | /** 51 | * @param list|null $products 52 | */ 53 | public function setProducts(?array $products): void; 54 | 55 | /** 56 | * Information about what started the update 57 | */ 58 | public function getTriggeredBy(): ?string; 59 | 60 | public function setTriggeredBy(?string $triggeredBy): void; 61 | 62 | public function getEstimatedNumberOfProductsToUpdate(): ?int; 63 | 64 | public function setEstimatedNumberOfProductsToUpdate(int $estimatedNumberOfProductsToUpdate): void; 65 | 66 | public function getProductsUpdated(): int; 67 | 68 | public function setProductsUpdated(int $productsUpdated): void; 69 | 70 | public function incrementProductsUpdated(int $increment = 1): void; 71 | 72 | /** 73 | * Holds a list of ids that represent the messages responsible for updating the products. 74 | * This way we can track when the processing of a catalog promotion update is done. 75 | * 76 | * @return list 77 | */ 78 | public function getMessageIds(): array; 79 | 80 | public function addMessageId(string $messageId): void; 81 | 82 | /** 83 | * @param list $messageIds 84 | */ 85 | public function setMessageIds(array $messageIds): void; 86 | 87 | /** 88 | * Adds a message id to the list of processed message ids. Should only be added when a message was successfully processed 89 | */ 90 | public function addProcessedMessageId(string $messageId): void; 91 | 92 | /** 93 | * Returns true if the processed list of message ids is equal to the list of message ids 94 | */ 95 | public function hasAllMessagesBeenProcessed(): bool; 96 | 97 | /** 98 | * The time the update either completed or failed 99 | */ 100 | public function getFinishedAt(): ?\DateTimeImmutable; 101 | 102 | public function setFinishedAt(\DateTimeImmutable $finishedAt): void; 103 | } 104 | -------------------------------------------------------------------------------- /src/Model/ProductInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function getPreQualifiedCatalogPromotions(): array; 18 | 19 | /** 20 | * @param array|null $preQualifiedCatalogPromotions 21 | */ 22 | public function setPreQualifiedCatalogPromotions(?array $preQualifiedCatalogPromotions): void; 23 | 24 | public function hasPreQualifiedCatalogPromotions(): bool; 25 | } 26 | -------------------------------------------------------------------------------- /src/Model/ProductTrait.php: -------------------------------------------------------------------------------- 1 | |null 13 | * 14 | * @ORM\Column(type="json", nullable=true) 15 | */ 16 | #[ORM\Column(type: 'json', nullable: true)] 17 | protected ?array $preQualifiedCatalogPromotions = null; 18 | 19 | /** 20 | * @return list 21 | */ 22 | public function getPreQualifiedCatalogPromotions(): array 23 | { 24 | return $this->preQualifiedCatalogPromotions ?? []; 25 | } 26 | 27 | /** 28 | * @param list|null $preQualifiedCatalogPromotions 29 | */ 30 | public function setPreQualifiedCatalogPromotions(?array $preQualifiedCatalogPromotions): void 31 | { 32 | $preQualifiedCatalogPromotions = self::sanitizeCodes($preQualifiedCatalogPromotions ?? []); 33 | 34 | if ([] === $preQualifiedCatalogPromotions) { 35 | $preQualifiedCatalogPromotions = null; 36 | } 37 | 38 | $this->preQualifiedCatalogPromotions = $preQualifiedCatalogPromotions; 39 | } 40 | 41 | public function hasPreQualifiedCatalogPromotions(): bool 42 | { 43 | return null !== $this->preQualifiedCatalogPromotions && [] !== $this->preQualifiedCatalogPromotions; 44 | } 45 | 46 | /** 47 | * @param array $codes 48 | * 49 | * @return list 50 | */ 51 | private static function sanitizeCodes(array $codes): array 52 | { 53 | // The reason for sorting is that we use the imploded string as a cache key elsewhere 54 | sort($codes, \SORT_STRING); 55 | 56 | return array_values(array_unique($codes)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Repository/CatalogPromotionRepository.php: -------------------------------------------------------------------------------- 1 | createProcessingQueryBuilder(); 17 | 18 | if ([] !== $catalogPromotions) { 19 | $qb->andWhere('o.code IN (:catalogPromotions)') 20 | ->setParameter('catalogPromotions', $catalogPromotions) 21 | ; 22 | } 23 | 24 | $objs = $qb->getQuery()->getResult(); 25 | Assert::isArray($objs); 26 | Assert::allIsInstanceOf($objs, CatalogPromotionInterface::class); 27 | Assert::isList($objs); 28 | 29 | return $objs; 30 | } 31 | 32 | public function findOneForProcessing(string $code): ?CatalogPromotionInterface 33 | { 34 | $obj = $this->createProcessingQueryBuilder() 35 | ->andWhere('o.code = :code') 36 | ->setParameter('code', $code) 37 | ->getQuery() 38 | ->getOneOrNullResult() 39 | ; 40 | Assert::nullOrIsInstanceOf($obj, CatalogPromotionInterface::class); 41 | 42 | return $obj; 43 | } 44 | 45 | public function findOneByCode(string $code): ?CatalogPromotionInterface 46 | { 47 | $obj = $this->findOneBy(['code' => $code]); 48 | Assert::nullOrIsInstanceOf($obj, CatalogPromotionInterface::class); 49 | 50 | return $obj; 51 | } 52 | 53 | private function createProcessingQueryBuilder(): QueryBuilder 54 | { 55 | return $this->createQueryBuilder('o') 56 | ->select('o, r') 57 | ->leftJoin('o.rules', 'r') // important to use left join because we might have 0 rules on a catalog promotion 58 | ; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Repository/CatalogPromotionRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | $catalogPromotions a list of catalog promotion codes to filter by 14 | * 15 | * @return list 16 | */ 17 | public function findForProcessing(array $catalogPromotions = []): array; 18 | 19 | public function findOneForProcessing(string $code): ?CatalogPromotionInterface; 20 | 21 | public function findOneByCode(string $code): ?CatalogPromotionInterface; 22 | } 23 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/model/CatalogPromotion.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/model/CatalogPromotionRule.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/model/CatalogPromotionUpdate.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Resources/config/routes.yaml: -------------------------------------------------------------------------------- 1 | setono_sylius_catalog_promotion_admin: 2 | resource: "@SetonoSyliusCatalogPromotionPlugin/Resources/config/routes/admin.yaml" 3 | prefix: /admin 4 | -------------------------------------------------------------------------------- /src/Resources/config/routes/admin.yaml: -------------------------------------------------------------------------------- 1 | setono_sylius_catalog_promotion_admin_catalog_promotion_update: 2 | resource: | 3 | alias: setono_sylius_catalog_promotion.catalog_promotion_update 4 | section: admin 5 | templates: '@SyliusAdmin\\Crud' 6 | grid: setono_sylius_catalog_promotion_admin_catalog_promotion_update 7 | permission: true 8 | vars: 9 | index: 10 | icon: in cart 11 | type: sylius.resource 12 | 13 | setono_sylius_catalog_promotion_admin_catalog_promotion: 14 | resource: | 15 | alias: setono_sylius_catalog_promotion.catalog_promotion 16 | section: admin 17 | templates: '@SyliusAdmin\\Crud' 18 | except: ['show'] 19 | redirect: update 20 | grid: setono_sylius_catalog_promotion_admin_catalog_promotion 21 | permission: true 22 | vars: 23 | all: 24 | subheader: setono_sylius_catalog_promotion.ui.manage_catalog_promotions 25 | templates: 26 | form: "@SetonoSyliusCatalogPromotionPlugin/admin/catalog_promotion/_form.html.twig" 27 | index: 28 | icon: in cart 29 | update: 30 | templates: 31 | form: "@SetonoSyliusCatalogPromotionPlugin/admin/catalog_promotion/_form.html.twig" 32 | type: sylius.resource 33 | 34 | setono_sylius_consent_management_admin_update_all_catalog: 35 | path: /catalog-promotions/update-all 36 | methods: GET 37 | defaults: 38 | _controller: Setono\SyliusCatalogPromotionPlugin\Controller\UpdateAllAction 39 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Resources/config/services/applicator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | %setono_sylius_catalog_promotion.model.catalog_promotion.class% 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Resources/config/services/calculator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Resources/config/services/checker.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 41 | 42 | 43 | 44 | 48 | 49 | 50 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/Resources/config/services/command.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | %setono_sylius_catalog_promotion.model.catalog_promotion_update.class% 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Resources/config/services/context.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/config/services/controller.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/config/services/data_provider.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | %sylius.model.product.class% 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Resources/config/services/event_subscriber.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Resources/config/services/factory.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 11 | 12 | 13 | 16 | 17 | %setono_sylius_catalog_promotion.catalog_promotion_rules% 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Resources/config/services/fixture.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | %setono_sylius_catalog_promotion.catalog_promotion_rules% 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Resources/config/services/form.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | setono_sylius_catalog_promotion 10 | 11 | 12 | setono_sylius_catalog_promotion 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | %setono_sylius_catalog_promotion.model.catalog_promotion.class% 22 | %setono_sylius_catalog_promotion.form.type.catalog_promotion.validation_groups% 23 | 24 | 25 | 26 | 27 | 28 | 29 | %setono_sylius_catalog_promotion.model.catalog_promotion_rule.class% 30 | %setono_sylius_catalog_promotion.form.type.catalog_promotion_rule.validation_groups% 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | %setono_sylius_catalog_promotion.catalog_promotion_rules% 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/Resources/config/services/message.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | %setono_sylius_catalog_promotion.model.catalog_promotion_update.class% 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | %setono_sylius_catalog_promotion.model.catalog_promotion_update.class% 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | %setono_sylius_catalog_promotion.model.catalog_promotion_update.class% 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/Resources/config/services/registry.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Setono\SyliusCatalogPromotionPlugin\Checker\PreQualification\Rule\RuleCheckerInterface 9 | Catalog promotion rule checker 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/config/services/validator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Resources/config/validation/CatalogPromotion.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/Resources/config/validation/CatalogPromotionRule.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Resources/translations/flashes.en.yaml: -------------------------------------------------------------------------------- 1 | setono_sylius_catalog_promotion: 2 | update_all_success: An update of all catalog promotions was triggered. 3 | -------------------------------------------------------------------------------- /src/Resources/translations/messages.en.yaml: -------------------------------------------------------------------------------- 1 | setono_sylius_catalog_promotion: 2 | form: 3 | catalog_promotion: 4 | add_rule: Add rule 5 | channels: Apply to channels 6 | description: Description 7 | discount: Discount 8 | enabled: Enabled 9 | ends_at: Ends at 10 | exclusive: Exclusive 11 | manually_discounted_products_excluded: Exclude manually discounted products 12 | using_original_price_as_base: Use original price as base 13 | name: Name 14 | priority: Priority 15 | rules: Rules 16 | starts_at: Starts at 17 | catalog_promotion_rule: 18 | contains_product: Product is 19 | contains_product_configuration: 20 | product: Product 21 | contains_products: Product is one of 22 | contains_products_configuration: 23 | products: Products 24 | has_taxon: Product having one of taxons 25 | has_taxon_configuration: 26 | taxons: Taxons 27 | has_not_taxon: Product not having any of taxons 28 | has_not_taxon_configuration: 29 | taxons: Taxons 30 | taxonomy: Taxonomy 31 | type: Type 32 | menu: 33 | admin: 34 | main: 35 | marketing: 36 | catalog_promotions: Catalog promotions 37 | ui: 38 | all: All 39 | back_to_catalog_promotions: Back to catalog promotions 40 | catalog_promotion: Catalog promotion 41 | catalog_promotion_updates: Catalog promotion updates 42 | catalog_promotions: Catalog promotions 43 | completed: Completed 44 | edit_catalog_promotion: Edit catalog promotion 45 | failed: Failed 46 | list_of_updates: List of updates 47 | manage_catalog_promotions: Manage catalog promotions 48 | manually_discounted_products_excluded: Manually discounted products excluded 49 | new_catalog_promotion: Create new catalog promotion 50 | no_end_date: No end date 51 | no_error: No error 52 | no_start_date: No start date 53 | pending: Pending 54 | using_original_price_as_base_information: | 55 | When calculating discounts, the discount is based on the existing price by default, however, you can choose to use the original price instead. For example, if a product is on sale for $80 (original price: $100) and a 30% discount is applied:

56 | - Using the price as base (default): $80 - 30% = $56
57 | - Using the original price as the base: $100 - 30% = $70.

58 | NOTICE that if you check the "Use original price as base" all other catalog promotions applied to a product will also use the original price. 59 | processing: Processing 60 | products_updated: Products updated 61 | products_updated_less_than_estimated: The number of products updated is less than the estimated number of products to update. This may be due to the fact that some products were filtered/edited/removed after the initial processing started. 62 | rules_explanation: If you don't add any rules, the catalog promotion will be applied to all products 63 | triggered_by: Triggered by 64 | updated_at: Updated at 65 | name_or_code: Name or code 66 | update_all_catalog_promotions: Update all 67 | -------------------------------------------------------------------------------- /src/Resources/translations/validators.en.yaml: -------------------------------------------------------------------------------- 1 | setono_sylius_catalog_promotion: 2 | catalog_promotion: 3 | code: 4 | unique: A catalog promotion with given code already exists. 5 | regex: Catalog promotion code can only be comprised of letters (a-z), numbers (0-9), dashes (-) and underscores (_). 6 | discount: 7 | range: Please enter value between 0% and 100%. 8 | end_date_cannot_be_set_prior_start_date: End date must be set after start date. 9 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion/_form.html.twig: -------------------------------------------------------------------------------- 1 | {# @var resource \Setono\SyliusCatalogPromotionPlugin\Model\CatalogPromotionInterface #} 2 |
3 |
4 | {{ form_errors(form) }} 5 |
6 |
7 | {{ form_row(form.code) }} 8 | {{ form_row(form.name) }} 9 |
10 | {{ form_row(form.description) }} 11 |
12 |
13 |
14 |
15 |
16 | {{ form_row(form.priority) }} 17 | {{ form_row(form.exclusive) }} 18 | {{ form_row(form.manuallyDiscountedProductsExcluded) }} 19 |
20 |
21 | 22 |
23 | {{ 'setono_sylius_catalog_promotion.ui.using_original_price_as_base_information'|trans|raw }} 24 |
25 |
26 | {{ form_row(form.usingOriginalPriceAsBase) }} 27 |
28 |
29 |
30 | {{ form_row(form.channels) }} 31 | {{ form_row(form.enabled) }} 32 |
33 |
34 |

{{ 'sylius.ui.start_date'|trans }} & {{ 'sylius.ui.end_date'|trans }}

35 | 36 |
37 | {{ form_row(form.startsAt) }} 38 | {{ form_row(form.endsAt) }} 39 |
40 |
41 |
42 |
43 |

{{ 'sylius.ui.configuration'|trans }}

44 |
45 |
46 |
47 | 48 |
49 | {{ 'setono_sylius_catalog_promotion.ui.rules_explanation'|trans }} 50 |
51 |
52 |
{{ form_row(form.rules) }}
53 |
54 |
55 |
56 | {{ form_row(form.discount) }} 57 |
58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion/_javascripts.html.twig: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion_update/_javascripts.html.twig: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion_update/label/state/completed.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ value|trans }} 4 | 5 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion_update/label/state/failed.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ value|trans }} 4 | 5 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion_update/label/state/pending.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ value|trans }} 4 | 5 | -------------------------------------------------------------------------------- /src/Resources/views/admin/catalog_promotion_update/label/state/processing.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ value|trans }} 4 | 5 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/_channels.html.twig: -------------------------------------------------------------------------------- 1 | {% for channel in data %} 2 |
3 | 4 | 5 | 6 | {{ channel.name|default(channel.code) }} 7 | 8 |
9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/catalog_promotion_list.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data string[] #} 2 | {% if data is empty %} 3 | {{ 'setono_sylius_catalog_promotion.ui.all'|trans }} 4 | {% else %} 5 |
    6 | {% for item in data %} 7 |
  • {{ item }}
  • 8 | {% endfor %} 9 |
10 | {% endif %} 11 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/discount.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data float #} 2 | {{ data|sylius_percentage }} 3 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/ends_at.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data \DateTimeInterface|null #} 2 | {% if data %} 3 | {{ data|date('Y-m-d H:i:s') }} 4 | {% else %} 5 | {{ 'setono_sylius_catalog_promotion.ui.no_end_date'|trans }} 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/error.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data string|null #} 2 | {% if data %} 3 | {% set truncated = data|u.truncate(20, '...', false) %} 4 | {% if data == truncated %} 5 | {{ data }} 6 | {% else %} 7 | {{ truncated }} 8 | {% endif %} 9 | {% else %} 10 | {{ 'setono_sylius_catalog_promotion.ui.no_error'|trans }} 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/product_list.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data string[] #} 2 | {% if data is empty %} 3 | {{ 'setono_sylius_catalog_promotion.ui.all'|trans }} 4 | {% else %} 5 |
    6 | {% for item in data %} 7 |
  • {{ item }}
  • 8 | {% endfor %} 9 |
10 | {% endif %} 11 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/products_updated.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data \Setono\SyliusCatalogPromotionPlugin\Model\CatalogPromotionUpdateInterface #} 2 | {{ data.productsUpdated }} / {{ data.estimatedNumberOfProductsToUpdate }} 3 | {% if data.productsUpdated < data.estimatedNumberOfProductsToUpdate and data.state == constant('Setono\\SyliusCatalogPromotionPlugin\\Model\\CatalogPromotionUpdateInterface::STATE_COMPLETED') %} 4 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /src/Resources/views/admin/grid/field/starts_at.html.twig: -------------------------------------------------------------------------------- 1 | {# @var data \DateTimeInterface|null #} 2 | {% if data %} 3 | {{ data|date('Y-m-d H:i:s') }} 4 | {% else %} 5 | {{ 'setono_sylius_catalog_promotion.ui.no_start_date'|trans }} 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /src/SetonoSyliusCatalogPromotionPlugin.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new RegisterRulesAndRuleCheckersPass()); 33 | $container->addCompilerPass(new OverrideProductVariantPricesCalculatorPass()); 34 | $container->addCompilerPass(new CompositeCompilerPass(CompositePreQualificationChecker::class, 'setono_sylius_catalog_promotion.pre_qualification_checker')); 35 | $container->addCompilerPass(new CompositeCompilerPass(CompositeRuntimeChecker::class, 'setono_sylius_catalog_promotion.runtime_checker')); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Validator/Constraints/CatalogPromotionDateRange.php: -------------------------------------------------------------------------------- 1 | getStartsAt(); 34 | $endsAt = $value->getEndsAt(); 35 | 36 | if (null === $startsAt || null === $endsAt) { 37 | return; 38 | } 39 | 40 | if ($startsAt > $endsAt) { 41 | $this->context 42 | ->buildViolation($constraint->message) 43 | ->atPath('endsAt') 44 | ->addViolation() 45 | ; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Workflow/CatalogPromotionUpdateWorkflow.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public static function getStates(): array 30 | { 31 | return [ 32 | CatalogPromotionUpdateInterface::STATE_PENDING, 33 | CatalogPromotionUpdateInterface::STATE_PROCESSING, 34 | CatalogPromotionUpdateInterface::STATE_COMPLETED, 35 | CatalogPromotionUpdateInterface::STATE_FAILED, 36 | ]; 37 | } 38 | 39 | public static function getConfig(): array 40 | { 41 | $transitions = []; 42 | foreach (self::getTransitions() as $transition) { 43 | $transitions[$transition->getName()] = [ 44 | 'from' => $transition->getFroms(), 45 | 'to' => $transition->getTos(), 46 | ]; 47 | } 48 | 49 | return [ 50 | self::NAME => [ 51 | 'type' => 'state_machine', 52 | 'marking_store' => [ 53 | 'type' => 'method', 54 | 'property' => self::PROPERTY_NAME, 55 | ], 56 | 'supports' => CatalogPromotionUpdateInterface::class, 57 | 'initial_marking' => CatalogPromotionUpdateInterface::STATE_PENDING, 58 | 'places' => self::getStates(), 59 | 'transitions' => $transitions, 60 | ], 61 | ]; 62 | } 63 | 64 | /** 65 | * @return non-empty-list 66 | */ 67 | public static function getTransitions(): array 68 | { 69 | return [ 70 | new Transition( 71 | self::TRANSITION_PROCESS, 72 | CatalogPromotionUpdateInterface::STATE_PENDING, 73 | CatalogPromotionUpdateInterface::STATE_PROCESSING, 74 | ), 75 | new Transition( 76 | self::TRANSITION_COMPLETE, 77 | CatalogPromotionUpdateInterface::STATE_PROCESSING, 78 | CatalogPromotionUpdateInterface::STATE_COMPLETED, 79 | ), 80 | new Transition( 81 | self::TRANSITION_FAIL, 82 | [CatalogPromotionUpdateInterface::STATE_PENDING, CatalogPromotionUpdateInterface::STATE_PROCESSING], 83 | CatalogPromotionUpdateInterface::STATE_FAILED, 84 | ), 85 | ]; 86 | } 87 | } 88 | --------------------------------------------------------------------------------