commerce-8.x-2.8/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php

modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
<?php

namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;

use Drupal\commerce\ConditionGroup;
use Drupal\commerce\ConditionManagerInterface;
use Drupal\commerce_order\Adjustment;
use Drupal\commerce_order\Entity\OrderItemInterface;
use Drupal\commerce_order\PriceSplitterInterface;
use Drupal\commerce_price\Calculator;
use Drupal\commerce_price\Price;
use Drupal\commerce_price\RounderInterface;
use Drupal\commerce_promotion\Entity\PromotionInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Provides the "Buy X Get Y" offer for orders.
 *
 * Examples:
 * - "Buy 1 t-shirt, get 1 hat for $10 less"
 * - "Buy 3 t-shirts, get 2 t-shirts free (100% off)"
 *
 * The cheapest items are always discounted first. The offer applies multiple
 * times ("Buy 3 Get 1" will discount 2 items when 6 are bought).
 *
 * Decimal quantities are supported.
 *
 * @CommercePromotionOffer(
 *   id = "order_buy_x_get_y",
 *   label = @Translation("Buy X Get Y"),
 *   entity_type = "commerce_order",
 * )
 */
class BuyXGetY extends OrderPromotionOfferBase {

  /**
   * The condition manager.
   *
   * @var \Drupal\commerce\ConditionManagerInterface
   */
  protected $conditionManager;

  /**
   * Constructs a new BuyXGetY object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The pluginId for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\commerce_price\RounderInterface $rounder
   *   The rounder.
   * @param \Drupal\commerce_order\PriceSplitterInterface $splitter
   *   The splitter.
   * @param \Drupal\commerce\ConditionManagerInterface $condition_manager
   *   The condition manager.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, RounderInterface $rounder, PriceSplitterInterface $splitter, ConditionManagerInterface $condition_manager) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $rounder, $splitter);

    $this->conditionManager = $condition_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('commerce_price.rounder'),
      $container->get('commerce_order.price_splitter'),
      $container->get('plugin.manager.commerce_condition')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
      'buy_quantity' => 1,
      'buy_conditions' => [],
      'get_quantity' => 1,
      'get_conditions' => [],
      'offer_type' => 'percentage',
      'offer_percentage' => '0',
      'offer_amount' => NULL,
    ] + parent::defaultConfiguration();
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form += parent::buildConfigurationForm($form, $form_state);
    // Remove the main fieldset.
    $form['#type'] = 'container';

    $form['buy'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Customer buys'),
      '#collapsible' => FALSE,
    ];
    $form['buy']['quantity'] = [
      '#type' => 'commerce_number',
      '#title' => $this->t('Quantity'),
      '#default_value' => $this->configuration['buy_quantity'],
    ];
    $form['buy']['conditions'] = [
      '#type' => 'commerce_conditions',
      '#title' => $this->t('Matching any of the following'),
      '#parent_entity_type' => 'commerce_promotion',
      '#entity_types' => ['commerce_order_item'],
      '#default_value' => $this->configuration['buy_conditions'],
    ];

    $form['get'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Customer gets'),
      '#collapsible' => FALSE,
    ];
    $form['get']['quantity'] = [
      '#type' => 'commerce_number',
      '#title' => $this->t('Quantity'),
      '#default_value' => $this->configuration['get_quantity'],
    ];
    $form['get']['conditions'] = [
      '#type' => 'commerce_conditions',
      '#title' => $this->t('Matching any of the following'),
      '#parent_entity_type' => 'commerce_promotion',
      '#entity_types' => ['commerce_order_item'],
      '#default_value' => $this->configuration['get_conditions'],
    ];

    $parents = array_merge($form['#parents'], ['offer', 'type']);
    $selected_offer_type = NestedArray::getValue($form_state->getUserInput(), $parents);
    $selected_offer_type = $selected_offer_type ?: $this->configuration['offer_type'];
    $offer_wrapper = Html::getUniqueId('buy-x-get-y-offer-wrapper');
    $form['offer'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('At a discounted value'),
      '#collapsible' => FALSE,
      '#prefix' => '<div id="' . $offer_wrapper . '">',
      '#suffix' => '</div>',
    ];
    $form['offer']['type'] = [
      '#type' => 'radios',
      '#title' => $this->t('Discounted by a'),
      '#title_display' => 'invisible',
      '#options' => [
        'percentage' => $this->t('Percentage'),
        'fixed_amount' => $this->t('Fixed amount'),
      ],
      '#default_value' => $selected_offer_type,
      '#ajax' => [
        'callback' => [get_called_class(), 'ajaxRefresh'],
        'wrapper' => $offer_wrapper,
      ],
    ];
    if ($selected_offer_type == 'percentage') {
      $form['offer']['percentage'] = [
        '#type' => 'commerce_number',
        '#title' => $this->t('Percentage off'),
        '#default_value' => Calculator::multiply($this->configuration['offer_percentage'], '100'),
        '#maxlength' => 255,
        '#min' => 0,
        '#max' => 100,
        '#size' => 4,
        '#field_suffix' => $this->t('%'),
        '#required' => TRUE,
      ];
    }
    else {
      $form['offer']['amount'] = [
        '#type' => 'commerce_price',
        '#title' => $this->t('Amount off'),
        '#default_value' => $this->configuration['offer_amount'],
        '#required' => TRUE,
      ];
    }

    return $form;
  }

  /**
   * Ajax callback.
   */
  public static function ajaxRefresh(array $form, FormStateInterface $form_state) {
    $parents = $form_state->getTriggeringElement()['#array_parents'];
    $parents = array_slice($parents, 0, -2);
    return NestedArray::getValue($form, $parents);
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
    parent::validateConfigurationForm($form, $form_state);

    $values = $form_state->getValue($form['#parents']);
    if ($values['offer']['type'] == 'percentage' && empty($values['offer']['percentage'])) {
      $form_state->setError($form, $this->t('Percentage must be a positive number.'));
    }
  }

