Ценообразование — пожалуй, достоинство в Magento и самая интересная часть системы.
А для владельца магазина — самая важная часть, так как связано с деньгами.
Ранее коллеги рисовали диаграммы, которые еле помещались на Китайской Стене, пытаясь уместить все-все-все этапы расчета. В этой статье попробую изложить только основные этапы расчета, и пример округления скидок в пользу магазина. К счастью, по сравнению с Magento 1, новшества коснулись самых глубин, подход остался неизменным.

Верхушка айсберга


image

Когда клиент меняет содержимое корзины начинается расчет. Скорость расчета зависит от множества действий «на глубине». Начнем погружение с видных мест. попутно увидим события и зависимости типов товаров, методов доставки, ценовых правил корзины и каталога.

Статья описывает правильный подход вмешательства в ценообразование для следующих модулей/интеграций:

  • Баланс клиента — когда клиенту деньги не возвращаются, а остаются в магазине.
  • Программа лояльности — оплата «попугаями» за заслуги перед магазином.
  • Сертификаты — Некий баланс который можно использовать по номеру.
  • Кратность количеству — не все ERP системы умеют продавать 3 товара за 2 рубля, появляется назойливая копейка, которую при возврате клиент будет требовать.
  • Интеграция ценообразования — у некоторых розничных сетей, или крупных компаний ответственность за расчет стоимости производит какая-то конкретная система, SAP ERP или облачный сервис (самописный модуль для кассы с интерфейсом).

Перейдем сразу к расчету, так как формирование строк корзины при добавлении товара — отдельная тема, возможно будет в следующих статьях.
В тексте будут встречаться Total, Price, Carrier модели, они обозначают определенный тип, и далее так проще ссылаться.

\Magento\Quote\Model\Quote::collectTotals


Путешествие начинается начинается где мы идем и начинаем проводить расчет.
Просим TotalsCollector повести расчет, этот класс специально отделили от корзины, чтоб не добавлять еще строк в код.

\Magento\Quote\Model\Quote\TotalsCollector::collect


Где проходим по всем адресам и просим адреса провести расчет для всех адресов.
Это сделано для возможности оформления заказов сразу по множеству адресов, так как это одна из полезных функций для B2B магазинов, у которых есть централизованный отдел закупок и заказы идут «оптом» но сразу по разным местам.

\Magento\Quote\Model\Quote\TotalsCollector::collectAddressTotals


Просим у нас есть ответственный TotalsCollectorList класс, который возвращает нам все этапы расчета. Все этапы находятся в конфигурации, упорядочены. В конце мы рассмотрим свой маленький модификатор ценообразования.

\Magento\Quote\Model\Quote\TotalsCollectorList::getCollectors


Результатом выполнения является массив классов CollectorInterface, в которых реализуется логика расчета стоимости.

Все этапы расчета декларируются для основных сущностей которые важны при при расчете стоимости: корзины, счета, возврата. В ядре системы всегда есть хорошие примеры: vendor/magento/module-sales/etc/sales.xml

Ниже описано добавление этапов расчет для order_invoice и order_creditmemo счета(-фактуры) и order_creditmemo возвраты средств.

Помимо этого добавляются available_product_type (доступные типы товаров для покупки). В модулях конкретных типов товаров декларируются типы товаров.

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/sales.xsd">
    <section name="order_invoice">
        <group name="totals">
            <item name="subtotal" instance="Magento\Sales\Model\Order\Invoice\Total\Subtotal" sort_order="50"/>
            <item name="discount" instance="Magento\Sales\Model\Order\Invoice\Total\Discount" sort_order="100"/>
            <item name="shipping" instance="Magento\Sales\Model\Order\Invoice\Total\Shipping" sort_order="150"/>
            <item name="tax" instance="Magento\Sales\Model\Order\Invoice\Total\Tax" sort_order="200"/>
            <item name="cost_total" instance="Magento\Sales\Model\Order\Invoice\Total\Cost" sort_order="250"/>
            <item name="grand_total" instance="Magento\Sales\Model\Order\Invoice\Total\Grand" sort_order="350"/>
        </group>
    </section>
    <section name="order_creditmemo">
        <group name="totals">
            <item name="subtotal" instance="Magento\Sales\Model\Order\Creditmemo\Total\Subtotal" sort_order="50"/>
            <item name="discount" instance="Magento\Sales\Model\Order\Creditmemo\Total\Discount" sort_order="150"/>
            <item name="shipping" instance="Magento\Sales\Model\Order\Creditmemo\Total\Shipping" sort_order="200"/>
            <item name="tax" instance="Magento\Sales\Model\Order\Creditmemo\Total\Tax" sort_order="250"/>
            <item name="cost_total" instance="Magento\Sales\Model\Order\Creditmemo\Total\Cost" sort_order="300"/>
            <item name="grand_total" instance="Magento\Sales\Model\Order\Creditmemo\Total\Grand" sort_order="400"/>
        </group>
    </section>
    <order>
        <available_product_type name="simple"/>
        <available_product_type name="virtual"/>
    </order>
