Перейти к содержанию

Товарная позиция хранит условия продажи в конкретном элементе CRM. В ней находятся товар, количество, цена, скидка и налог. Для работы с позициями используйте коллекцию объектов Bitrix\Crm\ProductRow у элемента Bitrix\Crm\Item. Изменения сохраняйте через операцию фабрики элемента.

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

Подготовить элемент и пользователя

Товары доступны у лидов, сделок, коммерческих предложений, счетов и смарт-процессов с включенной поддержкой товаров. Перед работой проверьте Factory::isLinkWithProductsEnabled() и наличие поля Item::FIELD_NAME_PRODUCTS. У контакта или компании такую коллекцию использовать нельзя.

Для счета выбирайте тип CCrmOwnerType::SmartInvoice. Для смарт-процесса передайте его entityTypeId и проверьте настройку товаров. Настройка типа описана в статье Смарт-процессы, общая модель фабрики и элемента — в статье Схема работы CRM и основные объекты API.

Примеры ниже работают с существующей сделкой в коробочном Битрикс24. Запускайте их в серверном обработчике после подключения пролога от авторизованного пользователя. Пролог подготавливает ядро Framework и окружение запроса. Порядок подключения описан в статье Жизненный цикл запроса.

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

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

Пример. Получите сделку 123 с учетом прав текущего пользователя и подготовьте контекст сохранения. Подставьте ID сделки своей установки.

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

global $USER;
$userId = (int)$USER->GetID();
if ($userId <= 0)
{
    throw new \RuntimeException('Требуется авторизованный пользователь');
}

$entityTypeId = \CCrmOwnerType::Deal;
$itemId = 123;
$container = \Bitrix\Crm\Service\Container::getInstance();
$factory = $container->getFactory($entityTypeId);
if ($factory === null || !$factory->isLinkWithProductsEnabled())
{
    throw new \RuntimeException('Тип CRM не поддерживает товары');
}

$items = $factory->getItemsFilteredByPermissions([
    'filter' => ['=ID' => $itemId],
], $userId);
$item = $items[0] ?? null;
if ($item === null)
{
    throw new \RuntimeException('Элемент не найден или недоступен');
}
if (!$item->hasField(\Bitrix\Crm\Item::FIELD_NAME_PRODUCTS))
{
    throw new \RuntimeException('У элемента нет поля товарных позиций');
}

$context = (new \Bitrix\Crm\Service\Context())->setUserId($userId);

Метод getItemsFilteredByPermissions() возвращает массив элементов, доступных пользователю для чтения. Параметр filter ограничивает выборку нужным ID. Без собственного select фабрика загружает стандартный набор полей вместе с товарными позициями. Если вы ограничиваете поля для чтения, добавьте Item::FIELD_NAME_PRODUCTS и Item::FIELD_NAME_CURRENCY_ID в select.

Контекст задает пользователя операции CRM, но не авторизует его в текущем PHP-запросе. Проверки каталога могут использовать текущего пользователя, поэтому примеры берут один ID из авторизованной сессии. Для фонового задания отдельно согласуйте пользователя CRM и контекст доступа к каталогу.

Получить товарные позиции

Метод Item::getProductRows() возвращает коллекцию позиций или null. Коллекция поддерживает перебор объектов ProductRow, а метод ProductRow::toArray() возвращает массив данных строки. Валюту берите у элемента через getCurrencyId(), отдельного поля валюты в строке нет.

Пример. Соберите строки сделки в массив $rows и получите код валюты в $currencyId. Переменная $item содержит элемент из подготовки.

$currencyId = $item->getCurrencyId();
$rows = [];

foreach ($item->getProductRows() ?? [] as $productRow)
{
    $rows[] = $productRow->toArray();
}

В массиве $rows находятся данные позиций, а не товары каталога. Пустой массив означает отсутствие строк у загруженного элемента. Для вывода в HTML экранируйте название и другие строковые значения.

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

|

|| Поле | Тип | Описание || || ID | int | ID товарной позиции CRM. Для новой строки не задавайте, CRM назначит его при сохранении || || OWNER_ID | int | ID элемента CRM, которому принадлежит строка || || OWNER_TYPE | string | Краткий код типа владельца. При изменении через Item CRM формирует привязку сама || || PRODUCT_ID | int | ID товара или торгового предложения каталога. Значение 0 обозначает позицию без связи с каталогом || || PRODUCT_NAME | string | Название в позиции. Если оно пустое и связанный товар существует, getProductName() получает название из элемента каталога || || QUANTITY | float | Количество единиц. По умолчанию 1, допускает дробное значение || || MEASURE_CODE | int | Код единицы измерения из настроек каталога, не ID записи единицы || || MEASURE_NAME | string | Обозначение единицы. CRM уточняет его при нормализации || || SORT | int | Значение для упорядочивания позиций || || XML_ID | string | Внешний идентификатор строки для сопоставления в интеграции || |#