  /**
   * {@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['buy_quantity'] = $values['buy']['quantity'];
      $this->configuration['buy_conditions'] = $values['buy']['conditions'];
      $this->configuration['get_quantity'] = $values['get']['quantity'];
      $this->configuration['get_conditions'] = $values['get']['conditions'];
      $this->configuration['offer_type'] = $values['offer']['type'];
      if ($this->configuration['offer_type'] == 'percentage') {
        $this->configuration['offer_percentage'] = Calculator::divide((string) $values['offer']['percentage'], '100');
        $this->configuration['offer_amount'] = NULL;
      }
      else {
        $this->configuration['offer_percentage'] = NULL;
        $this->configuration['offer_amount'] = $values['offer']['amount'];
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function apply(EntityInterface $entity, PromotionInterface $promotion) {
    $this->assertEntity($entity);
    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
    $order = $entity;
    $order_items = $order->getItems();

    $buy_conditions = $this->buildConditionGroup($this->configuration['buy_conditions']);
    $buy_order_items = $this->selectOrderItems($order_items, $buy_conditions, 'DESC');
    $buy_quantities = array_map(function (OrderItemInterface $order_item) {
      return $order_item->getQuantity();
    }, $buy_order_items);
    if (array_sum($buy_quantities) < $this->configuration['buy_quantity']) {
      return;
    }

    $get_conditions = $this->buildConditionGroup($this->configuration['get_conditions']);
    $get_order_items = $this->selectOrderItems($order_items, $get_conditions, 'ASC');
    $get_quantities = array_map(function (OrderItemInterface $order_item) {
      return $order_item->getQuantity();
    }, $get_order_items);
    if (empty($get_quantities)) {
      return;
    }

    // It is possible for $buy_quantities and $get_quantities to overlap (have
    // the same order item IDs). For example, in a "Buy 3 Get 1" scenario with
    // a single T-shirt order item of quantity: 8, there are 6 bought and 2
    // discounted products, in this order: 3, 1, 3, 1. To ensure the specified
    // results, $buy_quantities must be processed group by group, with any
    // overlaps immediately removed from the $get_quantities (and vice-versa).
    $final_quantities = [];
    while (!empty($buy_quantities)) {
      $selected_buy_quantities = $this->sliceQuantities($buy_quantities, $this->configuration['buy_quantity']);
      if (array_sum($selected_buy_quantities) < $this->configuration['buy_quantity']) {
        break;
      }
      $get_quantities = $this->removeQuantities($get_quantities, $selected_buy_quantities);
      $selected_get_quantities = $this->sliceQuantities($get_quantities, $this->configuration['get_quantity']);
      $buy_quantities = $this->removeQuantities($buy_quantities, $selected_get_quantities);
      // Merge the selected get quantities into a final list, to ensure that
      // each order item only gets a single adjustment.
      $final_quantities = $this->mergeQuantities($final_quantities, $selected_get_quantities);
    }

    foreach ($final_quantities as $order_item_id => $quantity) {
      $order_item = $get_order_items[$order_item_id];
      $adjustment_amount = $this->buildAdjustmentAmount($order_item, $quantity);

      $order_item->addAdjustment(new Adjustment([
        'type' => 'promotion',
        // @todo Change to label from UI when added in #2770731.
        'label' => t('Discount'),
        'amount' => $adjustment_amount->multiply('-1'),
        'source_id' => $promotion->id(),
      ]));
    }
  }

  /**
   * Builds a condition group for the given condition configuration.
   *
   * @param array $condition_configuration
   *   The condition configuration.
   *
   * @return \Drupal\commerce\ConditionGroup
   *   The condition group.
   */
  protected function buildConditionGroup(array $condition_configuration) {
    $conditions = [];
    foreach ($condition_configuration as $condition) {
      if (!empty($condition['plugin'])) {
        $conditions[] = $this->conditionManager->createInstance($condition['plugin'], $condition['configuration']);
      }
    }

    return new ConditionGroup($conditions, 'OR');
  }

