Местоположения и налоги

Модуль Интернет-магазин хранит местоположение в свойствах заказа и использует его при проверке ограничений доставки. Если заказ не использует НДС товара, налоговый расчет также выбирает ставки по местоположению.

Через API разработчик может найти местоположение, записать его код в заказ, пересчитать заказ и проверить получившиеся суммы.

Чем отличается адрес от местоположения

Адресные данные заказа состоят из разных значений. Не подменяйте одно другим.

Данные

Где хранятся

Для чего используются

Местоположение доставки

Свойство заказа типа LOCATION с флагом IS_LOCATION

Ограничения и обработчики доставки получают регион заказа

Местоположение для налогов

Свойство заказа типа LOCATION с флагом IS_LOCATION4TAX

Налоговый расчет выбирает связанные с местоположением ставки, если заказ не использует НДС товара

Адрес покупателя или доставки

Свойство типа STRING или ADDRESS с адресными флагами

Хранит улицу, дом, квартиру и другие сведения, которые не входят в код местоположения

Местоположение отгрузки

Поле DELIVERY_LOCATION объекта отгрузки

Хранит местоположение, связанное с конкретной отгрузкой. Не заменяет свойства заказа

Одно свойство может одновременно иметь флаги IS_LOCATION и IS_LOCATION4TAX, если доставка и налоги должны использовать один регион. Если нужны разные регионы, настройте отдельные свойства и заполняйте их независимо.

Выбрать API для местоположений

Для бизнес-операций с заказом используйте объектную модель. ORM подходит для чтения справочника и служебных выборок, в том числе для отчетов.

Задача

API

Результат

Найти местоположение по коду или другим полям

Bitrix\Sale\Location\LocationTable

ORM-запись местоположения

Получить название типа местоположения

Bitrix\Sale\Location\TypeTable

Данные типа, например страны, региона или города

Найти местоположение по введенному названию

Bitrix\Sale\Location\Search\Finder

Набор подходящих местоположений с учетом поискового индекса

Получить родителей или потомков в иерархии

Методы Bitrix\Sale\Location\LocationTable, унаследованные от Bitrix\Sale\Location\Tree

Узлы дерева местоположений

Создать или изменить местоположение

Bitrix\Sale\Location\LocationTable

Измененный узел дерева с локализованными названиями

Изменить местоположение в заказе

Bitrix\Sale\PropertyValueCollection

Измененное значение свойства в объекте заказа

Прочитать сохраненные значения для отчета

Bitrix\Sale\Internals\OrderPropsValueTable

ORM-записи значений без загрузки полного объекта заказа

В объектном сценарии меняйте значения через PropertyValueCollection, а не прямой записью в ORM-таблицу. Коллекция учитывает тип плательщика и сохраняется вместе с заказом.

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

Найти местоположение

Местоположение идентифицируется строковым кодом. Именно код записывают в свойство заказа типа LOCATION. Внутренний числовой идентификатор нужен для связей и запросов к таблицам, но не заменяет код в значении свойства.

Перед поиском подключите модуль sale и задайте язык, на котором нужно получить название. Состав и коды справочника зависят от импортированных данных.

use Bitrix\Main\Loader;
use Bitrix\Sale\Location\LocationTable;

if (!Loader::includeModule('sale'))
{
    throw new \RuntimeException('Не удалось подключить модуль sale');
}

$locationCode = '0000073738';
$languageId = LANGUAGE_ID;

$location =
    LocationTable::getList([
        'select' => [
            'ID',
            'CODE',
            'TYPE_ID',
            'LOCATION_NAME' => 'NAME.NAME',
        ],
        'filter' => [
            '=CODE' => $locationCode,
            '=NAME.LANGUAGE_ID' => $languageId,
        ],
        'limit' => 1,
    ])
        ->fetch()
;

if (!$location)
{
    throw new \RuntimeException('Местоположение не найдено');
}

Класс LocationTable хранит структуру местоположения, а локализованные названия связаны с ним отдельно. Поэтому язык нужно учитывать и в выборке названия, и в поиске по пользовательскому вводу.

