Кофе && Код

Утренние советы, best practices и продвинутые техники за чашкой кофе.

Как защитить агент импорта от двойного запуска с помощью Connection::lock()

Как защитить агент импорта от двойного запуска с помощью Connection::lock()

Проблема/контекст

Для агентов, cron-задач и CLI-команд в Битрикс типовая ошибка одна: процесс стартует второй раз, пока первая копия еще держит критическую секцию. В результате появляются дубли импорта, повторные списания, гонки при обновлении сущностей и нестабильные ошибки, которые сложно воспроизвести.

В ядре для этого уже есть встроенный механизм. В Bitrix\Main\DB\MysqlCommonConnection::lock() платформа переводит пул в master only, вызывает SELECT GET_LOCK(...), а имя блокировки нормализует через CMain::GetServerUniqID(). Это важная деталь: блокировка берется не на уровне PHP-процесса, а на уровне текущего соединения с основной БД, поэтому она подходит именно для межпроцессной синхронизации.

Решение с кодом

Если задача должна выполняться строго в одном экземпляре, ставьте блокировку в самом начале и всегда снимайте ее в finally. Такой подход соответствует и самому ядру: Bitrix\Main\Messenger\Internals\Storage\Db\DbStorage не читает очередь без Application::getConnection()->lock('queueLock'), а в main/classes/general/usertype.php изменение UTS-таблиц обернуто в блокировку uf_add_*.

        <?php
declare(strict_types=1);

use Bitrix\Main\Application;
use Bitrix\Main\Diag\Logger;
use Bitrix\Main\Loader;

final class CatalogImportAgent
{
    public static function run(): string
    {
        $connection = Application::getConnection();
        $lockName = 'catalog_import';

        // Вторая копия не ждет: если импорт уже идет, просто выходим
        if (!$connection->lock($lockName, 0))
        {
            return CatalogImportAgent::class . '::run();';
        }

        try
        {
            Loader::includeModule('iblock');
            (new ImportService())->sync();
        }
        catch (\Throwable $e)
        {
            Logger::create('catalog_import')?->error($e->getMessage());
        }
        finally
        {
            // Освобождаем lock даже при исключении
            $connection->unlock($lockName);
        }

        return CatalogImportAgent::class . '::run();';
    }
}

    

Практический вывод здесь двойной. Во-первых, named lock не заменяет транзакцию: он защищает вход в критическую секцию, а не целостность отдельных SQL-операций. Во-вторых, имя должно быть стабильным и описывать конкретный ресурс, например catalog_import, prices_sync или agent_invoice_export. Если использовать случайные ключи, механизм потеряет смысл. Для долгих задач имеет смысл выбирать ненулевой таймаут, но для агентов и cron-скриптов чаще полезнее мгновенно завершить повторный запуск и дождаться следующего окна.

Итог

Connection::lock() в Битрикс уже решает задачу одиночного запуска без самодельных файлов-флажков и временных таблиц. Если процесс нельзя выполнять параллельно, ставьте named lock до начала работы и снимайте его гарантированно через finally.

Как маркировать предупреждения в Bitrix\Sale

Как маркировать предупреждения в Bitrix\Sale

Проблема/контекст

В sale не каждая проблемная ситуация должна ронять сохранение заказа. Часто интеграция с доставкой, кассой или внутренней проверкой находит состояние, которое нужно показать оператору, но не превращать в фатальную ошибку. В ядре для этого есть не только поля MARKED и REASON_MARKED, но и отдельный механизм Bitrix\Sale\EntityMarker: он берет warnings из Bitrix\Sale\Result, создает marker-записи, записывает последнюю причину в заказ и помечает оплату или отгрузку как проблемную.

Практически это значит: если вы уже возвращаете Result с предупреждениями, не дублируйте еще один собственный реестр проблем. Используйте штатный контур Sale.

Решение с кодом

Ключевой момент в ядре такой: EntityMarker::addMarker() работает именно с warnings, а не с обычными errors. Поэтому для нефатального кейса нужен Bitrix\Sale\ResultWarning.

        <?php
declare(strict_types=1);

use Bitrix\Main\Loader;
use Bitrix\Sale\EntityMarker;
use Bitrix\Sale\Order;
use Bitrix\Sale\Result;
use Bitrix\Sale\ResultWarning;
use Bitrix\Sale\Shipment;

Loader::includeModule('sale');