При подготовке новой позиции через Item не задавайте OWNER_ID и OWNER_TYPE, CRM формирует привязку сама. ID строки нельзя заменять ID товара или ID сделки.

Если порядок важен для внешней системы, сортируйте полученные строки по SORT, а одинаковые значения — по ID. Не связывайте позиции по их порядковому номеру в массиве.

Задать цены, скидку и налог

Денежные поля позиции описывают цену одной единицы, количество хранится отдельно. При добавлении и изменении через Item CRM рассчитывает производные цены и уточняет единицу измерения. Этот этап называется нормализацией. Проверяйте возвращенный Result до сохранения элемента.

Передать конечную цену единицы

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

|

|| Поле | Тип | Описание || || PRICE | float | Цена единицы после скидки с налогом. По умолчанию 0 || || DISCOUNT_TYPE_ID | int | Вид скидки. Bitrix\Crm\Discount::PERCENTAGE — процентная, Bitrix\Crm\Discount::MONETARY — денежная || || DISCOUNT_RATE | float | Процент скидки. Для процентной скидки задайте значение вместе с конечной PRICE || || DISCOUNT_SUM | float | Денежная скидка на единицу без налога. Для денежной скидки задайте сумму вместе с конечной PRICE || || TAX_RATE | float или null | Ставка налога в процентах. Значение null означает отсутствие заданной ставки || || TAX_INCLUDED | bool | Признак включения налога в исходную цену. В массиве toArray() имеет вид 'Y' или 'N' || |#

Признак TAX_INCLUDED описывает способ учета налога, но не меняет смысл PRICE в методах Item. Даже при TAX_INCLUDED => false передайте в PRICE конечную цену с налогом. Не передавайте в это поле цену без налога в расчете на то, что нормализация прибавит налог.

Для процентной скидки нормализация восстанавливает сумму скидки из конечной цены и процента. Например, для исходной цены 100 без налога и скидки десять процентов передайте PRICE => 90.0 и DISCOUNT_RATE => 10.0. Если передать PRICE => 100.0, изменение процента само по себе не уменьшит конечную цену до 90.

При скидке сто процентов передайте PRICE => 0.0, DISCOUNT_RATE => 100.0 и ненулевую DISCOUNT_SUM, равную скидке без налога на единицу. Иначе по нулевой конечной цене нельзя восстановить исходную сумму и нормализация вернет ошибку.

CRM вычисляет производные поля цены. Используйте их для чтения результата, а не как замену PRICE при записи через Item.

|

|| Поле | Что содержит || || PRICE_EXCLUSIVE | Цену после скидки без налога || || PRICE_NETTO | Цену до скидки без налога || || PRICE_BRUTTO | Цену до скидки с налогом || || PRICE_ACCOUNT | Конечную цену в валюте учета CRM || |#

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

Выбрать способ расчета суммы элемента

Поле IS_MANUAL_OPPORTUNITY определяет, задается ли сумма элемента вручную. При значении true изменение позиций не переводит сумму в автоматический режим. При автоматическом режиме CRM рассчитывает сумму по товарам и учитывает предусмотренные для элемента связанные расчеты, в том числе доставку.

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

$item->setIsManualOpportunity(false);

Изменение флага остается в памяти до операции сохранения. Если нужно сохранить ручную сумму, не меняйте флаг. После операции проверяйте getOpportunity() у заново прочитанного элемента, а не вычисляйте ожидаемый итог только как сумму PRICE * QUANTITY.

Изменить товарные позиции

Для добавления, точечного изменения и замены набора предусмотрены разные методы. Выберите один подходящий сценарий ниже, затем сохраните элемент. Каждый пример продолжает подготовку и использует $item, $factory, $context и $userId. Не запускайте все варианты последовательно как одну инструкцию.

|

|| Метод | Что меняет | Результат || || addToProductRows($productRow) | Добавляет новый объект ProductRow в коллекцию, сохраняя остальные позиции | Bitrix\Main\Result || || updateProductRow($rowId, $fields) | Меняет переданные поля строки с ID $rowId, которая уже принадлежит элементу | Bitrix\Main\Result || || removeFromProductRows($productRow) | Удаляет из коллекции переданный объект строки | void || || setProductRowsFromArrays($rows) | Заменяет набор массивами данных строк с учетом сравнения содержимого позиций | Bitrix\Main\Result || || setProductRows($rows) | Заменяет набор массивом объектов ProductRow или коллекцией ProductRowCollection с учетом сравнения содержимого позиций | Bitrix\Main\Result || |#

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