Найти по названию

Если пользователь вводит часть названия, используйте Bitrix\Sale\Location\Search\Finder. Поиск через LocationTable по полю локализованного названия подходит для точных служебных запросов, но не заменяет поиск по фразе с учетом поискового индекса.

use Bitrix\Sale\Location\Search\Finder;

$phrase = 'Калининград'; // $phrase — строка из пользовательского ввода
$languageId = LANGUAGE_ID; // $languageId — код языка текущего сайта
$locations = Finder::find([
    'select' => [
        'ID',
        'CODE',
        'TYPE_ID',
        'NAME' => 'NAME.NAME',
    ],
    'filter' => [
        '=PHRASE' => $phrase,
        '=NAME.LANGUAGE_ID' => $languageId,
    ],
    'limit' => 10,
]);

$foundLocations = [];
while ($location = $locations->fetch())
{
    $foundLocations[] = $location;
}

Метод find() возвращает результат выборки. По умолчанию он использует поисковый индекс и повторяет запрос без индекса, если ничего не найдено. Ограничивайте число результатов и не передавайте в PHRASE символ %: метод удаляет его перед поиском.

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

Получить тип и иерархию

Поле TYPE_ID связывает местоположение с Bitrix\Sale\Location\TypeTable. Тип помогает отличить страну, регион, город и другие уровни справочника.

Получите код и локализованное название типа по идентификатору из найденного местоположения.

// $location — результат предыдущего запроса к LocationTable
// $languageId — код языка

use Bitrix\Sale\Location\TypeTable;

$locationType =
    TypeTable::getList([
        'select' => [
            'ID',
            'CODE',
            'TYPE_NAME' => 'NAME.NAME',
        ],
        'filter' => [
            '=ID' => (int) $location['TYPE_ID'],
            '=NAME.LANGUAGE_ID' => $languageId,
        ],
        'limit' => 1,
    ])
        ->fetch()
;

Переменная $locationType содержит данные о типе найденного узла на языке $languageId. Проверяйте результат перед обращением к полям: справочник может не содержать название типа на выбранном языке.

Класс Bitrix\Sale\Location\Tree — абстрактная основа для таблицы местоположений. Вызывайте унаследованные методы через LocationTable. Например, метод getPathToNodeByCode() возвращает цепочку родителей и найденный узел.

// $locationCode — проверенный строковый код местоположения
// $languageId — код языка

$pathResult = LocationTable::getPathToNodeByCode(
    $locationCode,
    [
        'select' => [
            'ID',
            'CODE',
            'TYPE_ID',
            'LOCATION_NAME' => 'NAME.NAME',
        ],
        'filter' => [
            '=NAME.LANGUAGE_ID' => $languageId,
        ],
    ]
);

$locationPath = [];
while ($pathItem = $pathResult->fetch())
{
    $locationPath[] = $pathItem;
}

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

Создать местоположение

Создавайте узел справочника через LocationTable::add(). Подготовьте уникальный строковый код, идентификатор типа, идентификатор родительского узла и локализованное название.

В примере $typeId — идентификатор типа местоположения из TypeTable, а $parentLocationId — числовой идентификатор родительского узла из LocationTable.

use Bitrix\Sale\Location\LocationTable;

$newLocationCode = 'custom-svetlogorsk';
$languageId = LANGUAGE_ID;

$existingLocation = LocationTable::getByCode(
    $newLocationCode,
    ['select' => ['ID']]
)->fetch();

if ($existingLocation)
{
    throw new \RuntimeException('Местоположение с таким кодом уже существует');
}

$result = LocationTable::add([
    'CODE' => $newLocationCode,
    'TYPE_ID' => $typeId,
    'PARENT_ID' => $parentLocationId,
    'NAME' => [
        $languageId => [
            'NAME' => 'Светлогорск',
        ],
    ],
]);

if (!$result->isSuccess())
{
    throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}

$newLocationId = (int) $result->getId();

