Drupal Commerce 2: Ценообразование

Ценообразование в Drupal Commerce 2 программным способом.

09.09.2017
10 комментариев
7 мин.

В этом материале я расскажу как влиять на цены товаров в Drupal Commerce 2.0 (Drupal 8). Если вы знакомы с ценообразованием в Drupal Commerce 1 (D7), то наверняка помните что цена формировалась из ценовых компонентов, а также контролировалась при помощи модуля Rules. В новой версии интеграции с Rules из коробки нет (а может есть, я Rules не ставил), зато есть крутой API, который позволяет внедряться на любых этапах работы магазина и делать что хочешь. По первому опыту работы с новым коммерцем, могу сказать, что опыт очень приятный, особенно это касается кода. Его стало писать проще, легче, он стал прозрачнее и понятнее.

Так вот, иногда нам нужно влиять на цену товара. Задач может быть просто уйма. Например, надо скинуть цену для определенной роли, если в корзине лежит другой товар, или же сбавлять цену от кол-ва товара. Например 1-10 товаров 100% стоимости, 11-20 — 95% от стоимости и т.д. Всё это работа ценообразования, и для этого есть очень простые инструменты для работы.

Для влияния на цену коммерц предоставляет Price Resolver. Это такой обьект, в него передаётся вся необходимая информация в момент расчета цены, и вы уже делаете что хотите.

Price Resolver состоит из двух частей:

  1. Непосредственно сам Price Resolver — который является очень простеньким объектом со всеми необходимыми методами для влияния на цену.
  2. Данный resolver объявляется в качестве соответствующего сервиса чтобы drupal commerce его смог найти.

Один важный момент — Price Resolver влияет на цену только внутри корзины, т.е. на цену line item, и на странице товара будет оригинальная цена из поля цены.

Далее по коду, модуль в котором пишется код — dummy

Price Resolver

Вы можете делать сколько угодно данных price resolver объектов, все они будут применяться (следуя определенным условиям). Тут вы уже сами разберетесь, когда делить на несколько, а когда все решать в одном. Все они наследуются от PriceResolverInterface и имеют публичный метод resolve, в который передается вся необходимая информация, и который возвращает новую цену. Вот так он выглядит в минимальном варианте:

Price Resolver шаблон
<?php

namespace Drupal\dummy\Resolvers;

use Drupal\commerce\Context;
use Drupal\commerce\PurchasableEntityInterface;
use Drupal\commerce_price\Resolver\PriceResolverInterface;

/**
 * {@inheritdoc}
 */
class PriceResolverExample implements PriceResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(PurchasableEntityInterface $entity, $quantity, Context $context) {
  
  }

}

В метод передаются три переменные, которые очень и очень полезные:

  • $entity: сущность ProductVariant для которой идет обработка цены. Отсюда вы можете плясать намного дальше, ::getProduct(), ::getStore() — в общем там обычная сущность, через которую вы можете получить все остальное, а соответственно, и иметь всю необходимую информацию для решения как влиять на цену.
  • $quantity: количество товара, например, пригодится как я писал выше, в случае когда надо сбрасывать цену при определенных кол-вах. Число является десятичным, например 1.00.
  • $context: это объект который имеет следующие методы:
    • getCustomer(): возвращает объект пользователя, кому принадлежит заказ.
    • getStore(): возвращает объект магазина, к которому данный заказ относится.
    • getTime(): unix timestamp в момент когда вызывался resolver.

Помимо данных аргументов, вы также никак не ограничены в использовании API самого Drupal для получения какой-либо информации.

Данный метод должен возвращать либо NULL, либо цену, которая является экземпляром объекта Price. В случае если возвращен NULL, будет вызван следующий резолвер, если такой имеется, в случае возвращения цены, эта цена и станет финальной. Цена возвращаемая данным методом должна быть ценой за единицу товара, а не за все товары разом.

Объявление Service

Созаднный(е) вами resolver нужно объявить в качестве сервиса. Объявление выглядит следующим образом:

Пример объявления Price Resolver в качестве сервиса
services:
  dummy.price_resolver_example:
    class: Drupal\dummy\Resolvers\PriceResolverExample
    arguments: ['@request_stack']
    tags:
      - { name: commerce_price.price_resolver, priority: 0 }

Как это работает я описывал в соответствующей статье, и без этого resolver просто не будет найден, поэтому останавливаться не будем. Единственное на что тут стоит обратить внимание, так это на priority.

Priority для данного сервиса, отвечает, как ни странно, за приоритет вызова. С Drupal Commerce поставляется всего один Price Resolver, стандартный, он имеет приоритет -100. Все остальные объявляются либо вами, либо сторонними модулями. Приоритеты идут от большего к меньшему. Например:

  1. Resolver1 - приоритет 100.
  2. Resolver2 - приоритет 0.
  3. DefaultResolver - приоритет -100.

