View source
<?php
namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
use Drupal\commerce\ConditionGroup;
use Drupal\commerce\ConditionManagerInterface;
use Drupal\commerce\Context;
use Drupal\commerce\Plugin\Commerce\Condition\PurchasableEntityConditionInterface;
use Drupal\commerce\PurchasableEntityInterface;
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\Resolver\ChainPriceResolverInterface;
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\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class BuyXGetY extends OrderPromotionOfferBase {
protected $conditionManager;
protected $chainPriceResolver;
protected $entityTypeManager;
public function __construct(array $configuration, $plugin_id, $plugin_definition, RounderInterface $rounder, PriceSplitterInterface $splitter, ConditionManagerInterface $condition_manager, ChainPriceResolverInterface $chain_price_resolver, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $rounder, $splitter);
$this->conditionManager = $condition_manager;
$this->chainPriceResolver = $chain_price_resolver;
$this->entityTypeManager = $entity_type_manager;
}
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'), $container
->get('commerce_price.chain_price_resolver'), $container
->get('entity_type.manager'));
}
public function defaultConfiguration() {
return [
'buy_quantity' => 1,
'buy_conditions' => [],
'get_quantity' => 1,
'get_conditions' => [],
'get_auto_add' => FALSE,
'offer_type' => 'percentage',
'offer_percentage' => '0',
'offer_amount' => NULL,
'offer_limit' => '0',
] + parent::defaultConfiguration();
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form += parent::buildConfigurationForm($form, $form_state);
$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'],
'#min' => 1,
];
$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'],
'#min' => 1,
];
$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'],
];
$states = $this
->getAutoAddStatesVisibility();
$form['get']['auto_add_help'] = [
'#type' => 'item',
'#title' => $this
->t('Behavior'),
'#states' => [
'visible' => [
$states,
],
],
];
$form['get']['auto_add'] = [
'#type' => 'checkbox',
'#title' => $this
->t("Automatically add the offer product to the cart if it isn't in it already"),
'#description' => $this
->t('This behavior will only work when a single product variation (or a single product with only one variation) is specified.'),
'#default_value' => $this->configuration['get_auto_add'],
'#states' => [
'visible' => [
$states,
],
],
];
$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,
];
}
$form['limit'] = [
'#type' => 'fieldset',
'#title' => $this
->t('Offer limit'),
'#description' => $this
->t('The number of times this offer can apply to the same order.'),
'#collapsible' => FALSE,
];
$form['limit']['amount'] = [
'#type' => 'radios',
'#title' => $this
->t('Limited to'),
'#title_display' => 'invisible',
'#options' => [
0 => $this
->t('Unlimited'),
1 => $this
->t('Limited number of uses'),
],
'#default_value' => $this->configuration['offer_limit'] ? 1 : 0,
];
$form['limit']['offer_limit'] = [
'#type' => 'number',
'#title' => $this
->t('Number of uses'),
'#title_display' => 'invisible',
'#default_value' => $this->configuration['offer_limit'] ?: 1,
'#states' => [
'invisible' => [
':input[name="offer[0][target_plugin_configuration][order_buy_x_get_y][limit][amount]"]' => [
'value' => 0,
],
],
],
];
return $form;
}
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);
}
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.'));
}
if ($values['get']['auto_add']) {
$valid_condition_ids = array_keys($this
->getPurchasableEntityConditions());
$has_valid_condition = FALSE;
foreach ($values['get']['conditions']['products'] as $condition_id => $condition_values) {
if (in_array($condition_id, $valid_condition_ids) && (bool) $condition_values['enable']) {
$has_valid_condition = TRUE;
break;
}
}
if (!$has_valid_condition) {
$values['get']['auto_add'] = FALSE;
$form_state
->setValue($form['#parents'], $values);
return;
}
if ($values['offer']['type'] === 'fixed_amount' || $values['offer']['type'] === 'percentage' && $values['offer']['percentage'] != '100') {
$form_state
->setError($form['offer'], $this
->t('The "auto-add" offer can only be enabled for fully discounted products (i.e with a 100% discount).'));
}
}
}
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['get_auto_add'] = $values['get']['auto_add'];
$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'];
}
$this->configuration['offer_limit'] = $values['limit']['amount'] == 0 ? 0 : $values['limit']['offer_limit'];
}
}
public function apply(EntityInterface $entity, PromotionInterface $promotion) {
$this
->assertEntity($entity);
$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']);
if ($this->configuration['get_auto_add'] && ($get_purchasable_entity = $this
->findSinglePurchasableEntity($get_conditions))) {
$order_item = $this
->findOrCreateOrderItem($get_purchasable_entity, $order_items);
$expected_get_quantity = $this
->calculateExpectedGetQuantity($buy_quantities, $order_item);
if (Calculator::compare($expected_get_quantity, '0') !== 0) {
if (Calculator::compare($order_item
->getQuantity(), $expected_get_quantity) === -1) {
$order_item
->setQuantity($expected_get_quantity);
$order_item
->lock();
}
$order_item
->setData("promotion:{$promotion->id()}:auto_add_quantity", $expected_get_quantity);
$time = $order
->getCalculationDate()
->format('U');
$context = new Context($order
->getCustomer(), $order
->getStore(), $time);
$unit_price = $this->chainPriceResolver
->resolve($get_purchasable_entity, $order_item
->getQuantity(), $context);
$order_item
->setUnitPrice($unit_price);
if ($order_item
->isNew()) {
$order_item
->set('order_id', $order
->id());
$order_item
->save();
$order
->addItem($order_item);
$order_items = $order
->getItems();
}
}
}
$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;
}
foreach ($buy_quantities as $id => $quantity) {
if (isset($get_quantities[$id])) {
unset($buy_quantities[$id]);
$buy_quantities[$id] = $quantity;
}
}
$final_quantities = [];
$i = 0;
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);
$final_quantities = $this
->mergeQuantities($final_quantities, $selected_get_quantities);
if ($this->configuration['offer_limit'] == ++$i) {
break;
}
}
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',
'label' => $promotion
->getDisplayName() ?: $this
->t('Discount'),
'amount' => $adjustment_amount
->multiply('-1'),
'source_id' => $promotion
->id(),
]));
}
}
public function clear(EntityInterface $entity, PromotionInterface $promotion) {
parent::clear($entity, $promotion);
$this
->assertEntity($entity);
$order = $entity;
if ($this->configuration['get_auto_add']) {
$promotion_data_key = "promotion:{$promotion->id()}:auto_add_quantity";
$auto_add_order_items = array_filter($order
->getItems(), function (OrderItemInterface $order_item) use ($promotion_data_key) {
return $order_item
->getData($promotion_data_key);
});
foreach ($auto_add_order_items as $order_item) {
$new_quantity = Calculator::subtract($order_item
->getQuantity(), $order_item
->getData($promotion_data_key));
$order_item
->setQuantity($new_quantity);
$order_item
->unsetData($promotion_data_key);
}
}
}
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');
}
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());
}
return $result;
});
return $selected_order_items;
}
protected function findSinglePurchasableEntity(ConditionGroup $conditions) {
foreach ($conditions
->getConditions() as $condition) {
if ($condition instanceof PurchasableEntityConditionInterface) {
$purchasable_entity_ids = $condition
->getPurchasableEntityIds();
if (count($purchasable_entity_ids) === 1) {
$purchasable_entities = $condition
->getPurchasableEntities();
return reset($purchasable_entities);
}
}
}
return NULL;
}
protected function findOrCreateOrderItem(PurchasableEntityInterface $get_purchasable_entity, array $order_items) {
foreach ($order_items as $order_item) {
$purchased_entity = $order_item
->getPurchasedEntity();
if ($purchased_entity
->getEntityTypeId() == $get_purchasable_entity
->getEntityTypeId() && $purchased_entity
->id() == $get_purchasable_entity
->id()) {
return $order_item;
}
}
$storage = $this->entityTypeManager
->getStorage('commerce_order_item');
$order_item = $storage
->createFromPurchasableEntity($get_purchasable_entity, [
'quantity' => 0,
]);
return $order_item;
}
protected function calculateExpectedGetQuantity(array $buy_quantities, OrderItemInterface $order_item) {
$expected_get_quantity = '0';
if (!$order_item
->isNew()) {
if (isset($buy_quantities[$order_item
->id()])) {
$quantity = $buy_quantities[$order_item
->id()];
unset($buy_quantities[$order_item
->id()]);
$buy_quantities[$order_item
->id()] = $quantity;
}
}
$i = 0;
while (!empty($buy_quantities)) {
$this
->sliceQuantities($buy_quantities, $this->configuration['buy_quantity']);
$expected_get_quantity = Calculator::add($expected_get_quantity, $this->configuration['get_quantity']);
if ($this->configuration['offer_limit'] == ++$i) {
break;
}
if (!$order_item
->isNew()) {
$buy_quantities = $this
->removeQuantities($buy_quantities, [
$order_item
->id() => $this->configuration['get_quantity'],
]);
}
if (array_sum($buy_quantities) < $this->configuration['buy_quantity']) {
break;
}
}
return $expected_get_quantity;
}
protected function getAutoAddStatesVisibility() {
$conditions = $this
->getPurchasableEntityConditions();
$states_visibility = array_map(function ($value) {
return [
':input[name="offer[0][target_plugin_configuration][order_buy_x_get_y][get][conditions][products][' . $value . '][enable]"]' => [
'checked' => TRUE,
],
];
}, array_keys($conditions));
for ($i = 0; $i < count($conditions) - 1; $i++) {
array_splice($states_visibility, $i + 1, 0, 'or');
}
return $states_visibility;
}
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;
}
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;
}
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;
}
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) {
$total_price = $order_item
->getUnitPrice()
->multiply($quantity);
$total_price = $this->rounder
->round($total_price);
}
$adjustment_amount = $total_price
->multiply($percentage);
}
else {
$amount = Price::fromArray($this->configuration['offer_amount']);
$adjustment_amount = $amount
->multiply($quantity);
}
$adjustment_amount = $this->rounder
->round($adjustment_amount);
return $adjustment_amount;
}
protected function getPurchasableEntityConditions() {
return array_filter($this->conditionManager
->getFilteredDefinitions('commerce_promotion', [
'commerce_order_item',
]), function ($definition) {
return is_subclass_of($definition['class'], PurchasableEntityConditionInterface::class);
});
}
}