Метод добавляет узел в иерархию и возвращает AddResult. Поле CODE обязательно, а TYPE_ID должно ссылаться на существующий тип. Если PARENT_ID равен 0, местоположение станет корневым узлом.

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

Заполнить свойства заказа

До заполнения свойств задайте тип плательщика. От него зависит, какие свойства попадут в коллекцию заказа.

// $order — новый или загруженный объект Bitrix\Sale\Order
// $locationCode — проверенный код местоположения

$propertyCollection = $order->getPropertyCollection();

$deliveryLocation = $propertyCollection->getDeliveryLocation();
if ($deliveryLocation === null)
{
    throw new \RuntimeException(
        'Для типа плательщика не настроено свойство местоположения доставки'
    );
}

$result = $deliveryLocation->setValue($locationCode);
if (!$result->isSuccess())
{
    throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}

Изменение остается в объекте заказа до вызова Order::save(). При сохранении настройки свойства флаг IS_LOCATION или IS_LOCATION4TAX принудительно устанавливает для него REQUIRED = Y.

До записи значения проверьте код через LocationTable: вызов setValue() не заменяет такую проверку.

Заполнить отдельное налоговое местоположение

Если в настройках есть отдельное свойство с флагом IS_LOCATION4TAX, получите его из коллекции и задайте тот код, который должен участвовать в налоговом расчете.

// $taxLocationCode — проверенный строковый код выбранного налогового местоположения

$taxLocation = $propertyCollection->getTaxLocation();
if ($taxLocation !== null)
{
    $result = $taxLocation->setValue($taxLocationCode);

    if (!$result->isSuccess())
    {
        throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
    }
}

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

Заполнить строковый адрес

Код местоположения не содержит улицу, дом и квартиру. Запишите подробный адрес в отдельное свойство.

$addressProperty = $propertyCollection->getItemByOrderPropertyCode('ADDRESS');
if ($addressProperty !== null)
{
    $result = $addressProperty->setValue('ул. Примерная, д. 1');

    if (!$result->isSuccess())
    {
        throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
    }
}

Символьный код ADDRESS — пример настройки. Используйте код свойства из своего типа плательщика.

Использовать местоположение при расчете доставки

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

  1. Создайте или загрузите заказ и задайте тип плательщика.

  2. Заполните местоположение и другие обязательные свойства.

  3. Создайте отгрузку и добавьте в нее позиции корзины.

  4. Получите доступные службы через Delivery\Services\Manager::getRestrictedList().

  5. Рассчитайте выбранную службу.

  6. Выполните финальный расчет заказа и сохраните его.

// $shipment — пользовательская отгрузка, которая уже связана с заказом и содержит позиции корзины

use Bitrix\Sale\Delivery\Restrictions\Manager as RestrictionManager;
use Bitrix\Sale\Delivery\Services\Manager as DeliveryManager;

$availableServices = DeliveryManager::getRestrictedList(
    $shipment,
    RestrictionManager::MODE_CLIENT
);

if ($availableServices === [])
{
    throw new \RuntimeException(
        'Для выбранного местоположения нет доступных служб доставки'
    );
}

После изменения местоположения заново проверяйте ограничения и пересчитывайте доставку. Ранее выбранная служба может стать недоступной, а рассчитанная стоимость — неактуальной.

Полный сценарий создания отгрузки и расчета стоимости описан в статье Доставка и отгрузки.

Прочитать местоположение сохраненного заказа для отчета

Для отчета можно прочитать значение напрямую через OrderPropsValueTable. Такой запрос не изменяет заказ:

use Bitrix\Sale\Internals\OrderPropsValueTable;

$propertyValue =
    OrderPropsValueTable::getList([
        'select' => ['VALUE', 'CODE', 'ORDER_PROPS_ID'],
        'filter' => [
            '=ORDER_ID' => $orderId,
            '=CODE' => 'LOCATION',
        ],
        'limit' => 1,
    ])
        ->fetch()
;

if ($propertyValue)
{
    $savedLocationCode = (string) $propertyValue['VALUE'];
}