Они будут вызываться в порядке Resolver1 - Resolver2 - DefaultResolver. Но вызываются они будут лишь до тех пор пока один из них не вернет цену. То есть, Resolver2 будет вызван только если Resolver1 вернул NULL, если же он вернет цену, то все последующие даже не будут вызваны.

Переходим непосредственно к примерам.

Пример №1 — скидка 5%, полный пример, умножение

На время написания своих Resolver я крайне рекомендую переключить время жизни кэша корзины с 300 секунд, до 1. Так как эти цены кэшируются на данное время, и никакой сброс кэша их не очищает (/admin/commerce/config/order-types/default/edit).

В данном примере мы пройдемся от начала до конца. Мы сделаем скидку на все варианты товары типа default (он создается самим комерцем) 5%.

Делаем Resolver. Он должен находится по пути /src/Resolvers, а назовем его PriceResolverExample.

src/Resolvers/PriceResolverExample.php
<?php

namespace Drupal\dummy\Resolvers;

use Drupal\commerce\Context;
use Drupal\commerce\PurchasableEntityInterface;
use Drupal\commerce_price\Resolver\PriceResolverInterface;

/**
 * Custom price resolver example.
 */
class PriceResolverExample implements PriceResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(PurchasableEntityInterface $entity, $quantity, Context $context) {
    if ($entity->bundle() == 'default') {
      // 5% discount for all products "default" type.
      return $entity->getPrice()->multiply('0.95');
    }
    return NULL;
  }

}

Первым делом мы получаем bundle сущности ProductVariant и сверяем, является ли он default. Затем применяем скидку 5% умножая оригинальной цены на 0.95. Таким образом, если цена товара 100 рублей, она станет 95.

$entity->getPrice() возвращает Price объект для текущего варианта товара. У данного объекта есть множество полезных методов, почти все мы рассмотрим в примерах дальше.

Осталось только зарегистрировать данный Resolver в качестве сервиса. Для этого создаем соответствующий файл и добавляем:

dummy.services.yml
services:
  dummy.price_resolver_example:
    class: Drupal\dummy\Resolvers\PriceResolverExample
    arguments: ['@request_stack']
    tags:
      - { name: commerce_price.price_resolver, priority: 0 }

Сбрасываем кэш, переходим в коризну, и пробуем!

Пример примененной скидки.

Пример №2 — добавление и вычитание определенной цены

Вы можете сделать двумя различными способами. Для этого у Price объекта есть два метода:

  • add(Price $price): добавляет переданный объект цены к текущему;
  • subtract(Price $price): вычитает переданный объект цены из текущего.

Вы можете, например, добавить отрицательную цену, это будет равносильно вычитанию и наоборот. Вот парочка примеров:

use Drupal\commerce_price\Price;
...

// Example 1, +100 RUB
$price = new Price(100, $entity->getPrice()->getCurrencyCode());
return $entity->getPrice()->add($price);

// Example 2, -100 RUB
$price = new Price(-100, $entity->getPrice()->getCurrencyCode());
return $entity->getPrice()->add($price);

// Example 3, -100 RUB in more correct way.
$price = new Price(100, $entity->getPrice()->getCurrencyCode());
return $entity->getPrice()->subtract($price);

Цена не может опуститься ниже 0. Если товар стоит 50 рублей, а вы сделали вычитание 100, то цена на товар станет 0, а не -50.

Пример №3 — деление

Деление происходит путем передачи числа в виде строки, а текущая цена будет поделена на это число.

// Example 1, base 100, result 50
return $entity->getPrice()->divide('2');

// Example 2, base 100, result 200
return $entity->getPrice()->divide('0.5');

Прочие полезные методы.

У Price есть методы не только влияющие на цену, но и другие полезные:

  • convert(): используется для конвертации из одной валюты в другую. Валюта, в которую будет выполнена конвертация, должна быть добавлена в магазине. Принимает два параметра:
    • $currency_code: код валюты в которую должна быть произведена конвертация
    • $rate: строка с обменным курсом. Будет произведено умножение на данное число. Поэтому, если вы хотите, например, конвертировать цену из рублей в доллары, то вам нужно перевести обменный курс в соответствующий множитель. Проще всего сделать так: $multiplier = (string) (1 / $exchange_rate); не забыв объявить переменную $exchange_rate с курсом доллара.
  • compareTo(): принимает объект с другой ценой и сравнивает их. Возвращает:
    • 0, если обе цены равны;
    • 1, если первая цены больше (первой ценой будет цена товара);
    • -1, если наоборот, вторая цена (переданная в аргументе) больше первой.
  • isZero(): проверяет, является ли цена нулевой, возвращает TRUE или FALSE.
  • equals(): принимает объект с ценой и сравнивает, является ли переданная цена равной текущей у товара. Вызывает внутри compareTo(). Единственное отличие, возвращает либо TRUE, либо FALSE.
  • greaterThan(): принимает объект с ценой, и возвращает TRUE, если цена товара больше переданной цены.
  • greaterThanOrEqual(): принимает объект с ценой, и возвращает TRUE, если цена товара больше или равна переданной цене.
  • lessThan(): принимает объект с ценой, и возвращает TRUE, в случае если текущая цена товара меньше переданной в аргументе.
  • lessThanOrEqual(): принимает объект с ценой, и возвращает TRUE, если переданная цена выше или равна текущей цене товара.