  /**
   * Selects non-free order items that match the given conditions.
   *
   * Selected order items are sorted by unit price in the specified direction.
   *
   * @param \Drupal\commerce_order\Entity\OrderItemInterface[] $order_items
   *   The order items.
   * @param \Drupal\commerce\ConditionGroup $conditions
   *   The conditions.
   * @param string $sort_direction
   *   The sort direction.
   *   Use 'ASC' for least expensive first, 'DESC' for most expensive first.
   *
   * @return \Drupal\commerce_order\Entity\OrderItemInterface[]
   *   The selected order items, keyed by order item ID.
   */
  protected function selectOrderItems(array $order_items, ConditionGroup $conditions, $sort_direction = 'ASC') {
    $selected_order_items = [];
    foreach ($order_items as $index => $order_item) {
      if ($order_item->getAdjustedTotalPrice()->isZero()) {
        continue;
      }
      if (!$conditions->evaluate($order_item)) {
        continue;
      }
      $selected_order_items[$order_item->id()] = $order_item;
    }
    uasort($selected_order_items, function (OrderItemInterface $a, OrderItemInterface $b) use ($sort_direction) {
      if ($sort_direction == 'ASC') {
        $result = $a->getUnitPrice()->compareTo($b->getUnitPrice());
      }
      else {
        $result = $b->getUnitPrice()->compareTo($a->getUnitPrice());
      }
      // PHP5 workaround, maintain existing sort order when items are equal.
      if ($result === 0) {
        $result = ($a->id() < $b->id()) ? -1 : 1;
      }

      return $result;
    });

    return $selected_order_items;
  }