Код свойства LOCATION зависит от настроек магазина. Если отчет должен работать для разных типов плательщика, находите свойство заказа через OrderPropsTable по флагу IS_LOCATION или IS_LOCATION4TAX, а не по условному коду.

Для изменения сохраненного заказа загрузите Order, получите коллекцию свойств, задайте значение, повторите расчеты доставки и налогов и сохраните заказ. Прямая запись через OrderPropsValueTable::update() не выполняет этот сценарий.

Налоги заказа

Сумма налога зависит от местоположения, настроек налогов, привязок ставок, налоговых данных товаров, состава и стоимости корзины.

Стоимость доставки влияет на сумму налога, только если в настройках модуля включен налог на доставку и стоимость доставки больше нуля.

Чем отличаются налоги заказа от НДС товара

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

  • Модуль catalog хранит ставку НДС товара и признак включения НДС в цену.

  • Позиция корзины хранит ставку в VAT_RATE, а признак включения НДС в цену — в VAT_INCLUDED.

  • Объект заказа получает сумму НДС из корзины. В этом режиме Order::getTaxValue() возвращает сумму НДС.

  • Если НДС товара не используется, модуль sale выбирает налоговые ставки по сайту, типу плательщика и значению свойства IS_LOCATION4TAX.

  • Местоположение для налогов не определяет ставку НДС товара.

Не создавайте и не изменяйте ставку НДС товара через налоговые классы модуля sale. Используйте сценарии Создать ставку НДС и Назначить НДС товару из документации модуля catalog.

Настроить налоги заказа

Для настройки налогов заказа используйте классы классического API CSaleTax и CSaleTaxRate. Перед вызовами подключите модуль sale. Сначала создайте налог для сайта, затем добавьте ставку и свяжите ее с типом плательщика и местоположениями.

Задача

Методы классического API

Создать, изменить или удалить налог

CSaleTax::Add(), Update(), Delete()

Прочитать налог

CSaleTax::GetByID(), GetList()

Создать, изменить или удалить ставку

CSaleTaxRate::Add(), Update(), Delete()

Прочитать ставку

CSaleTaxRate::GetByID(), GetList()

Прочитать рассчитанные строки налога заказа

CSaleOrderTax::GetList()

В примере $personTypeId — идентификатор типа плательщика, а $taxLocationCode — проверенный строковый код местоположения.

$taxId = \CSaleTax::Add([
    'LID' => SITE_ID,
    'NAME' => 'Региональный налог',
    'CODE' => 'REGIONAL_TAX',
    'DESCRIPTION' => 'Налог для выбранного региона',
]);

if (!$taxId)
{
    throw new \RuntimeException('Не удалось создать налог');
}

$taxRateId = \CSaleTaxRate::Add(
    [
        'TAX_ID' => $taxId,
        'PERSON_TYPE_ID' => $personTypeId,
        'VALUE' => 5,
        'IS_PERCENT' => 'Y',
        'IS_IN_PRICE' => 'N',
        'ACTIVE' => 'Y',
        'APPLY_ORDER' => 100,
        'TAX_LOCATION' => [
            [
                'LOCATION_ID' => $taxLocationCode,
                'LOCATION_TYPE' => 'L',
            ],
        ],
    ],
    ['EXPECT_LOCATION_CODES' => true]
);

if (!$taxRateId)
{
    throw new \RuntimeException('Не удалось создать налоговую ставку');
}

Поля ставки определяют расчет:

  • VALUE — значение ставки. В примере это пять процентов.

  • IS_PERCENT — тип ставки: Y для процентов, N для фиксированной суммы.

  • IS_IN_PRICE — признак включения налога в цену.

  • APPLY_ORDER — порядок применения ставки.

  • TAX_LOCATION — местоположения или группы местоположений, для которых действует ставка. Тип L обозначает местоположение, тип G — группу.

Опция EXPECT_LOCATION_CODES сообщает методу, что в LOCATION_ID переданы строковые коды нового справочника местоположений. Без этой опции метод преобразует числовые идентификаторы в коды.

Прочитайте настройки через CSaleTax::GetList() и CSaleTaxRate::GetList():

$taxes = \CSaleTax::GetList(
    ['NAME' => 'ASC'],
    ['LID' => SITE_ID]
);

while ($tax = $taxes->Fetch())
{
    $rates = \CSaleTaxRate::GetList(
        ['APPLY_ORDER' => 'ASC'],
        ['TAX_ID' => (int) $tax['ID']]
    );

    while ($rate = $rates->Fetch())
    {
        $taxRateValue = (float) $rate['VALUE'];
    }
}

Методы GetList() возвращают результаты классического API, которые нужно обходить через Fetch(). Проверяйте создание налога и ставки отдельно: если ставка не создана, запись налога уже остается в системе.

Пересчитать налоги заказа

После изменения налогов выполните финальный расчет заказа. Вызов doFinalAction(true) применяет скидки, обновляет налоговые данные и пересчитывает итоговые суммы. Получить налоги можно методом getTaxValue().

$result = $order->doFinalAction(true);
if (!$result->isSuccess())
{
    throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}

$taxValue = $order->getTaxValue();

Переменная $taxValue содержит итоговую сумму налога заказа в валюте заказа.

  • Если заказ использует НДС товара, getTaxValue() возвращает сумму НДС.

  • В остальных случаях метод возвращает сумму рассчитанных налогов.

Сохраните заказ после проверки результата.

$result = $order->save();
if (!$result->isSuccess())
{
    throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}

Не используйте сохранение как способ проверить доступность ставки. До записи убедитесь, что код местоположения существует, нужные свойства заполнены, а расчет завершился без ошибок.

Задавайте налоговое местоположение до первого вызова Order::getTaxLocation() или расчета налогов. Объект заказа кеширует полученный код в вычисляемом поле TAX_LOCATION и не сбрасывает его автоматически при последующем изменении свойства.

Если нужно изменить налоговое местоположение уже рассчитанного сохраненного заказа, повторно загрузите этот заказ в отдельный объект Order. Измените свойство до обращения к налоговым данным и только затем вызовите doFinalAction(true).

Проверить сохраненные налоговые данные

Общую сумму налога можно прочитать через Order::getTaxValue(). Для разбивки сохраненных строк налога в коде используется класс классического API CSaleOrderTax.

// $orderId — идентификатор сохраненного заказа

$taxRows = [];

$taxResult = \CSaleOrderTax::GetList(
    ['APPLY_ORDER' => 'ASC'],
    ['ORDER_ID' => $orderId]
);

while ($tax = $taxResult->Fetch())
{
    $taxRows[] = [
        'NAME' => $tax['TAX_NAME'],
        'RATE' => (float) $tax['VALUE'],
        'AMOUNT' => (float) $tax['VALUE_MONEY'],
    ];
}

Для заказа, который существует только в памяти, проверяйте результат объектного расчета до сохранения.

Классы CSaleTax, CSaleTaxRate и CSaleOrderTax относятся к классическому API. Используйте их только там, где API D7 не предоставляет полного сценария настройки или чтения налогов.

Частые ошибки

Ошибка

Как правильно

Передан числовой идентификатор вместо кода

Значение свойства типа LOCATION ожидает строковый код местоположения. Получите поле CODE из LocationTable и сохраните его без преобразования в число

Доставка рассчитана до заполнения свойств

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

Адрес и местоположение считаны одним значением

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

Заказ не пересчитан после изменения местоположения

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

Для налогового местоположения также вызовите Order::doFinalAction(true). Учитывайте кеш TAX_LOCATION: меняйте свойство в повторно загруженном объекте заказа до первого налогового расчета

Изменения внесены в ORM-таблицу вместо объекта заказа

Изменяйте PropertyValueCollection, а не записывайте данные напрямую в OrderPropsValueTable. Прямая запись может оставить доставку и итоговые суммы в состоянии, которое не соответствует новому местоположению