Добавить позицию без каталога

Метод ProductRow::createFromArray() создает объект товарной позиции из массива полей. Передайте PRODUCT_ID => 0, название, количество и конечную цену. Новая позиция будет принадлежать только элементу CRM, отдельный товар каталога при этом не появится.

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

$productRow = \Bitrix\Crm\ProductRow::createFromArray([
    'PRODUCT_ID' => 0,
    'PRODUCT_NAME' => 'Настройка оборудования',
    'PRICE' => 150.0,
    'QUANTITY' => 2.0,
    'DISCOUNT_TYPE_ID' => \Bitrix\Crm\Discount::PERCENTAGE,
    'DISCOUNT_RATE' => 0.0,
    'TAX_RATE' => null,
    'TAX_INCLUDED' => false,
    'SORT' => 100,
]);

$rowsResult = $item->addToProductRows($productRow);
if (!$rowsResult->isSuccess())
{
    throw new \RuntimeException(implode('; ', $rowsResult->getErrorMessages()));
}

Единицу измерения CRM возьмет из настройки по умолчанию. Если нужна другая единица, добавьте ее MEASURE_CODE в массив. Проверяйте код по настройкам своей установки.

Добавить товар или торговое предложение

Для каталожной позиции передайте в PRODUCT_ID ID продаваемого товара. Если у товара есть варианты, выберите ID конкретного торгового предложения. Родительский товар и предложение имеют разные ID, поэтому сначала определите нужный объект по правилам статьи Работа с товарами и торговыми предложениями.

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

Пример. Добавьте выбранный товар с ценой 100 до скидки и без налога. Конечная цена после скидки десять процентов составит 90 за единицу. Подставьте существующий ID товара или предложения вместо 456.

$productId = 456;
$productRow = \Bitrix\Crm\ProductRow::createFromArray([
    'PRODUCT_ID' => $productId,
    'PRICE' => 90.0,
    'QUANTITY' => 3.0,
    'DISCOUNT_TYPE_ID' => \Bitrix\Crm\Discount::PERCENTAGE,
    'DISCOUNT_RATE' => 10.0,
    'TAX_RATE' => null,
    'TAX_INCLUDED' => false,
    'SORT' => 200,
]);

$rowsResult = $item->addToProductRows($productRow);
if (!$rowsResult->isSuccess())
{
    throw new \RuntimeException(implode('; ', $rowsResult->getErrorMessages()));
}

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

Изменить количество или цену строки

Метод updateProductRow() принимает ID позиции CRM и массив изменяемых полей. Не переданные поля сохраняют прежние значения. Если строка с таким ID не принадлежит элементу, метод возвращает ошибку.

Пример. Измените количество существующей строки на четыре единицы, сохранив ее цену и скидку. Подставьте ID из прочитанного массива позиций вместо 789.

$rowId = 789;
$rowsResult = $item->updateProductRow($rowId, [
    'QUANTITY' => 4.0,
]);
if (!$rowsResult->isSuccess())
{
    throw new \RuntimeException(implode('; ', $rowsResult->getErrorMessages()));
}

Для изменения цены передайте PRICE. Если меняете условия скидки или налога, передайте вместе с ними согласованную конечную цену. Не включайте в массив ID, OWNER_ID и OWNER_TYPE, поскольку этот сценарий меняет условия существующей строки, а не ее принадлежность.

Удалить одну позицию

Получите объект строки из коллекции текущего элемента по ее ID и передайте в removeFromProductRows(). Метод не возвращает Result. Ошибки записи проверяйте у операции сохранения.

Пример. Удалите из сделки позицию 789. Подставьте ID строки, которую нужно убрать.

$rowId = 789;
$productRows = $item->getProductRows();
$productRow = $productRows ? $productRows->getByPrimary($rowId) : null;
if ($productRow === null)
{
    throw new \RuntimeException('Товарная позиция не найдена в элементе');
}

$item->removeFromProductRows($productRow);

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

Заменить весь набор или очистить его

Метод setProductRowsFromArrays() принимает полный новый набор и обрабатывает добавление, изменение и удаление строк. Для частичного изменения используйте updateProductRow().

Внимание

Передавайте в setProductRowsFromArrays() все позиции, которые должны остаться в элементе. Метод может удалить пропущенные строки. Пустой массив удаляет весь набор после сохранения. Не передавайте этим методом только строки, изменившиеся во внешней системе.

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

Для точного управления одинаковыми позициями используйте addToProductRows(), updateProductRow() и removeFromProductRows(). Изменение только XML_ID передавайте через updateProductRow(), поскольку полная замена может его не учесть.

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

$rowId = 789;
$replacement = [];
$found = false;