  /**
   * Takes a slice from the given quantity list.
   *
   * For example, ['1' => '10', '2' => '5'] sliced for total quantity '11'
   * will produce a ['1' => '10', '2' => '1'] slice, leaving ['2' => '4']
   * in the original list.
   *
   * @param array $quantities
   *   The quantity list. Modified by reference.
   * @param string $total_quantity
   *   The total quantity of the new slice.
   *
   * @return array
   *   The quantity list slice.
   */
  protected function sliceQuantities(array &$quantities, $total_quantity) {
    $remaining_quantity = $total_quantity;
    $slice = [];
    foreach ($quantities as $order_item_id => $quantity) {
      if ($quantity <= $remaining_quantity) {
        $remaining_quantity = Calculator::subtract($remaining_quantity, $quantity);
        $slice[$order_item_id] = $quantity;
        unset($quantities[$order_item_id]);
        if ($remaining_quantity === '0') {
          break;
        }
      }
      else {
        $slice[$order_item_id] = $remaining_quantity;
        $quantities[$order_item_id] = Calculator::subtract($quantity, $remaining_quantity);
        break;
      }
    }

    return $slice;
  }

  /**
   * Removes the second quantity list from the first quantity list.
   *
   * For example: ['1' => '10', '2' => '20'] - ['1' => '10', '2' => '17']
   * will result in ['2' => '3'].
   *
   * @param array $first_quantities
   *   The first quantity list.
   * @param array $second_quantities
   *   The second quantity list.
   *
   * @return array
   *   The new quantity list.
   */
  protected function removeQuantities(array $first_quantities, array $second_quantities) {
    foreach ($second_quantities as $order_item_id => $quantity) {
      if (isset($first_quantities[$order_item_id])) {
        $first_quantities[$order_item_id] = Calculator::subtract($first_quantities[$order_item_id], $second_quantities[$order_item_id]);
        if ($first_quantities[$order_item_id] <= 0) {
          unset($first_quantities[$order_item_id]);
        }
      }
    }

    return $first_quantities;
  }

  /**
   * Merges the first quantity list with the second quantity list.
   *
   * Quantities belonging to shared order item IDs will be added together.
   *
   * For example: ['1' => '10'] and ['1' => '10', '2' => '17']
   * will merge into ['1' => '20', '2' => '17'].
   *
   * @param array $first_quantities
   *   The first quantity list.
   * @param array $second_quantities
   *   The second quantity list.
   *
   * @return array
   *   The new quantity list.
   */
  protected function mergeQuantities(array $first_quantities, array $second_quantities) {
    foreach ($second_quantities as $order_item_id => $quantity) {
      if (!isset($first_quantities[$order_item_id])) {
        $first_quantities[$order_item_id] = $quantity;
      }
      else {
        $first_quantities[$order_item_id] = Calculator::add($first_quantities[$order_item_id], $second_quantities[$order_item_id]);
      }
    }

    return $first_quantities;
  }

  /**
   * Builds an adjustment amount for the given order item and quantity.
   *
   * @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
   *   The order item.
   * @param string $quantity
   *   The quantity.
   *
   * @return \Drupal\commerce_price\Price
   *   The adjustment amount.
   */
  protected function buildAdjustmentAmount(OrderItemInterface $order_item, $quantity) {
    if ($this->configuration['offer_type'] == 'percentage') {
      $percentage = (string) $this->configuration['offer_percentage'];
      $total_price = $order_item->getTotalPrice();
      if ($order_item->getQuantity() != $quantity) {
        // Calculate a new total for just the quantity that will be discounted.
        $total_price = $order_item->getUnitPrice()->multiply($quantity);
        $total_price = $this->rounder->round($total_price);
      }
      $adjustment_amount = $total_price->multiply($percentage);
    }
    else {
      $amount = $this->configuration['offer_amount'];
      $amount = new Price($amount['number'], $amount['currency_code']);
      $adjustment_amount = $amount->multiply($quantity);
    }
    $adjustment_amount = $this->rounder->round($adjustment_amount);

    return $adjustment_amount;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc