diff --git a/modules/promotion/commerce_promotion.module b/modules/promotion/commerce_promotion.module index 9395bab4..f0f279f1 100644 --- a/modules/promotion/commerce_promotion.module +++ b/modules/promotion/commerce_promotion.module @@ -10,6 +10,20 @@ use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element; +/** + * Implements hook_commerce_condition_info_alter(). + */ +function commerce_promotion_commerce_condition_info_alter(&$definitions) { + foreach ($definitions as &$definition) { + // Force all order item conditions to have the same category. + // This prevents them from accidentally showing in vertical tabs + // in the promotion offer UI. + if ($definition['entity_type'] == 'commerce_order_item') { + $definition['category'] = t('Products'); + } + } +} + /** * Implements hook_theme(). */ diff --git a/modules/promotion/commerce_promotion.post_update.php b/modules/promotion/commerce_promotion.post_update.php index 7042d7d6..77cca048 100644 --- a/modules/promotion/commerce_promotion.post_update.php +++ b/modules/promotion/commerce_promotion.post_update.php @@ -233,3 +233,124 @@ function commerce_promotion_post_update_8(&$sandbox = NULL) { $sandbox['#finished'] = ($sandbox['total_count'] - $sandbox['current_count']) / $sandbox['total_count']; } } + +/** + * Update offers and conditions. + */ +function commerce_promotion_post_update_9(&$sandbox = NULL) { + $promotion_storage = \Drupal::entityTypeManager()->getStorage('commerce_promotion'); + if (!isset($sandbox['current_count'])) { + $query = $promotion_storage->getQuery(); + $sandbox['total_count'] = $query->count()->execute(); + $sandbox['current_count'] = 0; + + if (empty($sandbox['total_count'])) { + $sandbox['#finished'] = 1; + return; + } + } + + $query = $promotion_storage->getQuery(); + $query->range($sandbox['current_count'], 25); + $result = $query->execute(); + if (empty($result)) { + $sandbox['#finished'] = 1; + return; + } + + $disabled = []; + /** @var \Drupal\commerce_promotion\Entity\PromotionInterface[] $promotions */ + $promotions = $promotion_storage->loadMultiple($result); + foreach ($promotions as $promotion) { + $needs_save = FALSE; + $needs_disable = FALSE; + + $conditions = $promotion->getConditions(); + $order_item_conditions = array_filter($conditions, function ($condition) { + /** @var \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface $condition */ + return $condition->getEntityTypeId() == 'commerce_order_item' && $condition->getPluginId() != 'order_item_quantity'; + }); + $condition_map = [ + 'order_item_product' => 'order_product', + 'order_item_product_type' => 'order_product_type', + 'order_item_variation_type' => 'order_variation_type', + ]; + $condition_items = $promotion->get('conditions')->getValue(); + + $known_order_item_offers = [ + 'order_item_fixed_amount_off', + 'order_item_percentage_off', + ]; + $offer = $promotion->getOffer(); + $offer_item = $promotion->get('offer')->first()->getValue(); + + // Transfer order item conditions to the offer. + if ($offer->getEntityTypeId() == 'commerce_order_item') { + // Modify the offer item directly to be able to upgrade offers that + // haven't yet been converted to extend OfferItemPromotionOfferBase. + $offer_item['target_plugin_configuration']['conditions'] = []; + foreach ($order_item_conditions as $condition) { + $offer_item['target_plugin_configuration']['conditions'][] = [ + 'plugin' => $condition->getPluginId(), + 'configuration' => $condition->getConfiguration(), + ]; + } + $needs_save = TRUE; + } + + // The promotion is using a custom offer, disable it so that it can + // get updated. + if (!in_array($offer->getPluginId(), $known_order_item_offers)) { + $needs_disable = TRUE; + } + + // Convert known order item conditions to order conditions. + if ($order_item_conditions) { + foreach ($condition_items as $condition_item) { + if (array_key_exists($condition_item['target_plugin_id'], $condition_map)) { + $condition_item['target_plugin_id'] = $condition_map[$condition_item['target_plugin_id']]; + $needs_save = TRUE; + } + } + } + + // Drop unknown order item conditions. + $conditions = $promotion->getConditions(); + $order_item_conditions = array_filter($conditions, function ($condition) { + /** @var \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface $condition */ + return $condition->getEntityTypeId() == 'commerce_order_item' && $condition->getPluginId() != 'order_item_quantity'; + }); + foreach ($order_item_conditions as $condition) { + foreach ($condition_items as $index => $condition_item) { + if ($condition_item['target_plugin_id'] == $condition->getPluginId()) { + unset($condition_items[$index]); + $needs_save = TRUE; + // An unrecognized offer was dropped, but because the offer applies + // to the order, wasn't transferred there. Disable the promotion + // to allow the merchant to double check the new configuration. + if ($offer->getEntityTypeId() == 'commerce_order') { + $needs_disable = TRUE; + } + } + } + } + + if ($needs_disable) { + $disabled[] = $promotion->label(); + $promotion->setEnabled(FALSE); + } + if ($needs_save) { + $promotion->set('offer', $offer_item); + $promotion->set('conditions', array_values($condition_items)); + $promotion->save(); + } + } + + $sandbox['current_count'] += 25; + if ($sandbox['current_count'] >= $sandbox['total_count']) { + $sandbox['#finished'] = 1; + } + else { + $sandbox['#finished'] = ($sandbox['total_count'] - $sandbox['current_count']) / $sandbox['total_count']; + } +} diff --git a/modules/promotion/src/Entity/Promotion.php b/modules/promotion/src/Entity/Promotion.php index 12944da2..4f8118eb 100644 --- a/modules/promotion/src/Entity/Promotion.php +++ b/modules/promotion/src/Entity/Promotion.php @@ -6,6 +6,7 @@ use Drupal\commerce\ConditionGroup; use Drupal\commerce\Entity\CommerceContentEntityBase; use Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface; use Drupal\commerce_order\Entity\OrderInterface; +use Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\OrderItemPromotionOfferInterface; use Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityStorageInterface; @@ -456,43 +457,15 @@ class Promotion extends CommerceContentEntityBase implements PromotionInterface // Promotions without conditions always apply. return TRUE; } - $order_conditions = array_filter($conditions, function ($condition) { + // Filter the conditions just in case there are leftover order item + // conditions (which have been moved to offer conditions). + $conditions = array_filter($conditions, function ($condition) { /** @var \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface $condition */ return $condition->getEntityTypeId() == 'commerce_order'; }); - $order_item_conditions = array_filter($conditions, function ($condition) { - /** @var \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface $condition */ - return $condition->getEntityTypeId() == 'commerce_order_item'; - }); - $condition_operator = $this->getConditionOperator(); - $order_conditions = new ConditionGroup($order_conditions, $condition_operator); - $order_item_conditions = new ConditionGroup($order_item_conditions, $condition_operator); - - $order_conditions_apply = $order_conditions->evaluate($order); - if ($condition_operator == 'AND' && !$order_conditions_apply) { - return FALSE; - } - - $order_item_conditions_apply = FALSE; - foreach ($order->getItems() as $order_item) { - // Order item conditions must match at least one order item. - if ($order_item_conditions->evaluate($order_item)) { - $order_item_conditions_apply = TRUE; - break; - } - } - - if ($condition_operator == 'AND') { - return $order_conditions_apply && $order_item_conditions_apply; - } - elseif ($condition_operator == 'OR') { - // Empty condition groups are TRUE by default, which leads to incorrect - // logic with ORed groups due to false positives. - $order_conditions_apply = $order_conditions->getConditions() && $order_conditions_apply; - $order_item_conditions_apply = $order_item_conditions->getConditions() && $order_item_conditions_apply; + $condition_group = new ConditionGroup($conditions, $this->getConditionOperator()); - return $order_conditions_apply || $order_item_conditions_apply; - } + return $condition_group->evaluate($order); } /** @@ -500,22 +473,18 @@ class Promotion extends CommerceContentEntityBase implements PromotionInterface */ public function apply(OrderInterface $order) { $offer = $this->getOffer(); - if ($offer->getEntityTypeId() == 'commerce_order') { - $offer->apply($order, $this); - } - elseif ($offer->getEntityTypeId() == 'commerce_order_item') { - $order_item_conditions = array_filter($this->getConditions(), function ($condition) { - /** @var \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface $condition */ - return $condition->getEntityTypeId() == 'commerce_order_item'; - }); - $order_item_conditions = new ConditionGroup($order_item_conditions, $this->getConditionOperator()); + if ($offer instanceof OrderItemPromotionOfferInterface) { + $offer_conditions = new ConditionGroup($offer->getConditions(), 'OR'); // Apply the offer to order items that pass the conditions. foreach ($order->getItems() as $order_item) { - if ($order_item_conditions->evaluate($order_item)) { + if ($offer_conditions->evaluate($order_item)) { $offer->apply($order_item, $this); } } } + else { + $offer->apply($order, $this); + } } /** @@ -634,7 +603,7 @@ class Promotion extends CommerceContentEntityBase implements PromotionInterface 'type' => 'commerce_conditions', 'weight' => 3, 'settings' => [ - 'entity_types' => ['commerce_order', 'commerce_order_item'], + 'entity_types' => ['commerce_order'], ], ]); diff --git a/modules/promotion/src/EventSubscriber/FilterConditionsEventSubscriber.php b/modules/promotion/src/EventSubscriber/FilterConditionsEventSubscriber.php index 55007c45..9f31345d 100644 --- a/modules/promotion/src/EventSubscriber/FilterConditionsEventSubscriber.php +++ b/modules/promotion/src/EventSubscriber/FilterConditionsEventSubscriber.php @@ -31,10 +31,6 @@ class FilterConditionsEventSubscriber implements EventSubscriberInterface { $definitions = $event->getDefinitions(); unset($definitions['order_store']); unset($definitions['order_type']); - // Remove until #2980700 lands. - unset($definitions['order_product']); - unset($definitions['order_product_type']); - unset($definitions['order_variation_type']); $event->setDefinitions($definitions); } } diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferBase.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferBase.php index a8cd4367..92544b97 100644 --- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferBase.php +++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferBase.php @@ -2,9 +2,76 @@ namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer; +use Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface; +use Drupal\Core\Form\FormStateInterface; + /** * Provides the base class for order item offers. */ abstract class OrderItemPromotionOfferBase extends PromotionOfferBase implements OrderItemPromotionOfferInterface { + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'conditions' => [], + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['conditions'] = [ + '#type' => 'commerce_conditions', + '#title' => $this->t('Applies to'), + '#parent_entity_type' => 'commerce_promotion', + '#entity_types' => ['commerce_order_item'], + '#default_value' => $this->configuration['conditions'], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['conditions'] = $values['conditions']; + } + } + + /** + * {@inheritdoc} + */ + public function getConditions() { + $plugin_manager = \Drupal::service('plugin.manager.commerce_condition'); + $conditions = []; + foreach ($this->configuration['conditions'] as $condition) { + $conditions[] = $plugin_manager->createInstance($condition['plugin'], $condition['configuration']); + } + return $conditions; + } + + /** + * {@inheritdoc} + */ + public function setConditions(array $conditions) { + $this->configuration['conditions'] = []; + foreach ($conditions as $condition) { + if ($condition instanceof ConditionInterface) { + $this->configuration['conditions'][] = [ + 'plugin' => $condition->getPluginId(), + 'configuration' => $condition->getConfiguration(), + ]; + } + } + return $this; + } + } diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferInterface.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferInterface.php index ec9721c2..9f16223b 100644 --- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferInterface.php +++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderItemPromotionOfferInterface.php @@ -4,7 +4,28 @@ namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer; /** * Defines the interface for order item offers. + * + * Order item offers have conditions, which are used to determine which + * order items should be passed to the offer. */ interface OrderItemPromotionOfferInterface extends PromotionOfferInterface { + /** + * Gets the conditions. + * + * @return \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface[] + * The conditions. + */ + public function getConditions(); + + /** + * Sets the conditions. + * + * @param \Drupal\commerce\Plugin\Commerce\Condition\ConditionInterface[] $conditions + * The conditions. + * + * @return $this + */ + public function setConditions(array $conditions); + } diff --git a/modules/promotion/tests/src/Kernel/PromotionConditionTest.php b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php index ace7963e..aa848a8c 100644 --- a/modules/promotion/tests/src/Kernel/PromotionConditionTest.php +++ b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php @@ -5,7 +5,9 @@ namespace Drupal\Tests\commerce_promotion\Kernel; use Drupal\commerce_order\Entity\OrderItem; use Drupal\commerce_order\Entity\OrderItemType; use Drupal\commerce_order\Entity\Order; -use Drupal\commerce_price\Price; +use Drupal\commerce_product\Entity\Product; +use Drupal\commerce_product\Entity\ProductType; +use Drupal\commerce_product\Entity\ProductVariation; use Drupal\commerce_promotion\Entity\Promotion; use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase; @@ -65,6 +67,8 @@ class PromotionConditionTest extends CommerceKernelTestBase { 'orderType' => 'default', ])->save(); + $user = $this->createUser(); + $this->order = Order::create([ 'type' => 'default', 'state' => 'completed', @@ -72,14 +76,15 @@ class PromotionConditionTest extends CommerceKernelTestBase { 'ip_address' => '127.0.0.1', 'order_number' => '6', 'store_id' => $this->store, + 'uid' => $user->id(), 'order_items' => [], ]); } /** - * Tests promotions with an order condition. + * Tests promotion conditions. */ - public function testOrderCondition() { + public function testPromotionConditions() { // Starts now, enabled. No end time. Matches orders under $20 or over $100. $promotion = Promotion::create([ 'name' => 'Promotion 1', @@ -150,9 +155,9 @@ class PromotionConditionTest extends CommerceKernelTestBase { } /** - * Tests promotions with both order and order item conditions. + * Tests offer conditions. */ - public function testMixedCondition() { + public function testOfferConditions() { // Starts now, enabled. No end time. $promotion = Promotion::create([ 'name' => 'Promotion 1', @@ -160,8 +165,16 @@ class PromotionConditionTest extends CommerceKernelTestBase { 'stores' => [$this->store->id()], 'status' => TRUE, 'offer' => [ - 'target_plugin_id' => 'order_percentage_off', + 'target_plugin_id' => 'order_item_percentage_off', 'target_plugin_configuration' => [ + 'conditions' => [ + [ + 'plugin' => 'order_item_product_type', + 'configuration' => [ + 'product_types' => ['default'], + ], + ], + ], 'percentage' => '0.10', ], ], @@ -176,78 +189,69 @@ class PromotionConditionTest extends CommerceKernelTestBase { ], ], ], - [ - 'target_plugin_id' => 'order_item_quantity', - 'target_plugin_configuration' => [ - 'operator' => '>', - 'quantity' => 1, - ], - ], ], 'condition_operator' => 'AND', ]); $promotion->save(); - $order_item = OrderItem::create([ - 'type' => 'test', - 'quantity' => 4, - 'unit_price' => [ - 'number' => '10.00', + $product_type = ProductType::create([ + 'id' => 'test', + 'label' => 'Test', + 'variationType' => 'default', + ]); + $product_type->save(); + commerce_product_add_stores_field($product_type); + commerce_product_add_variations_field($product_type); + + $first_variation = ProductVariation::create([ + 'type' => 'default', + 'sku' => $this->randomMachineName(), + 'price' => [ + 'number' => '20', 'currency_code' => 'USD', ], ]); - $order_item->save(); - - // AND: Both conditions apply. - $this->order->addItem($order_item); - $this->order->save(); - $result = $promotion->applies($this->order); - $this->assertTrue($result); - - // OR: Both conditions apply. - $promotion->setConditionOperator('OR'); - $result = $promotion->applies($this->order); - $this->assertTrue($result); - - // AND: Neither condition applies. - $order_item->setQuantity(1); - $order_item->save(); - $this->order->save(); - $promotion->setConditionOperator('AND'); - $result = $promotion->applies($this->order); - $this->assertFalse($result); - - // OR: Neither condition applies. - $promotion->setConditionOperator('OR'); - $result = $promotion->applies($this->order); - $this->assertFalse($result); - - // AND: Order condition fails, order item condition passes. - $order_item->setQuantity(2); - $order_item->save(); - $this->order->save(); - $promotion->setConditionOperator('AND'); - $result = $promotion->applies($this->order); - $this->assertFalse($result); + $first_variation->save(); + $second_variation = ProductVariation::create([ + 'type' => 'default', + 'sku' => $this->randomMachineName(), + 'price' => [ + 'number' => '30', + 'currency_code' => 'USD', + ], + ]); + $second_variation->save(); - // OR: Order condition fails, order item condition passes. - $promotion->setConditionOperator('OR'); - $result = $promotion->applies($this->order); - $this->assertTrue($result); + $first_product = Product::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => [$this->store], + 'variations' => [$first_variation], + ]); + $first_product->save(); + $second_product = Product::create([ + 'type' => 'test', + 'title' => $this->randomMachineName(), + 'stores' => [$this->store], + 'variations' => [$second_variation], + ]); + $second_product->save(); - // AND: Order condition passes, order item condition fails. - $order_item->setUnitPrice(new Price('40', 'USD')); - $order_item->setQuantity(1); - $order_item->save(); + /** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_storage */ + $order_item_storage = \Drupal::entityTypeManager()->getStorage('commerce_order_item'); + $first_order_item = $order_item_storage->createFromPurchasableEntity($first_variation); + $first_order_item->save(); + $second_order_item = $order_item_storage->createFromPurchasableEntity($second_variation); + $second_order_item->save(); + $this->order->setItems([$first_order_item, $second_order_item]); + $this->order->state = 'draft'; $this->order->save(); - $promotion->setConditionOperator('AND'); - $result = $promotion->applies($this->order); - $this->assertFalse($result); + $this->order = $this->reloadEntity($this->order); + $first_order_item = $this->reloadEntity($first_order_item); + $second_order_item = $this->reloadEntity($second_order_item); - // OR: Order condition passes, order item condition fails. - $promotion->setConditionOperator('OR'); - $result = $promotion->applies($this->order); - $this->assertTrue($result); + $this->assertCount(1, $first_order_item->getAdjustments()); + $this->assertCount(0, $second_order_item->getAdjustments()); } } diff --git a/src/Element/Conditions.php b/src/Element/Conditions.php index 8c838569..9b58e176 100644 --- a/src/Element/Conditions.php +++ b/src/Element/Conditions.php @@ -112,16 +112,31 @@ class Conditions extends FormElement { $element['#attached']['library'][] = 'commerce/conditions'; $element['#categories'] = []; - $element['conditions'] = [ - '#type' => 'vertical_tabs', - '#title' => $element['#title'], - ]; + + // Render vertical tabs only if there is more than a single category. + $render_vertical_tabs = count($grouped_definitions) > 1; + if ($render_vertical_tabs) { + $element['conditions'] = [ + '#type' => 'vertical_tabs', + '#title' => $element['#title'], + ]; + } + else { + $element['conditions_title'] = [ + '#type' => 'item', + '#title' => $element['#title'], + ]; + $element['conditions'] = [ + '#type' => 'container', + ]; + } + foreach ($grouped_definitions as $category => $definitions) { $category_id = preg_replace('/[^a-zA-Z\-]/', '_', strtolower($category)); $element['#categories'][] = $category_id; $element[$category_id] = [ - '#type' => 'details', + '#type' => $render_vertical_tabs ? 'details' : 'container', '#title' => $category, '#group' => $tab_group, ];