diff --git a/modules/order/src/Entity/LineItem.php b/modules/order/src/Entity/LineItem.php index 677e098d79..8c8b92636f 100644 --- a/modules/order/src/Entity/LineItem.php +++ b/modules/order/src/Entity/LineItem.php @@ -102,7 +102,7 @@ public function setTitle($title) { * {@inheritdoc} */ public function getQuantity() { - return $this->get('quantity')->value; + return (string) $this->get('quantity')->value; } /** diff --git a/modules/promotion/commerce_promotion.services.yml b/modules/promotion/commerce_promotion.services.yml index 7a2d64055a..99cae2760a 100644 --- a/modules/promotion/commerce_promotion.services.yml +++ b/modules/promotion/commerce_promotion.services.yml @@ -2,3 +2,6 @@ services: plugin.manager.commerce_promotion_offer: class: Drupal\commerce_promotion\PromotionOfferManager arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@entity_type.manager'] + plugin.manager.commerce_promotion_condition: + class: Drupal\commerce_promotion\PromotionConditionManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@entity_type.manager'] diff --git a/modules/promotion/src/Annotation/CommercePromotionCondition.php b/modules/promotion/src/Annotation/CommercePromotionCondition.php new file mode 100644 index 0000000000..ec00de283a --- /dev/null +++ b/modules/promotion/src/Annotation/CommercePromotionCondition.php @@ -0,0 +1,25 @@ +getEntityTypeId(); + // @todo should whatever invokes this method be providing the context? + $context = new Context(new ContextDefinition('entity:' . $entity_type_id), $entity); + + // Execute each plugin, this is an AND operation. + // @todo support OR operations. + /** @var \Drupal\commerce\Plugin\Field\FieldType\PluginItem $item */ + foreach ($this->get('conditions') as $item) { + /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionInterface $condition */ + $condition = $item->getTargetInstance([$entity_type_id => $context]); + if (!$condition->evaluate()) { + return FALSE; + } + } + + return TRUE; + } + /** * {@inheritdoc} */ @@ -243,6 +265,7 @@ public function apply(EntityInterface $entity) { $entity_type_id = $entity->getEntityTypeId(); // @todo should whatever invokes this method be providing the context? $context = new Context(new ContextDefinition('entity:' . $entity_type_id), $entity); + /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface $offer */ $offer = $this->get('offer')->first()->getTargetInstance([$entity_type_id => $context]); $offer->execute(); @@ -320,6 +343,15 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { 'weight' => 3, ]); + $fields['conditions'] = BaseFieldDefinition::create('commerce_plugin_item:commerce_promotion_condition') + ->setLabel(t('Conditions')) + ->setCardinality(BaseFieldDefinition::CARDINALITY_UNLIMITED) + ->setRequired(FALSE) + ->setDisplayOptions('form', [ + 'type' => 'commerce_plugin_select', + 'weight' => 3, + ]); + $fields['coupons'] = BaseFieldDefinition::create('entity_reference') ->setLabel(t('Coupons')) ->setDescription(t('Coupons which allow promotion to be redeemed.')) diff --git a/modules/promotion/src/Entity/PromotionInterface.php b/modules/promotion/src/Entity/PromotionInterface.php index 72867d98f0..0b7b171b33 100644 --- a/modules/promotion/src/Entity/PromotionInterface.php +++ b/modules/promotion/src/Entity/PromotionInterface.php @@ -214,7 +214,18 @@ public function isEnabled(); public function setEnabled($enabled); /** - * Applies the promotion to an entity. + * Checks whether the promotion entity can be applied. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return bool + * TRUE if promotion can be applied, or false if conditions failed. + */ + public function applies(EntityInterface $entity); + + /** + * Apply the promotion to an entity. * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity. diff --git a/modules/promotion/src/Plugin/Commerce/PromotionCondition/OrderTotalPrice.php b/modules/promotion/src/Plugin/Commerce/PromotionCondition/OrderTotalPrice.php new file mode 100644 index 0000000000..3715161ff4 --- /dev/null +++ b/modules/promotion/src/Plugin/Commerce/PromotionCondition/OrderTotalPrice.php @@ -0,0 +1,90 @@ + NULL, + // @todo expose the operator in form. + 'operator' => '>', + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form += parent::buildConfigurationForm($form, $form_state); + + $default_price = NULL; + if (!empty($this->configuration['amount']['amount'])) { + $default_price = new Price($this->configuration['amount']['amount'], $this->configuration['amount']['currency_code']); + } + + $form['amount'] = [ + '#type' => 'commerce_price', + '#title' => t('Amount'), + '#default_value' => $default_price, + '#required' => TRUE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function evaluate() { + /** @var \Drupal\commerce_order\Entity\OrderInterface $order */ + $order = $this->getTargetEntity(); + /** @var \Drupal\commerce_price\Price $total_price */ + $total_price = $order->getTotalPrice(); + /** @var \Drupal\commerce_price\Price $comparison_price */ + $comparison_price = $this->configuration['amount']; + + switch ($this->configuration['operator']) { + case '==': + return $total_price->equals($comparison_price); + + case '>=': + return $total_price->greaterThanOrEqual($comparison_price); + + case '>': + return $total_price->greaterThan($comparison_price); + + case '<=': + return $total_price->lessThanOrEqual($comparison_price); + + case '<': + return $total_price->lessThan($comparison_price); + + default: + throw new \InvalidArgumentException("Invalid operator {$this->configuration['operator']}"); + } + } + + /** + * {@inheritdoc} + */ + public function summary() { + return $this->t('Compares the order total amount.'); + } + +} diff --git a/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionBase.php b/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionBase.php new file mode 100644 index 0000000000..ccabb33c6d --- /dev/null +++ b/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionBase.php @@ -0,0 +1,34 @@ +pluginDefinition['target_entity_type']; + } + + /** + * {@inheritdoc} + */ + public function getTargetEntity() { + return $this->getContextValue($this->getTargetEntityType()); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $result = $this->evaluate(); + return $this->isNegated() ? !$result : $result; + } + +} diff --git a/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionInterface.php b/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionInterface.php new file mode 100644 index 0000000000..8ab145b0c9 --- /dev/null +++ b/modules/promotion/src/Plugin/Commerce/PromotionCondition/PromotionConditionInterface.php @@ -0,0 +1,28 @@ +getTargetEntity(); - $price_amount = $line_item->getTotalPrice()->multiply($this->getAmount()); + $price_amount = $line_item->getUnitPrice()->multiply($this->getAmount()); $this->applyAdjustment($line_item, $price_amount); } diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php index 639200914e..731dda0639 100644 --- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php +++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php @@ -28,7 +28,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition /** * {@inheritdoc} */ - public function appliesTo() { + public function getTargetEntityType() { return $this->pluginDefinition['target_entity_type']; } @@ -36,7 +36,7 @@ public function appliesTo() { * {@inheritdoc} */ public function getTargetEntity() { - return $this->getContextValue($this->appliesTo()); + return $this->getContextValue($this->getTargetEntityType()); } /** diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php index 68fc98c077..fecb9347c6 100644 --- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php +++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php @@ -19,12 +19,12 @@ interface PromotionOfferInterface extends ExecutableInterface, PluginFormInterfa const LINE_ITEM = 'commerce_line_item'; /** - * Returns what entity the promotion offer applies to. + * Gets the entity type the offer is for. * * @return string * The entity type it applies to. */ - public function appliesTo(); + public function getTargetEntityType(); /** * Get the target entity for the offer. diff --git a/modules/promotion/src/PromotionConditionManager.php b/modules/promotion/src/PromotionConditionManager.php new file mode 100644 index 0000000000..567bbf1f70 --- /dev/null +++ b/modules/promotion/src/PromotionConditionManager.php @@ -0,0 +1,93 @@ +alterInfo('condition_info'); + $this->setCacheBackend($cache_backend, 'condition_plugins'); + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function execute(ExecutableInterface $condition) { + /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionInterface $condition */ + $result = $condition->evaluate(); + return $condition->isNegated() ? !$result : $result; + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + foreach (['id', 'label', 'target_entity_type'] as $required_property) { + if (empty($definition[$required_property])) { + throw new PluginException(sprintf('The promotion condition %s must define the %s property.', $plugin_id, $required_property)); + } + } + + $target = $definition['target_entity_type']; + if (!$this->entityTypeManager->getDefinition($target)) { + throw new PluginException(sprintf('The promotion condition %s must reference a valid entity type, %s given.', $plugin_id, $target)); + } + + // If the plugin did not specify a category, use the target entity's label. + if (empty($definition['category'])) { + $definition['category'] = $this->entityTypeManager->getDefinition($target)->getLabel(); + } + + // Generate the context definition if it is missing. + if (empty($definition['context'][$target])) { + $definition['context'][$target] = new ContextDefinition('entity:' . $target, $definition['category']); + } + } + +} diff --git a/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php b/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php index 3ed300586b..07df50f855 100644 --- a/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php +++ b/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php @@ -35,24 +35,33 @@ protected function getAdministratorPermissions() { /** * Tests creating a promotion. + * + * @group create */ public function testCreatePromotion() { $this->createStore(NULL, NULL, 'default', TRUE); $this->drupalGet('admin/commerce/promotions'); $this->getSession()->getPage()->clickLink('Add a new promotion'); + $this->drupalGet('promotion/add'); // Check the integrity of the form. $this->assertSession()->fieldExists('name[0][value]'); $this->getSession()->getPage()->fillField('offer[0][target_plugin_id]', 'commerce_promotion_product_percentage_off'); - $this->getSession()->wait(4000); + $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0"); $name = $this->randomMachineName(8); $edit = [ 'name[0][value]' => $name, 'offer[0][target_plugin_configuration][amount]' => '10.0', ]; + + $this->getSession()->getPage()->fillField('conditions[0][target_plugin_id]', 'commerce_promotion_order_total_price'); + $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0"); + + $edit['conditions[0][target_plugin_configuration][amount][amount]'] = '50.00'; + $this->submitForm($edit, t('Save')); $this->assertSession()->pageTextContains("The promotion $name has been successfully saved."); $promotion_count = $this->getSession()->getPage()->find('xpath', '//table/tbody/tr/td[text()="' . $name . '"]'); diff --git a/modules/promotion/tests/src/Kernel/PromotionConditionTest.php b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php new file mode 100644 index 0000000000..0ae1777904 --- /dev/null +++ b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php @@ -0,0 +1,162 @@ +installEntitySchema('commerce_store'); + $this->installEntitySchema('profile'); + $this->installEntitySchema('commerce_order'); + $this->installEntitySchema('commerce_order_type'); + $this->installEntitySchema('commerce_line_item'); + $this->installEntitySchema('commerce_promotion'); + $this->installConfig([ + 'profile', + 'commerce_order', + 'commerce_store', + 'commerce_promotion', + ]); + $this->store = $this->createStore(NULL, NULL, 'default', TRUE); + + // A line item type that doesn't need a purchasable entity, for simplicity. + LineItemType::create([ + 'id' => 'test', + 'label' => 'Test', + 'orderType' => 'default', + ])->save(); + + $this->order = Order::create([ + 'type' => 'default', + 'state' => 'completed', + 'mail' => 'test@example.com', + 'ip_address' => '127.0.0.1', + 'order_number' => '6', + 'store_id' => $this->store, + 'line_items' => [], + ]); + } + + /** + * Tests the order amount condition. + */ + public function testOrderTotal() { + // Use addLineItem so the total is calculated. + $line_item = LineItem::create([ + 'type' => 'test', + 'quantity' => 2, + 'unit_price' => [ + 'amount' => '20.00', + 'currency_code' => 'USD', + ], + ]); + $line_item->save(); + $this->order->addLineItem($line_item); + + // Starts now, enabled. No end time. + $promotion = Promotion::create([ + 'name' => 'Promotion 1', + 'order_types' => [$this->order->bundle()], + 'stores' => [$this->store->id()], + 'status' => TRUE, + 'offer' => [ + 'target_plugin_id' => 'commerce_promotion_order_percentage_off', + 'target_plugin_configuration' => [ + 'amount' => '0.10', + ], + ], + 'conditions' => [ + [ + 'target_plugin_id' => 'commerce_promotion_order_total_price', + 'target_plugin_configuration' => [ + 'amount' => new Price('20.00', 'USD'), + ], + ], + ], + ]); + $promotion->save(); + + $result = $promotion->applies($this->order); + + $this->assertTrue($result); + + $promotion = Promotion::create([ + 'name' => 'Promotion 1', + 'order_types' => [$this->order->bundle()], + 'stores' => [$this->store->id()], + 'status' => TRUE, + 'offer' => [ + 'target_plugin_id' => 'commerce_promotion_order_percentage_off', + 'target_plugin_configuration' => [ + 'amount' => '0.10', + ], + ], + 'conditions' => [ + [ + 'target_plugin_id' => 'commerce_promotion_order_total_price', + 'target_plugin_configuration' => [ + 'amount' => new Price('50.00', 'USD'), + ], + ], + ], + ]); + $promotion->save(); + + $result = $promotion->applies($this->order); + + $this->assertFalse($result); + } + +} diff --git a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php index 7746dad3e4..da1288a109 100644 --- a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php +++ b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php @@ -5,6 +5,7 @@ use Drupal\commerce_order\Entity\LineItem; use Drupal\commerce_order\Entity\LineItemType; use Drupal\commerce_order\Entity\Order; +use Drupal\commerce_price\Price; use Drupal\commerce_promotion\Entity\Promotion; use Drupal\commerce_store\StoreCreationTrait; use Drupal\KernelTests\KernelTestBase; @@ -94,7 +95,7 @@ public function testOrderPercentageOff() { // Use addLineItem so the total is calculated. $line_item = LineItem::create([ 'type' => 'test', - 'quantity' => 2, + 'quantity' => '2', 'unit_price' => [ 'amount' => '20.00', 'currency_code' => 'USD', @@ -125,7 +126,7 @@ public function testOrderPercentageOff() { $promotion->apply($this->order); $this->assertEquals(1, count($this->order->getAdjustments())); - $this->assertEquals(36, $this->order->total_price->amount); + $this->assertEquals(new Price('36.00', 'USD'), $this->order->getTotalPrice()); } @@ -136,7 +137,7 @@ public function testProductPercentageOff() { // Use addLineItem so the total is calculated. $line_item = LineItem::create([ 'type' => 'test', - 'quantity' => 2, + 'quantity' => '2', 'unit_price' => [ 'amount' => '10.00', 'currency_code' => 'USD', @@ -164,8 +165,20 @@ public function testProductPercentageOff() { $this->assertEquals('0.50', $offer_field->target_plugin_configuration['amount']); $promotion->apply($line_item); + $line_item->save(); + + $adjustments = $line_item->getAdjustments(); + $this->assertEquals(1, count($adjustments)); + /** @var \Drupal\commerce_order\Adjustment $adjustment */ + $adjustment = reset($adjustments); + // Adjustment for 50% of the line item total. + $this->assertEquals(new Price('-5.00', 'USD'), $adjustment->getAmount()); + // Adjustments don't affect total line item price, but the order's total. + $this->assertEquals(new Price('20.00', 'USD'), $line_item->getTotalPrice()); - $this->assertEquals(10, $line_item->getTotalPrice()->getDecimalAmount()); + $this->order->addLineItem($line_item); + $this->assertEquals(1, count($this->order->getLineItems())); + $this->assertEquals(new Price('10.00', 'USD'), $this->order->getTotalPrice()); } } diff --git a/src/Plugin/Field/FieldType/PluginItemDeriver.php b/src/Plugin/Field/FieldType/PluginItemDeriver.php index aa7421f933..ad01646fe9 100644 --- a/src/Plugin/Field/FieldType/PluginItemDeriver.php +++ b/src/Plugin/Field/FieldType/PluginItemDeriver.php @@ -19,6 +19,7 @@ public function getDerivativeDefinitions($base_plugin_definition) { 'condition' => $this->t('Conditions'), 'action' => $this->t('Action'), 'commerce_promotion_offer' => $this->t('Promotion offer'), + 'commerce_promotion_condition' => $this->t('Promotion condition'), ]; foreach ($supported as $id => $label) {