Эти методы облегчат написание и читаемость кода при более сложных ценообразованиях, не забывайте про них.

Прикрепленные файлы
Готовый модуль с примером — dummy.tar.gz, 789 байт
Drupal
Drupal 8
Drupal Commerce

Комментарии

Алексей   пт, 15/09/2017 - 10:52

Отлично. приходилось работать с ценами в 7-ке. Но все же это все для line item, будем ждать еще для цен продуктов. Спасибо за ваш труд!

Niklan   чт, 26/10/2017 - 14:53

Чтобы PriceResolver применялся на странице товара: нужно в управлении отображением для нужного типа варианта товара, для поля цены указать виджет Calculated Price, вместо default. Тогда ценообразование будет также применяться при подготовке страницы.

Если не то что нужно. Тут два варианта:

  1. По-быстрому. Использовать hook_preprocess_HOOK для поля цены и менять цену.
  2. По-хорошему. Написать свой FieldFormatter для поля, а затем выбрать его в админке. За основу можно взять commerce/modules/price/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php.
Станислав   вт, 26/12/2017 - 21:06

В примере 2, где происходит добавление или вычитание цены, есть неточность в трактовке. В данном коде произойдёт не прибавление 100 рублей, а прибавление 100 единиц той валюты, в которой хранится цена:

// Example 1, +100 RUB $price = new Price(100, $entity->getPrice()->getCurrencyCode()); return $entity->getPrice()->add($price);

Art   пн, 29/04/2019 - 12:01

Возник вопрос.

Как в Drupal Commerce 2 создать глобальный атрибут, который будет использовать добавочную стоимость к уже существующим вариациям.

Например, майка X, XL, XXL имеет отдельные вариации и имеют разные цены, а нанесение рисунка на майку имеет добавочную статическую стоимость в размере 10$.

В стандартном случае необходимо создавать еще три вариации, а если атрибутов много, это уже не столько удобно в работе.

В 7 ке был чем то схожий модуль https://www.drupal.org/project/commerce_pricing_attributes когда атрибутам можно было присвоить отдельную цену.

Станислав   пн, 19/09/2022 - 22:08

Тоже интересует этот вопрос. А то куча вариаций и сидеть править каждый раз цену неудобно.

Famen   вт, 15/09/2020 - 21:23

Никита, здравствуйте. Можете подсказать, как вывести все товары со скидкой во Views? Скидка создана в штатном модуле Акции, модуля Commerce 2. Похоже, что проблема перекочевала из Drupal 7, но там есть модуль, а в Восьмерке, нет ниччего поожего. Всё перелопатил

Niklan   чт, 17/09/2020 - 12:35

Догадки: Надо делать вьюс не с товарами, а с акциями, подгружать через связи товары к которым она применима и выводить их, а не акции.

Не знаю даже что посоветовать, я бы такое сразу в коде сделал.

Виталий   чт, 25/02/2021 - 09:09

Никита, день добрый. Спасибо за статью. Вопрос по работе через Resolver c пересчетом цен товаров в зависимости от итоговой суммы корзины. Например:

  • есть цена розничная (базовое поле price) и цена оптовая (например, field_price_opt)
  • если итоговая сумма корзины >= 5000, то необходимо перейти на оптовые цены Реально ли такое, вообще, сделать через Resolver? Получается, что мы берем сумму товаров корзины в розничных ценах и, если она больше определенного значения, пересчитываем ее в оптовых ценах.
    Не подскажете, куда смотреть в этом случае?
Niklan   пн, 01/03/2021 - 08:02

Не подскажу, нужно проверять и тестировать. Сделать то можно, никто не мешает получить корзину в резолвере и задействовать её в логике. Но я не уверен что цены у уже имеющихся в корзине товаров будут обновлены. У ордера есть метод для перерасчёта цен, возможно как-то в связке с ним это и делается.

Volodymyr   вс, 18/04/2021 - 17:01

При использовании метода convert есть проблема с дефолтной валютой сайта. Тоесть у меня дефолтная валюта USD, но все цены вывожу в UAH конвертируя их. А вот на странице корзины нет возможности конвертировать потому что выдает ошибку: The provided prices have mismatched currencies: 0 USD, 178.48 UAH если изменить дефолтную валюту сайта на UAH, то тогда на странице корзины сработает, но получаю эту же проблему когда добавляю в корзину товар. Можешь пожалуйста подсказать как правильно работать с конвертацией ? как решение можно было иметь 2 дефолтных валюты на сате, но так не предусмотрено в commerce2