foreach ($item->getProductRows() ?? [] as $productRow)
{
    $row = $productRow->toArray();
    if ((int)$row['ID'] === $rowId)
    {
        $row['QUANTITY'] = 4.0;
        $found = true;
    }
    $replacement[] = $row;
}

if (!$found)
{
    throw new \RuntimeException('Товарная позиция не найдена в элементе');
}

$rowsResult = $item->setProductRowsFromArrays($replacement);
if (!$rowsResult->isSuccess())
{
    throw new \RuntimeException(implode('; ', $rowsResult->getErrorMessages()));
}

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

Для очистки выполните setProductRowsFromArrays([]), проверьте его Result и сохраните элемент. В режиме ручной суммы очистка товаров сама по себе не обнуляет сумму элемента.

Сохранить элемент и проверить результат

После выбранного изменения вызовите Factory::getUpdateOperation($item, $context)->launch(). Операция проверяет права и поля, сохраняет элемент и выполняет связанные действия CRM. Прямая запись через ProductRowTable или вызов Item::save() не заменяет этот цикл.

Для новой сделки сначала создайте Item, заполните обязательные поля и добавьте позиции до getAddOperation()->launch(). После сохранения CRM свяжет строки с новым элементом. Создание, выбор стадии и обязательных полей приведены в статье Работа с элементами CRM.

Пример. Сохраните изменение из одного из предыдущих сценариев и перечитайте позиции и сумму. Переменные $factory, $item, $context, $itemId и $userId подготовлены в начале.

$result = $factory->getUpdateOperation($item, $context)->launch();
if (!$result->isSuccess())
{
    throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}

$savedItems = $factory->getItemsFilteredByPermissions([
    'filter' => ['=ID' => $itemId],
], $userId);
$savedItem = $savedItems[0] ?? null;
if ($savedItem === null)
{
    throw new \RuntimeException('Не удалось прочитать сохраненный элемент');
}

$savedRows = [];
foreach ($savedItem->getProductRows() ?? [] as $productRow)
{
    $savedRows[] = $productRow->toArray();
}
$savedCurrencyId = $savedItem->getCurrencyId();
$savedOpportunity = $savedItem->getOpportunity();

Сравните $savedRows с ожидаемым набором. При добавлении проверьте новый ID строки, при изменении — нужные значения, при удалении — отсутствие строки. Переменные $savedCurrencyId и $savedOpportunity содержат валюту и сумму после обработки CRM.

Разобрать ошибку

Проверяйте два результата отдельно. Ошибка нормализации возникает при подготовке позиции, ошибка операции — при проверках или сохранении элемента и связанных действиях.

|

|| Ситуация | Что проверить | Что сделать || || Не найдена единица измерения | MEASURE_CODE, единицу товара и единицу по умолчанию | Указать существующий код или исправить настройку каталога || || Не удалось изменить строку по ID | ID позиции и ее принадлежность загруженному элементу | Повторно прочитать строки и выбрать ID позиции CRM || || Ошибка цены или скидки | Конечную PRICE, вид и параметры скидки, валюту | Исправить согласованный набор значений и повторить нормализацию || || Операция отклонила доступ | Права на изменение элемента, цены и скидки | Выполнить действие от подходящего пользователя, сохранив проверки операции || || Ошибка обязательного поля или связанного действия | Сообщения getErrorMessages() и фактическое состояние элемента | Исправить причину и определить, какие изменения уже сохранились || |#

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

Повторить обмен без потери строк

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

Для обмена храните соответствие внешнего идентификатора строки и ее ID в CRM. Поле XML_ID подходит для собственного ключа, но API изменения коллекции не выполняет обновление по этому ключу вместо ID. Проверку уникальности и поиск соответствия обеспечьте в коде интеграции. Не используйте один PRODUCT_ID как уникальный ключ позиции, один товар может находиться в нескольких строках.

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

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

Разделить ответственность CRM и каталога

Изменение цены в позиции задает условия конкретной продажи. Оно не заменяет изменение цены в торговом каталоге. Чтобы обновить свойства товара, каталог цен или складские данные, используйте API соответствующего модуля.

|

|| Задача | Где выполнять | Связанный материал || || Изменить количество и цену продажи в сделке | Коллекция ProductRow и операция фабрики CRM | Изменить товарные позиции || || Создать товар, выбрать предложение или изменить его свойства | Модули catalog и iblock | Работа с товарами и торговыми предложениями || || Получить цены каталога и доступность товара | API торгового каталога | Доступность, цены и подписка || || Работать с остатками и складскими документами | Складской учет каталога | Складской учет || || Изменить корзину, оплату или отгрузку заказа | API модуля sale с учетом связи с CRM | Введение и базовые концепции || |#

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

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