</config>

Ниже список наименований и классов Total-моделей для корзины:

1. subtotal => \Magento\Quote\Model\Quote\Address\Total\Subtotal
Расчет стоимости товаров до налогообложения скидок и прочего.
2. tax_subtotal => \Magento\Tax\Model\Sales\Total\Quote\Subtotal
Налоги часть налогообложения
3. weee => \Magento\Weee\Model\Total\Quote\Weee,
Фиксированные налоги, акцизы
4. shipping => \Magento\Quote\Model\Quote\Address\Total\Shipping
Расчет стоимости доставки, обращение в службы доставки за онлайн расчетом
5. tax_shipping => \Magento\Tax\Model\Sales\Total\Quote\Shipping
Налоги на доставку, доставка тоже может может облагаться и/или для бухгалтерии это требуется.
6. discount => \Magento\SalesRule\Model\Quote\Discount,
Обработка правил скидок, применение купонов, акций, скидки по «погоде»
7. tax => \Magento\Tax\Model\Sales\Total\Quote\Tax
Еще один этап расчета налогов, так как скидка по законодательству может не уменьшать налоговую базу.
8. weee_tax => \Magento\Weee\Model\Total\Quote\WeeeTax,
Фиксированные налоги еще один этап
9. grand_total => \Magento\Quote\Model\Quote\Address\Total\Grand
Итоговый подсчет суммируем все что посчитали до этого.

Под водой


Самые интересные элементы находятся в subtotal, shipping, discount.

\Magento\Quote\Model\Quote\Address\Total\Subtotal


image

И так, чтоб получить стоимость товара Subtotal просит продукт выдать ему финальную цену.

Но продукт сам свою цену не знает, он ходит к своей Price-модели.
Работа Price модели это целая тема для отдельной статьи «Как создать свой тип товара».
Но этого уже хватает для того, чтоб переопределить первичную цену любого товара, это может быть часть простейшей интеграции с персональными ценами под клиента, где все цены хранятся в простой таблице, возможно их туда загружают ра з в сутки.

\Magento\SalesRule\Model\Quote\Discount


image

Скидки — еще одно интересное место где происходит проверка корзины на предмет того может или нет использоваться скидка. Добавление особых правил (например скидка по погоде в городе) для проверки заслуживает отдельной статьи.

Система проверяет все активные правила скидки на текущую дату. Если много правил, это может замедлять пересчет корзины. Все будет хорошо если правил до

\Magento\Quote\Model\Quote\Address\Total\Shipping


image

Расчет стоимости доставки происходит посредством обхода всех методов доставки и вызовом
\Magento\Shipping\Model\Carrier\AbstractCarrierInterface::collectRates Расчет доставки сохраняется в БД и происходит только при указанной стране доставки.

По умолчанию можно задать метод доставки и страну доставки для проведения расчета стоимости заказа сразу после добавления товара в корзину. Такой подход можно использовать если в магазине мы получаем данные о городе или регионе по IP каким-то образом.

Свой расчет правильно


Модуль \Project\Integration


Как создавать свой модуль коллеги писали и ранее на Хабре тут.
Приступим к внедрению своего пересчета скидок для строк заказа, уберем копейки после расчета скидок.

Это удобно когда у нас все товары имеют цены без копеек, и копейки из скидок нам только мешают (при расчете НДС).

В файле Project/Integration/etc/sales.xml мы можем добавить свою Total-модель, или убрать старую/ненужную weee.

sort_order — обеспечивает порядок выполнения, для всех Total-моделей в sales.xml он тоже задан.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/sales.xsd">
    <section name="quote">
        <group name="totals">
            <item name="integration_total" instance="Project\Integration\Model\Quote\Address\Total\Custom" sort_order="430"/>
            <item name="weee" instance="" />
            <item name="weee_tax" instance="" />
        </group>
    </section>
</config>

sort_order=«430» — декларирует расчет скидок и до расчета налогов.
Это то самое место, где лучше всего срезать копейки со скидки или провести запрос в систему расчета скидок корзины.

Реализация расчета в \Project\Integration\Model\Quote\Address\Total\Custom

<?php

namespace Project\Integration\Model\Quote\Address\Total;

// декларируем используемые сущности

class Custom extends \Magento\Quote\Model\Quote\Address\Total\AbstractTotal
{
    // декларируем переменные класса ...