function markShipmentIntegrationWarning(Order $order, Shipment $shipment): void
{
    $result = new Result();

    // Нефатальная проблема: заказ сохранять можно, но отгрузку нужно подсветить оператору
    $result->addWarning(new ResultWarning(
        'Не удалось подтвердить трек-номер во внешней службе доставки',
        'SHIPMENT_TRACKING_SYNC_WARNING'
    ));

    EntityMarker::addMarker($order, $shipment, $result);
    EntityMarker::saveMarkers($order);

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

    

После этого в заказ попадет REASON_MARKED, у отгрузки выставится MARKED = 'Y', а сам marker сохранится в таблице маркеров. Это важнее простого $shipment->setField('MARKED', 'Y'): в ядре сохраняются код проблемы, тип маркера (AUTO или MANUAL) и статус успешности исправления.

Warning в заказе

Когда причина уже устранена, снимайте проблему не только с поля, но и с marker-записи. Для этого можно удалить маркеры сущности и пересчитать состояние заказа через refreshMarkers().

        <?php
declare(strict_types=1);

use Bitrix\Sale\EntityMarker;
use Bitrix\Sale\Order;
use Bitrix\Sale\Shipment;

function clearShipmentMarkers(Order $order, Shipment $shipment): void
{
    // deleteByEntity() работает только для уже сохраненной сущности с ID
    EntityMarker::deleteByEntity($shipment);
    EntityMarker::refreshMarkers($order);

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

    

Итог

EntityMarker в sale нужен не для декоративного флага, а для нормального жизненного цикла предупреждений. Если проблема не фатальна, но должна остаться в истории и интерфейсе заказа, сохраняйте ее как marker, а после исправления очищайте через API маркеров, а не только через поле MARKED.

walk() для ORM-коллекций: практичные массовые операции с инфоблоками

walk() для ORM-коллекций: практичные массовые операции с инфоблоками

Проблема/контекст

В D7 вы часто получаете из ORM не массив, а типизированную коллекцию объектов через fetchCollection(). Перебирать её через foreach нормально, но в реальном коде обычно хочется:

  • компактно “пройтись и изменить” объекты;
  • сохранить изменения одной цепочкой вызовов;
  • не плодить лишние временные переменные.

Начиная с main 26.0.0 у Bitrix\Main\ORM\Objectify\Collection появился метод walk(): это “аналог foreach, но как метод”, который удобно вставляется в цепочку.

Решение с кодом

walk() вызывает ваш callback для каждого объекта коллекции и возвращает эту же коллекцию (для чейнинга). В callback приходят:

  1. объект сущности,
  2. ключ итерации — это ключ внутреннего массива коллекции (обычно сериализованный первичный ключ; для составного — строка).

Пример 1. Массово деактивировать элементы инфоблока и сохранить одной цепочкой

Частый кейс: найти элементы по условию, поставить ACTIVE = 'N' и сохранить максимально “чисто”.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function deactivateSectionElements(int $iblockId, int $sectionId): void
{
	Loader::includeModule('iblock');

	$saveResult = ElementTable::query()
		->setSelect(['ID', 'ACTIVE', 'IBLOCK_ID', 'IBLOCK_SECTION_ID'])
		->where('IBLOCK_ID', $iblockId)
		->where('IBLOCK_SECTION_ID', $sectionId)
		->where('ACTIVE', 'Y')
		->fetchCollection()
		->walk(static function($element): void {
			$element->setActive('N');
		})
		->save()
	;

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

    

Практические детали:

  • При одинаковом изменении для всех объектов save() умеет выполнять групповой UPDATE (однотипные правки). Это как раз тот случай, когда walk() хорошо “стыкуется” с коллекциями.
  • Если вы меняете разные значения для каждого объекта (например, SORT по формуле), ORM может уйти в серию UPDATE по одному объекту.

Пример 2. “Пройтись и собрать” данные без лишнего foreach

walk() удобен не только для мутаций, но и для побочных эффектов: собрать ID, составить карту, подсчитать суммы.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function collectElementIdsAndNames(int $iblockId, int $limit = 50): array
{
	Loader::includeModule('iblock');

	$ids = [];
	$namesById = [];

	ElementTable::query()
		->setSelect(['ID', 'NAME'])
		->where('IBLOCK_ID', $iblockId)
		->setLimit($limit)
		->fetchCollection()
		->walk(static function($element, $key) use (&$ids, &$namesById): void {
			$id = (int)$element->getId();
			$ids[] = $id;
			$namesById[$id] = (string)$element->getName();
		})
	;

	return [
		'ids' => $ids,
		'namesById' => $namesById,
	];
}

    

Пример 3. Когда walk() лучше, чем filter()

У коллекции есть “функциональный” filter(), но он не сработает, если в коллекции есть несохранённые изменения — тогда выбрасывается CollectionFilterException.

Поэтому практическое правило такое:

  • хотите “сначала отобрать, потом поменять” — делайте filter() первым шагом;
  • хотите просто “пройтись и поменять” — берите walk().
        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function deactivateActiveElementsInIblock(int $iblockId): void
{
	Loader::includeModule('iblock');

	$saveResult = ElementTable::query()
		->setSelect(['ID', 'ACTIVE'])
		->where('IBLOCK_ID', $iblockId)
		->fetchCollection()
		->filter(static fn($element) => $element->getActive() == 'Y')
		->walk(static function($element): void {
			$element->setActive('N');
		})
		->save();

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

    

Ограничения, о которых полезно помнить

  • walk() не даёт “прервать” обход по условию. Если нужен ранний выход — используйте foreach или find() у коллекции.
  • В callback вторым аргументом приходит ключ итерации. Он удобен для отладки/логики по PK, но не стоит на него “завязывать” бизнес-логику (особенно при составных ключах).

Итог

walk() — маленький метод, но в D7 он хорошо раскрывается именно в связке fetchCollection() -> walk() -> save(): получается компактный, читабельный и часто более производительный массовый апдейт.

Получение DETAIL_PAGE_URL для элемента инфоблока в D7

Получение DETAIL_PAGE_URL для элемента инфоблока в D7

Проблема/контекст

В новом ядре D7 (таблица Bitrix\Iblock\ElementTable) нет готового поля DETAIL_PAGE_URL. Это связано с тем, что ссылка на детальную страницу — это динамический шаблон (например, /catalog/#SECTION_CODE#/#ELEMENT_CODE#/), который хранится в настройках инфоблока и требует подстановки реальных данных элемента для вычисления.

Решение с кодом

Чтобы получить корректную ссылку, необходимо выбрать данные элемента вместе с шаблоном URL из связанной таблицы инфоблока. Рекомендуется использовать Element API (если у инфоблока задан API_CODE), но способ также работает и с базовым ElementTable.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\Elements\ElementCatalogTable; // Где 'Catalog' — это API_CODE вашего инфоблока
use Bitrix\Main\Loader;

Loader::includeModule('iblock');

$elementId = 123;

// Если у инфоблока нет API_CODE, используйте \Bitrix\Iblock\ElementTable
$element = ElementCatalogTable::getList([
    'select' => [
        'ID',
        'CODE',
        'IBLOCK_SECTION_ID',
        // Получаем шаблон URL из настроек инфоблока через связь IBLOCK
        'DETAIL_PAGE_URL_TEMPLATE' => 'IBLOCK.DETAIL_PAGE_URL'
    ],
    'filter' => ['=ID' => $elementId]
])->fetch();

if ($element)
{
    // Метод ReplaceDetailUrl заменит плейсхолдеры типа #ID#, #CODE# на реальные значения из массива $element
    $detailUrl = \CIBlock::ReplaceDetailUrl(
        url: $element['DETAIL_PAGE_URL_TEMPLATE'],
        arr: $element,
        server_name: true,
        arrType: 'E' // Тип "E" означает Element
    );

    echo $detailUrl;
}

    

Пример с использованием fetchObject()

Если вы предпочитаете работать с объектами (EO_Element), данные для замены масок нужно подготовить через метод collectValues():

        $elementObject = ElementCatalogTable::getList([
    'select' => ['ID', 'CODE', 'IBLOCK_SECTION_ID', 'IBLOCK.DETAIL_PAGE_URL'],
    'filter' => ['=ID' => $elementId]
])->fetchObject();

if ($elementObject)
{
    // ReplaceDetailUrl ожидает массив, поэтому используем collectValues()
    $detailUrl = \CIBlock::ReplaceDetailUrl(
        url: $elementObject->getIblock()->getDetailPageUrl(),
        arr: $elementObject->collectValues(),
        server_name: true,
        arrType: 'E'
    );
}

    

Параметры метода ReplaceDetailUrl

  1. $url (string) — Шаблон URL из настроек инфоблока (например, /catalog/#SECTION_CODE#/#ELEMENT_CODE#/).
  2. $arr (array) — Массив данных элемента. Ключи должны совпадать с масками (ID для #ID#, CODE для #CODE# и т.д.).
  3. $server_name (bool) — Если true, метод подставит SITE_DIR и домен сайта. Полезно для генерации полных ссылок (в письмах или RSS).
  4. $arrType (string) — Тип сущности: "E" для элементов, "S" для разделов. Это определяет, какие маски будут обрабатываться (например, #SECTION_CODE_PATH#).

Практические детали:

  • Обязательные поля: В select запроса getList нужно обязательно включать все поля, которые используются в шаблоне ссылки (обычно это ID, CODE, IBLOCK_SECTION_ID). Если в шаблоне есть #SECTION_CODE#, вам придется добавить в select связь с секцией, например 'SECTION_CODE' => 'IBLOCK_SECTION.CODE'.
  • Element API vs ElementTable: Использование ElementCatalogTable предпочтительнее для новых проектов, так как это дает типизацию и упрощенную работу со свойствами. Но если вы пишете универсальный код или API_CODE не задан, \Bitrix\Iblock\ElementTable работает по тому же принципу.
  • Тип сущности: При вызове ReplaceDetailUrl последний параметр 'E' указывает, что мы работаем с элементом. Для разделов используется 'S'.

Итог

Для получения ссылки в D7 используйте выборку шаблона через связь IBLOCK.DETAIL_PAGE_URL и стандартный метод \CIBlock::ReplaceDetailUrl для подстановки данных. Это наиболее производительный и правильный способ в рамках Bitrix Framework.

Использование локальных баз MaxMind в GeoIp Bitrix для высокой точности

Использование локальных баз MaxMind в GeoIp Bitrix для высокой точности

Стандартные GeoIP-обработчики в 1С-Битрикс часто полагаются на внешние HTTP-сервисы, что вносит задержки в генерацию страницы и создает риски из-за блокировок API. Для проектов, ориентированных на высокую производительность, оптимальным решением является использование локального бинарного файла. Отечественного, к сожалению, актуального не нашёл, но MaxMind со своей бинарной БД (.mmdb) вполне подходит.

Контекст и проблема

При использовании облачных GeoIP-сервисов каждый запрос к сайту может инициировать внешнее соединение, что увеличивает TTFB (Time to First Byte). Кроме того, сторонние API могут быть ограничены для запросов из определенных регионов. Использование локальной базы данных позволяет обрабатывать запросы на стороне сервера со скоростью чтения из памяти, обеспечивая при этом актуальность данных для российских IP-адресов (но проверяйте страну для новых регионов, т.к. MaxMind - зарубежный сервис).

Решение

Для реализации необходимо скачать базу данных (например, бесплатную GeoLite2 City) в формате .mmdb и разместить её на сервере. В административной панели (Настройки -> Настройки продукта -> Геолокация) нужно активировать обработчик «MaxMind (бинарный файл)» и указать путь к файлу. Для скачивания БД с официального сайта MaxMind потребуется регистрация - она бесплатна, но можно поискать зеркала.

Программное взаимодействие осуществляется через Bitrix\Main\Service\GeoIp\Manager. Рекомендуется явно проверять наличие данных на русском языке, так как базы MaxMind по умолчанию содержат мультиязычные записи.

        declare(strict_types=1);

namespace Local\Services;

use Bitrix\Main\Service\GeoIp\Manager;
use Bitrix\Main\Service\GeoIp\MaxMind;
use Bitrix\Main\Error;

final class GeoService
{
    /**
     * Получает детальную информацию о местоположении через локальный MaxMind
     */
    static public function getCityInfo(string $ip = ''): ?array
    {
        if ($ip === '') {
            $ip = Manager::getRealIp();
        }

        // Можно принудительно использовать только локальный обработчик MaxMind
        // Класс обработчика: \Bitrix\Main\Service\GeoIp\GeoIP2
        // Важно не использовать здесь конструкцию \Bitrix\Main\Service\GeoIp\GeoIP2::class
        // т.к. она генерирует строку без ведущего слеша Bitrix\Main\Service\GeoIp\GeoIP2 , а в методе
        // getHandlerByClassName() проверка идёт строго с ведущим (facepalm)
        $handler = Manager::getHandlerByClassName('\\Bitrix\\Main\\Service\\GeoIp\\GeoIP2');

        if (!$handler || !$handler->isActive()) {
            return null;
        }

        // Запрашиваем данные с приоритетом на русском языке
        $result = $handler->getDataResult($ip, 'ru');

        if ($result && $result->isSuccess()) {
            $geoData = $result->getGeoData();

            return [
                'CITY' => $geoData->cityName,
                'REGION_CODE' => $geoData->regionCode,
                'COUNTRY' => $geoData->countryName,
                'IS_IN_RF' => ($geoData->countryCode === 'RU'),
            ];
        }

        return null;
    }
}

// Пример вызова сервиса для получения данных о геолокации
/*
$geoData = GeoService::getCityInfo();
if ($geoData) {
    echo "Ваш город: " . $geoData['CITY'];
}
*/

    

Важно:

При обновлении базы MaxMind вручную (через замену файла), Битрикс подхватит изменения мгновенно, так как менеджер инициализирует обработчик при каждом запросе. Для автоматизации обновления можно использовать кастомный скрипт, скачивающий базу, например, с зеркала и перезаписывающий файл в /local/share/geoip/.

Итог

Использование локального MaxMind через интерфейс GeoIp\Manager — это "золотой стандарт" для Битрикс-разработки. Вы получаете максимальную скорость работы, отсутствие внешних зависимостей и полную совместимость с ядром D7.

Блокировка заказа в Sale для защиты от гонок

Блокировка заказа в Sale для защиты от гонок

Проблема/контекст

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

Решение с кодом

Используйте встроенную блокировку Bitrix\Sale\Order::lock()/unlock() и всегда снимайте её в finally. Перед блокировкой проверьте статус через getLockedStatus(): если заказ уже заблокирован другим пользователем — корректно завершайте операцию (HTTP 409/ошибка).

        <?php
declare(strict_types=1);

use Bitrix\Main\Context;
use Bitrix\Main\Loader;
use Bitrix\Sale\Order;

Loader::includeModule('sale');

$request = Context::getCurrent()->getRequest();

$orderId = (int)$request->getPost('orderId');
if ($orderId <= 0)
{
    throw new \Bitrix\Main\ArgumentException('Invalid orderId');
}

// 1) Проверяем, нет ли чужой блокировки
$lockStatus = Order::getLockedStatus($orderId)->getData(); // LOCK_STATUS, LOCKED_BY, DATE_LOCK
if (($lockStatus['LOCK_STATUS'] ?? null) === Order::SALE_ORDER_LOCK_STATUS_RED)
{
    // Здесь лучше вернуть 409 Conflict и показать, кем/когда заблокировано
    throw new \RuntimeException('Order is locked by another user');
}

$lockResult = Order::lock($orderId);
if (!$lockResult->isSuccess())
{
    throw new \RuntimeException('Failed to lock order: ' . implode('; ', $lockResult->getErrorMessages()));
}

try {
    // 2) Загружаем и меняем заказ строго внутри блокировки
    $order = Order::load($orderId);
    if (!$order)
    {
        throw new \RuntimeException('Order not found');
    }

    // Пример: безопасное изменение статуса и сохранение
    $r = $order->setField('STATUS_ID', 'P'); // ваш код статуса
    if (!$r->isSuccess())
    {
        throw new \RuntimeException('Status update failed: ' . implode('; ', $r->getErrorMessages()));
    }

    $save = $order->save();
    if (!$save->isSuccess())
    {
        throw new \RuntimeException('Order save failed: ' . implode('; ', $save->getErrorMessages()));
    }
} finally {
    // 3) Снимаем блокировку всегда, даже при исключениях
    Order::unlock($orderId);
}

    

Практические детали:

  • Блокируйте как можно ближе к месту изменения и держите блокировку минимальное время.
  • Не делайте внутри блокировки долгие операции (внешние API, генерация файлов, длительные расчёты). Сначала подготовьте данные, затем коротко: lock → load → change → save → unlock.
  • Если операция может выполняться без веб-пользователя (агент/CLI), заранее продумайте контекст прав: unlock() проверяет права текущего пользователя, поэтому тип выполнения важен.

Итог

Order::lock()/unlock() — простой способ избежать потери изменений при параллельных запросах. Ключевое правило — проверка статуса и снятие блокировки в finally.

Обновление свойств и количества товаров в корзине Bitrix через Sale API

Обновление свойств и количества товаров в корзине Bitrix через Sale API

В современной разработке на платформе 1С-Битрикс любые манипуляции с корзиной покупателя должны выполняться исключительно через объектно-ориентированное API модуля «Интернет-магазин» (sale). Распространенной ошибкой среди начинающих разработчиков является попытка прямого изменения записей в таблице базы данных b_sale_basket или использование устаревших методов глобального класса CSaleBasket. Такой подход крайне опасен, поскольку он не инициирует сложную цепочку внутренних бизнес-процессов ядра: автоматический пересчет правил корзины, применение скидок, расчет налогов и актуализацию стоимости доставки. В результате данные в корзине становятся некорректными, что ведет к финансовым потерям и сбоям при оформлении заказа.

Для правильного и безопасного обновления данных необходимо работать с высокоуровневыми объектами корзины. Ключевым инструментом в данном контексте выступает класс Bitrix\Sale\Basket. Загрузка объектов обычно осуществляется через статический метод loadItemsForFUser, который принимает идентификатор владельца корзины (FUser ID) и идентификатор сайта. Это позволяет получить актуальный объект корзины со всеми вложенными элементами, даже если заказ еще не начал оформляться. После того как корзина загружена, вы можете получить доступ к конкретному элементу Bitrix\Sale\BasketItem, используя его внутренний идентификатор или выполнив поиск по PRODUCT_ID.

Объект BasketItem предоставляет набор методов для управления полями записи. Например, метод setField('QUANTITY', $value) позволяет изменить количество товара, а работа с кастомными характеристиками товара осуществляется через специальную коллекцию свойств, доступную через метод getPropertyCollection(). Это критически важно для товаров с торговыми предложениями или индивидуальными параметрами, которые выбирает пользователь.

Пример реализации кода для обновления параметров товара в корзине:

        use Bitrix\Main\Loader;
use Bitrix\Sale\Basket;
use Bitrix\Sale\Fuser;
use Bitrix\Main\Context;

// Обязательная проверка подключения модуля sale
if (Loader::includeModule('sale')) {
    // Получаем внутренний идентификатор корзины текущего пользователя
    $fUserId = Fuser::getId();
    $siteId = Context::getCurrent()->getSite();

    // Загружаем объект корзины со всеми товарами
    $basket = Basket::loadItemsForFUser($fUserId, $siteId);

    // Поиск конкретного элемента корзины по ID товара (PRODUCT_ID)
    // В реальных задачах ID часто передается через AJAX-запрос
    $productId = 201;
    $basketItems = $basket->getExistsItems('catalog', $productId, null); // здесь null - если нам не важны свойства товара, например, размер

    if (!empty($basketItems)) {
        // Берем первый найденный элемент
        $basketItem = current($basketItems);
			
        // 1. Изменение количества товара с автоматической проверкой доступности
        $basketItem->setField('QUANTITY', 5);

        // 2. Управление коллекцией свойств товара (например, цвет или размер)
        $propertyCollection = $basketItem->getPropertyCollection();
        
        // Метод setProperty позволяет массово обновить или добавить свойства
        $propertyCollection->setProperty([
            [
                'NAME' => 'Размер',
                'CODE' => 'SIZE',
                'VALUE' => 'XL',
                'SORT' => 100,
            ],
        ]);

        // 3. Вызов метода save() сохраняет изменения в БД и запускает пересчеты
        $saveResult = $basket->save();

        if (!$saveResult->isSuccess()) {
            // В случае ошибки возвращаем массив описаний проблем
            $errors = $saveResult->getErrorMessages();
            // Логирование ошибок или вывод пользователю
        }
    }
}

    

Важно учитывать контекст выполнения: если объект корзины уже является частью объекта заказа (Bitrix\Sale\Order), то вызывать метод save() непосредственно у корзины не рекомендуется. В этом случае правильнее вызвать сохранение всего заказа через $order->save(). Это гарантирует, что итоговая сумма заказа, налоги и все связанные сущности (отгрузки, оплаты) будут синхронизированы и пересчитаны в рамках одной транзакции.

Использование объектной модели BasketItem — это стандарт качественной разработки, который обеспечивает масштабируемость и стабильность вашего решения при любых обновлениях платформы.

Контроль дневных лимитов SMS через Limitation

Контроль дневных лимитов SMS через Limitation

При массовой отправке SMS-сообщений критически важно контролировать количество отправленных сообщений. Превышение лимитов провайдера приводит к блокировке аккаунта или дополнительным расходам. Модуль messageservice предоставляет класс Bitrix\MessageService\Sender\Limitation для управления дневными лимитами.

Установка дневного лимита

Для каждого провайдера и номера отправителя можно задать индивидуальный лимит:

        use Bitrix\MessageService\Sender\Limitation;

// Устанавливаем лимит 1000 SMS в день для sms.ru с номера +79001234567
Limitation::setDailyLimit('smsru', '+79001234567', 1000);

// Лимит для Twilio
Limitation::setDailyLimit('twilio', 'MGXXXXXXXXXX', 500);

    

После установки лимита все отложенные сообщения автоматически возвращаются в очередь обработки.

Проверка текущего состояния лимитов

Метод getDailyLimits() возвращает полную информацию о лимитах всех провайдеров:

        use Bitrix\MessageService\Sender\Limitation;

$limits = Limitation::getDailyLimits();

foreach ($limits as $key => $data)
{
    // $key = 'smsru:+79001234567'
    echo sprintf(
        "Провайдер: %s, Номер: %s, Лимит: %d, Отправлено: %d\n",
        $data['senderId'],
        $data['fromId'],
        $data['limit'],
        $data['current']
    );
}

    

Проверка лимита перед отправкой

Метод checkDailyLimit() проверяет, доступна ли отправка:

        use Bitrix\MessageService\Sender\Limitation;
use Bitrix\Main\Diag\EventLogger;
use Psr\Log\LogLevel;

$senderId = 'smsru';
$fromNumber = '+79001234567';

if (Limitation::checkDailyLimit($senderId, $fromNumber))
{
    // Лимит не превышен — можно отправлять
    $message = SmsManager::createMessage([
        'MESSAGE_TO' => '+79009876543',
        'MESSAGE_BODY' => 'Ваш код подтверждения: 123456',
    ]);
    $message->send();
}
else
{
    // Лимит исчерпан — сообщение будет отложено
    // Можно уведомить администратора
    $logger = new EventLogger('messageservice', 'SMS_LIMIT_EXCEEDED');
    $logger->log(
        LogLevel::WARNING,
        'Дневной лимит SMS исчерпан для ' . $senderId
    );
}

    

Настройка времени повторной отправки

Если сообщение отложено из-за лимита, оно будет отправлено на следующий день. Время повторной попытки настраивается:

        use Bitrix\MessageService\Sender\Limitation;

// Повторная отправка в 9:00 по московскому времени
Limitation::setRetryTime([
    'h' => 9,
    'i' => 0,
    'auto' => false,
    'tz' => 'Europe/Moscow',
]);

// Получение текущих настроек
$retryTime = Limitation::getRetryTime();
// ['h' => 9, 'i' => 0, 'auto' => false, 'tz' => 'Europe/Moscow']

    

Класс Limitation обеспечивает централизованное управление лимитами отправки SMS для всех провайдеров и защищает от превышения квот.

Работа с датами и временем через Bitrix\Main\Type\DateTime

Работа с датами и временем через Bitrix\Main\Type\DateTime

При работе с датами в 1С-Битрикс критически важно использовать класс Bitrix\Main\Type\DateTime вместо стандартных PHP функций. Это обеспечивает корректную обработку часовых поясов пользователей и унифицированное форматирование дат согласно настройкам сайта.

Проблема

Разработчики часто используют встроенные PHP функции date(), time() или класс DateTime, что приводит к проблемам с часовыми поясами и несоответствием форматов дат в различных частях системы. Особенно это критично при работе с пользовательскими данными и API.

Решение

Класс Bitrix\Main\Type\DateTime предоставляет полный набор методов для работы с датами и временем с учетом настроек Битрикс.

Создание объектов даты

        use Bitrix\Main\Type\DateTime;

// Текущая дата и время
$now = new DateTime();

// Из строки с автоопределением формата
$date1 = new DateTime('2024-12-15 15:30:00', 'Y-m-d H:i:s');

// Из timestamp
$date2 = DateTime::createFromTimestamp(time());

// Из PHP DateTime
$phpDate = new \DateTime();
$date3 = DateTime::createFromPhp($phpDate);

    

Работа с часовыми поясами

        // Преобразование в пользовательское время
$serverDate = new DateTime('2025-12-15 12:00:00', 'Y-m-d H:i:s');
$serverDate->toUserTime(); // Учитывает настройки часового пояса пользователя

    

Форматирование и сравнение

        // Форматирование с учетом формата сайта
$date = new DateTime();
echo $date->toString(); // Использует формат сайта из настроек

// Пользовательский формат
echo $date->format('d.m.Y H:i:s');

// Сравнение дат
$date1 = new DateTime('2025-12-15 10:00:00', 'Y-m-d H:i:s');
$date2 = new DateTime('2025-12-15 15:00:00', 'Y-m-d H:i:s');

if ($date1 < $date2) {
    echo 'date1 раньше date2';
}

    

Модификация дат

        $date = new DateTime();

// Добавление/вычитание интервалов
$date->add('+1 day');
$date->add('+2 hours');
$date->add('-3 months');

// Установка конкретных значений
$date->setTime(14, 30, 0); // Установить время 14:30:00
$date->setDate(2024, 12, 31); // Установить дату

    

Итог

Использование Bitrix\Main\Type\DateTime обеспечивает корректную работу с датами в контексте Битрикс, автоматически учитывает часовые пояса пользователей и настройки форматирования. Это особенно важно при разработке API, работе с базой данных и выводе дат в интерфейсе.

Программная фильтрация данных через Security Filter Request

Программная фильтрация данных через Security Filter Request

Проблема

При интеграции с внешними системами данные поступают не через стандартные суперглобальные переменные, а через API, webhooks или файлы. Встроенный WAF Битрикса автоматически фильтрует только $_GET, $_POST и $_COOKIE, оставляя кастомные источники без защиты от XSS и SQL-инъекций.

Решение

Класс Bitrix\Security\Filter\Request позволяет программно применять те же фильтры безопасности к любым данным. Вы можете подключить нужные аудиторы и выбрать стратегию обработки опасных значений.

        use Bitrix\Main\Loader;
use Bitrix\Main\Diag\EventLogger;
use Bitrix\Security\Filter\Request;
use Bitrix\Security\Filter\Auditor;
use Psr\Log\LogLevel;

Loader::includeModule('security');

// Данные из внешнего источника (API, webhook, файл)
$externalData = [
    'name' => 'Иван Петров',
    'comment' => '<script>alert("xss")</script>Текст комментария',
    'query' => "'; DROP TABLE users; --",
];

// Создаём фильтр с нужным действием
$filter = new Request([
    'action' => 'filter', // filter|clear|none
    'log' => 'Y',         // логировать срабатывания в EventLog
]);

// Подключаем аудиторы для проверки
$filter->setAuditors([
    'XSS' => new Auditor\Xss(),
    'SQL' => new Auditor\Sql(),
]);

// Фильтруем данные (ключ 'data' для произвольных массивов)
$result = $filter->filter(['data' => $externalData]);

// Получаем очищенные данные
$cleanData = $result['data'] ?? $externalData;

    

Параметр action определяет поведение при обнаружении угрозы:

  • filter — удаляет опасные конструкции, сохраняя остальной текст
  • clear — полностью очищает значение при срабатывании
  • none — только логирует, не изменяя данные

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

        // Проверяем, сработал ли какой-либо аудитор
if ($filter->isAuditorsTriggered()) {
    // Добавляем свой формат записи в лог
    $logger = new EventLogger('mymodule', 'EXTERNAL_DATA_FILTERED');
    
    // Получаем список изменённых переменных
    foreach ($filter->getChangedVars() as $varName => $originalValue) {
        $logger->log(
            LogLevel::WARNING,
            'Отфильтровано опасное значение в {variable}',
            [
                'variable' => $varName,
                'original' => $originalValue,
            ]
        );
    }
}

    

Доступные аудиторы находятся в пространстве Bitrix\Security\Filter\Auditor:

  • Xss — обнаружение XSS-атак (скрипты, события, опасные теги)
  • Sql — обнаружение SQL-инъекций (UNION, SELECT, DROP)
  • Path — обнаружение path traversal (../, доступ к файлам)

Итог

Класс Request из модуля security позволяет защитить любые входные данные теми же механизмами, что использует встроенный WAF Битрикса. Используйте его при обработке данных из API, webhooks, импортируемых файлов и других внешних источников.

Signer и TimeSigner для защиты данных от подделки

Signer и TimeSigner для защиты данных от подделки

При передаче данных через URL, формы или AJAX необходимо гарантировать их целостность. Стандартные проверки легко обойти, а хранение данных в сессии усложняет архитектуру. Классы Bitrix\Main\Security\Sign\Signer и TimeSigner решают эту задачу криптографической подписью.

Базовая подпись через Signer

Класс Signer создаёт подпись данных на основе HMAC и системного ключа:

        use Bitrix\Main\Security\Sign\Signer;
use Bitrix\Main\Security\Sign\BadSignatureException;

$signer = new Signer();

// Подписываем данные
$data = 'user_id=15&action=delete';
$signed = $signer->sign($data);
// Результат: "user_id=15&action=delete.a1b2c3d4e5..."

// Проверяем и извлекаем данные
try {
    $original = $signer->unsign($signed);
    // $original === 'user_id=15&action=delete'
} catch (BadSignatureException $e) {
    // Данные были изменены
}

    

Использование salt для изоляции контекстов

Параметр salt позволяет создавать независимые подписи для разных сценариев:

        use Bitrix\Main\Security\Sign\Signer;

$signer = new Signer();

// Подписи с разными salt несовместимы
$tokenA = $signer->sign('data', 'context_a');
$tokenB = $signer->sign('data', 'context_b');

// Проверка требует тот же salt
$signer->unsign($tokenA, 'context_a'); // OK
$signer->unsign($tokenA, 'context_b'); // BadSignatureException

    

Подпись с ограничением времени через TimeSigner

TimeSigner добавляет срок действия подписи — критически важно для одноразовых ссылок:

        use Bitrix\Main\Security\Sign\TimeSigner;

$timeSigner = new TimeSigner();

// Ссылка для сброса пароля, действует 1 час
$token = $timeSigner->sign('user_id=42', '+1 hour');

// Подтверждение email, действует 24 часа
$emailToken = $timeSigner->sign('email=test@example.com', '+1 day');

// Можно указать точный timestamp
$exactToken = $timeSigner->sign('data', strtotime('2025-12-31 23:59:59'));

    

При проверке автоматически контролируется срок:

        try {
    $data = $timeSigner->unsign($token);
} catch (BadSignatureException $e) {
    // "Signature timestamp expired" — время истекло
    // или "Signature does not match" — данные изменены
}

    

Практический пример: защищённая ссылка отписки

        use Bitrix\Main\Security\Sign\BadSignatureException;
use Bitrix\Main\Security\Sign\TimeSigner;
use Bitrix\Main\Web\Json;

// Генерация ссылки
$timeSigner = new TimeSigner();
$payload = Json::encode(['user_id' => 15, 'mailing_id' => 7]);
$token = $timeSigner->sign($payload, '+7 days', 'unsubscribe');

$url = '/unsubscribe/?token=' . urlencode($token);

$request = \Bitrix\Main\Context::getCurrent()->getRequest();
// Обработка перехода
try {
    $data = Json::decode(
        $timeSigner->unsign($request->get('token'), 'unsubscribe')
    );
    // Безопасно отписываем пользователя
} catch (BadSignatureException $e) {
    // Ссылка недействительна или истекла
}

    

Классы Signer и TimeSigner используют системный ключ из настроек и не требуют дополнительной конфигурации. Это надёжный способ защитить любые передаваемые данные от модификации.

Визуальное сравнение текстов через Bitrix\Main\Text\Diff

Визуальное сравнение текстов через Bitrix\Main\Text\Diff

При работе с инфоблоками в режиме документооборота накапливается история версий элементов. Стандартный интерфейс Битрикс показывает список версий, но не позволяет увидеть конкретные изменения между ними. Класс Bitrix\Main\Text\Diff решает эту задачу.

Класс реализует алгоритм Майерса и предоставляет метод getDiffHtml(), который принимает две версии текста и возвращает HTML с подсветкой: удалённые фрагменты выделены красным перечёркнутым текстом, добавленные — зелёным жирным.

Сравнение версий элемента инфоблока

        use Bitrix\Main\Text\Diff;

/**
 * Получает историю версий элемента через скомпилированный ORM класс
 */
function getElementHistory(int $elementId)
{
    return \Bitrix\Iblock\Elements\ElementNewsTable::getList([
        'select' => [
            'ID',
            'NAME',
            'PREVIEW_TEXT',
            'DETAIL_TEXT',
            'TIMESTAMP_X',
            'WF_COMMENTS',
            'WF_STATUS_ID',
            'WF_PARENT_ELEMENT_ID',
            'MODIFIER_' => 'MODIFIED_BY_USER',
        ],
        'filter' => [
            'WF_PARENT_ELEMENT_ID' => $elementId,
        ],
        'order' => ['ID' => 'ASC'],
    ])->fetchCollection();
}


$elementId = 37; // ID элемента в режиме документооборота
$history = getElementHistory($elementId);

if ($history->count() >= 2) {
    $items = $history->getAll();
    $prev = $items[count($items) - 2];
    $curr = $items[count($items) - 1];

    $diff = new Diff();

    echo '<div class="version-diff">';
    echo '<p>Версия #' . $curr->getId() . ' от ' . $curr->getTimestampX() . '</p>';
    echo '<p>Автор: ' . $curr->getModifiedByUser()->getLogin() . '</p>';

    if ($curr->getWfComments()) {
        echo '<p>Комментарий: ' . htmlspecialchars($curr->getWfComments()) . '</p>';
    }

    echo '<h4>Название:</h4>';
    echo $diff->getDiffHtml($prev->getName(), $curr->getName());

    echo '<h4>Описание:</h4>';
    echo $diff->getDiffHtml(
        $prev->getDetailText() ?? '',
        $curr->getDetailText() ?? ''
    );
    echo '</div>';
}

    

Вывод diff для всей истории элемента

        // Формируем полную историю изменений с diff
$history = getElementHistory($elementId);
$diff = new Diff();
$items = $history->getAll();

echo '<div class="history-timeline">';
for ($i = 1; $i < count($items); $i++) {
    $prev = $items[$i - 1];
    $curr = $items[$i];

    printf(
        '<div class="history-item">
            <div class="meta">%s | %s | Статус: %d</div>
            <div class="comment">%s</div>
            <div class="changes">%s</div>
        </div>',
        $curr->getTimestampX()->format('d.m.Y H:i'),
        $curr->getModifiedByUser()?->getLogin() ?? 'N/A',
        $curr->getWfStatusId(),
        htmlspecialchars($curr->getWfComments() ?? ''),
        $diff->getDiffHtml(
            $prev->getDetailText() ?? '',
            $curr->getDetailText() ?? ''
        )
    );
}
echo '</div>';

    

Для программного анализа используйте метод getDiffScript(), который возвращает массив операций с ключами startA, startB, deletedA, insertedB.

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

AI Домовой История

0 / 100

Привет! Я помогу с вопросами по 1С-Битрикс.

Спрашивай про D7, ORM, компоненты или события.

Требуется авторизация

Войдите или зарегистрируйтесь, чтобы задавать вопросы AI-ассистенту.

Войти