    public function __construct(
        // подключаем что нам нужно через DI ...
    )
    {
        // переносим в переменные класса ...
    }

    public function collect(
        \Magento\Quote\Model\Quote $quote,
        \Magento\Quote\Api\Data\ShippingAssignmentInterface $shippingAssignment,
        \Magento\Quote\Model\Quote\Address\Total $total
    )
    {
        $address = $shippingAssignment->getShipping()->getAddress();
        $quoteItems = $quote->getAllItems();
        
        // не будем проводить расчет если нет скидки, это первый расчет корзины, или товары отсутствуют, или адрес-платежный
        if ($total->getTotalAmount('discount') == 0 || $quote->getItemsCount() == 0 || !$quote->getId() || $address->getAddressType() == 'billing') return $this;


        // Делаем корректировки базовой цены и скидки, задаем потенциальный номер заказа ... 

        // Нулевая скидка в начале
        $totalDiscount = 0;
        $baseTotalDiscount = 0;
        foreach ($quoteItems as $item) {
            // Отбросим копейки, окрегляем в пользу магазина
            $newDiscountAmount = (int)$item->getDiscountAmount();
            $newBaseDiscountAmount = (int)$item->getBaseDiscountAmount();

            // добавляем скидку по позиции
            $totalDiscount += $newDiscountAmount;
            $baseTotalDiscount += $newBaseDiscountAmount;

            // Пересчитаем итог строки
            $rowTotal = $item->getRowTotal() + $item->getDiscountAmount() - $newDiscountAmount;
            $baseRowTotal = $item->getBaseRowTotal() + $item->getBaseDiscountAmount() - $newBaseDiscountAmount;

            // Установим новые скидки
            $item->setDiscountAmount($newDiscountAmount);
            $item->setBaseDiscountAmount($newBaseDiscountAmount);

            // Установим новый итог строки
            $item->setRowTotal($rowTotal);
            $item->setBaseRowTotal($baseRowTotal);
        }
        // подводим итоги
        $total->setTotalAmount('discount', $totalDiscount);
        $total->setBaseTotalAmount('discount', $baseTotalDiscount);
        $total->setSubtotalWithDiscount($total->getSubtotal() + $total->getDiscountAmount());
        $total->setBaseSubtotalWithDiscount($total->getBaseSubtotal() + $total->getBaseDiscountAmount());

        return $this;
    }
}

Вместо концовки
Тут мы удачно отбросили копейки. Нет копеек, нет проблем.

Если мы используем дополнительные модификаторы цены (свои спец-скидки или наценки, налоги на Internet Explorer), то нам нужно побеспокоиться, что все расчеты верно проходят и в счетах, и в возвратах, иначе вы превратитесь в серийных программистов которые убивают бухгалтеров. Наиболее оптимально модифицировать скидку или базовую стоимость товара для обеспечения целостности сумм даже при условиях возвратов.

Комментарии (6)


  1. NatalyMorozova
    01.09.2017 18:45

    А если к продукту применяются несколько скидок, то по какому алгоритму суммируются скидки и как выглядит ценообразование?


    1. kirmorozov Автор
      01.09.2017 19:01

      Если правильно понял вопрос, речь идет о порядке обработки скидок.
      Скидки применяются согласно приоритету, который задан у правила скидки для корзины.
      Validator::sortItemsByPriority() загрузит уже в приоритетном порядке.
      В каждом правиле можно остановить обработку и не обрабатывать цены с более низким приоритетом. Потому можно создавать сложные правила комбинации разных скидок одновременно (даже с привязкой к географии).
      \Magento\SalesRule\Model\Quote\Discount::collect реализует всю логику пересчета


      1. NatalyMorozova
        01.09.2017 20:57

        спасибо)


  1. Enlightened
    04.09.2017 18:31

    Не совсем по теме статьи, но я так и не понял, что делает модуль с «говорящим» названием weee. Не могли бы осветить это чуть подробнее?


    1. kirmorozov Автор
      04.09.2017 19:22
      +1

      Weee — это модуль фиксированного налога на товар, фиксированная ставка за обьем/единицу продукции (почему его так называли история умалчивает). Наиболее очевидное применение — акцизы, применяется фиксированная ставка за объем, примеры:
      1. Винный завод или импортер алкоголя (5 рублей за литр).
      2. Импортер или производитель сигарет (38,6 руб. с пачки).

      Дает немного прозрачности в цене товара для покупателя.
      Включить можно в настройках Система -> Конфигурация -> Продажи -> Налог -> Фиксированные налоги товара (System > Config > Sales > Tax > Fixed Product Tax)


      1. Enlightened
        04.09.2017 22:44

        Спасибо!