#d7

Советы с тегом "d7"

13 советов

Резерв корзины в Sale — не складской документ

Резерв корзины в Sale — не складской документ

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

Резерв позиции заказа и складской резерв — разные сущности. \Bitrix\Catalog\StoreProductTable::QUANTITY_RESERVED показывает итоговое число по паре товар-склад, а снимается оно документом TYPE_UNDO_RESERVE. Строку резерва конкретной позиции корзины ведёт модуль sale — через \Bitrix\Sale\Reservation\BasketReservationService. Правка складского остатка мимо него оставит заказ и склад рассогласованными.

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

Сервис зарегистрирован в sale/.settings.php под ключом sale.basketReservation, зависимость BasketReservationHistoryService собирается в constructorParams. Есть и статический ярлык BasketReservationService::getInstance().

Данные лежат в b_sale_basket_reservation (BasketReservationTable): QUANTITY, DATE_RESERVE, DATE_RESERVE_END, BASKET_ID — обязательные, плюс STORE_ID и RESERVED_BY. Все три метода записи возвращают Bitrix\Main\Result:

        <?php
declare(strict_types=1);

use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;
use Bitrix\Sale\Reservation\BasketReservationService;

Loader::includeModule('sale');

/** @var BasketReservationService $service */
$service = ServiceLocator::getInstance()->get('sale.basketReservation');

$result = $service->add([
    'BASKET_ID' => $basketId,
    'STORE_ID' => $storeId,
    'QUANTITY' => 3.0,
    'DATE_RESERVE' => new DateTime(),
    'DATE_RESERVE_END' => (new DateTime())->add('7 days'),
]);

if ($result->isSuccess())
{
    $service->update((int)$result->getId(), ['QUANTITY' => 5.0]);
}

    

Ключевое отличие от прямой работы с таблицей — история. add() после успешной вставки вызывает addByReservation(), update(int $id, array $fields)updateByReservation(), delete(int $id)deleteByReservation(). История в BasketReservationHistoryTable хранит не снимки, а дельты: при изменении количества с 80 на 90 добавляется строка на 10. По этим дельтам и их датам считается, сколько позиция реально может списать со склада:

        <?php
declare(strict_types=1);

// $ret[$productId][$storeId] => доступное количество
$byOrder = $service->getAvailableCountForOrder($orderId);

$available = $service->getAvailableCountForBasketItem($basketId, $storeId); // float

    

Логика такая: если первым зарезервировали 80 из 100, а вторым — 40, то первая сделка спишет 80, вторая — 20. Порядок резервирования учитывается.

В прикладном коде заказа отдельный вызов сервиса обычно не нужен: ReserveQuantity::addInternal(), updateInternal() и ReserveQuantityCollection::deleteInternal() уже ходят в sale.basketReservation, поэтому $basketItem->getReserveQuantityCollection() пишет историю автоматически. Сервис нужен для диагностики, миграций и внешних интеграций.

Условие резерва и срок хранения приходят из ReservationSettingsService (ключ sale.reservation.settings) — метод get() читает опции sale/product_reserve_condition и sale/product_reserve_clear_period, собирает ReservationSettings и рассылает ReservationSettingsBuildEvent с именем OnReservationSettingsBuild. В обработчике можно вызвать $event->getSettings()->setReserveCondition(ReserveCondition::ON_CREATE) и включить автоматический резерв, не меняя настройки модуля. Допустимые значения — ON_CREATE, ON_PAY, ON_FULL_PAY, ON_ALLOW_DELIVERY, ON_SHIP; setReserveCondition() валидирует их и бросает SystemException.

Итог

Резерв позиции — это строка b_sale_basket_reservation плюс история дельт, а не складской документ. Меняйте её через sale.basketReservation, а условие резервирования переопределяйте событием OnReservationSettingsBuild.

PropertyFeature вместо ручного списка свойств каталога

PropertyFeature вместо ручного списка свойств каталога

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

Контент-менеджер отмечает в карточке свойства галки «Показывать в списке» и «Показывать на детальной», а разработчик в своём компоненте дублирует те же коды в параметре PROPERTY_CODE. Списки расходятся при первом же изменении настроек. Эти галки хранятся не в свойстве, а в отдельной таблице b_iblock_property_feature, и у неё есть публичное API — Bitrix\Iblock\Model\PropertyFeature.

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

Движок фич включается опцией «Использовать параметры свойств в компонентах и формах» property_features_enabled модуля iblock (значение по умолчанию — Y). Проверка — PropertyFeature::isEnabledFeatures(): bool.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\Model\PropertyFeature;
use Bitrix\Main\Loader;

Loader::includeModule('iblock');

if (PropertyFeature::isEnabledFeatures())
{
    // ['COLOR', 'MATERIAL'] или null
    $listCodes = PropertyFeature::getListPageShowPropertyCodes($iblockId, ['CODE' => 'Y']);
    $detailCodes = PropertyFeature::getDetailPageShowPropertyCodes($iblockId, ['CODE' => 'Y']);
}


    

Детали, которые важны на практике:

  • сигнатура — getListPageShowPropertyCodes($iblockId, array $parameters = []): ?array; второй аргумент понимает единственный ключ CODE в верхнем регистре;
  • без ['CODE' => 'Y'] возвращаются ID свойств (строками), а не символьные коды. С CODE => 'Y' вернётся CODE, а при пустом CODE — ID;
  • возвращается null, если движок выключен, $iblockId <= 0 или подходящих свойств нет. Пустого массива не будет — обрабатывайте null явно;
  • выборка фильтрует PROPERTY.ACTIVE = 'Y' и IS_ENABLED = 'Y', сортировка — по PROPERTY.SORT, затем PROPERTY.ID.

Именно так делают штатные компоненты. bitrix:catalog.section наследует Bitrix\Iblock\Component\ElementList: метод initIblockPropertyFeatures() вызывает loadDisplayPropertyCodes(), тот берёт getListPageShowPropertyCodes($iblockId, ['CODE' => 'Y']) для основного инфоблока и для инфоблока торговых предложений и кладёт результат в storage['IBLOCK_PARAMS'][$iblockId]['PROPERTY_CODE'] и OFFERS_PROPERTY_CODE. Точка входа — Base::processResultData(). bitrix:catalog.element через Bitrix\Iblock\Component\Element делает то же самое, но вызывает устаревший алиас getDetailPageShowProperties().

Хранилище — ORM-класс Bitrix\Iblock\PropertyFeatureTable (таблица b_iblock_property_feature, поля PROPERTY_ID, MODULE_ID, FEATURE_ID, IS_ENABLED). Писать напрямую не нужно: есть addFeatures(), updateFeatures(), setFeatures(), все возвращают Bitrix\Main\ORM\Data\Result. CIBlockProperty::Update() сам вызывает setFeatures(), если передан ключ FEATURES.

Свой тип фичи регистрируется обработчиком события модуля iblock с полным именем Bitrix\Iblock\Model\PropertyFeature::OnPropertyFeatureBuildList. Обработчик получает параметры property и description и возвращает EventResult(EventResult::SUCCESS, [...]) с элементами MODULE_ID, FEATURE_ID, FEATURE_NAME. Эталон — Bitrix\Catalog\Product\PropertyCatalogFeature::handlerPropertyFeatureBuildList(), добавляющий IN_BASKET и OFFER_TREE.

Итог

Набор отображаемых свойств задаётся в карточке свойства и лежит в b_iblock_property_feature. Читайте его через PropertyFeature::getListPageShowPropertyCodes() / getDetailPageShowPropertyCodes() с ['CODE' => 'Y'] и проверкой на null — тогда кастомный компонент покажет ровно то же, что catalog.section.

Text\Emoji: 4-байтовые символы в колонках utf8

Text\Emoji: 4-байтовые символы в колонках utf8

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

Колонка в кодировке utf8 (utf8mb3) в MySQL хранит максимум три байта на символ. Эмодзи и часть CJK-расширений занимают четыре байта, поэтому запись обрывается на первом таком символе и остаток текста теряется. Переводить рабочую базу в utf8mb4 ради одного поля дорого, и ядро идёт другим путём — классом Bitrix\Main\Text\Emoji.

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

Emoji::encode() прогоняет строку через preg_replace_callback и заменяет каждую четырёхбайтовую последовательность на ":" . bin2hex($match) . ":". Кодируются байты UTF-8, а не кодовая точка: 😊 (U+1F60A) даёт :f09f988a: — 10 символов вместо 4 байт. Паттерн покрывает плоскости 1–16 (\xF0[\x90-\xBF], [\xF1-\xF3], \xF4[\x80-\x8F]), трёхбайтовые символы не затрагиваются.

Emoji::decode() работает по собственной регулярке /:([A-F0-9]{8}):/isu и возвращает подстроку без изменений, если hex2bin() не дал валидную четырёхбайтовую последовательность. Обратная сторона: строка :f09f988a:, набранная пользователем руками, при чтении станет эмодзи.

Ключевая деталь модификаторов: getSaveModificator() и getFetchModificator() ничего не преобразуют. Каждый возвращает массив колбэков — [[Emoji::class, 'encode']] и [[Emoji::class, 'decode']]. В параметр поля кладут именно фабрику: Field::getSaveDataModifiers() вызывает её через call_user_func() и бросает SystemException, если вернулся не массив. Так объявлены поля в Bitrix\Blog\PostTable, Bitrix\Vote\QuestionTable, моделях im и landing:

        'TITLE' => [
    'data_type' => 'string',
    'save_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getSaveModificator'],
    'fetch_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getFetchModificator'],
],

    

В объектной карте полей удобнее добавлять колбэки напрямую — они проходят проверку is_callable без фабрики:

        <?php
declare(strict_types=1);

namespace Vendor\Module\Model;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
use Bitrix\Main\ORM\Fields\TextField;
use Bitrix\Main\Text\Emoji;

final class CommentTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'vendor_module_comment';
    }

    public static function getMap(): array
    {
        return [
            (new IntegerField('ID'))->configurePrimary()->configureAutocomplete(),
            (new StringField('TITLE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
            (new TextField('MESSAGE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
        ];
    }
}

    

Save-модификаторы применяются в DataManager в add(), update() и их multi-вариантах через Field::modifyValueBeforeSave(). Fetch-модификаторы собирает Query::isFetchModificationRequired() и применяет к алиасам выборки, то есть только к полям, попавшим в select.

Границы механизма:

  • фильтр не модифицируется, поиск по значению с эмодзи требует ручного вызова:
        CommentTable::getList([
    'filter' => ['=TITLE' => Emoji::encode($userInput)],
]);

    
  • закодированное значение длиннее исходного, для varchar закладывайте запас;
  • сырые запросы через Connection модификаторы не проходят — вызывайте Emoji::encode()/decode() сами;
  • флаг колонки читается через Application::getConnection()->isUtf8mb4($table, $column) из секции utf8mb4 в connections. Компоненты соцсети кодируют текст только при false, но ORM-модификаторы срабатывают всегда, независимо от этой настройки.

Итог

Text\Emoji держит четырёхбайтовые символы в трёхбайтовой колонке ценой десяти ASCII-символов на эмодзи. Подключайте модификаторы в карте полей, а фильтры и прямые SQL-запросы кодируйте вручную.

Парсер телефонов и страна по умолчанию в #[Phone]

Парсер телефонов и страна по умолчанию в #[Phone]

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

Атрибут #[Phone] из Bitrix\Main\Validation\Rule выглядит как проверка формата строки. На деле он собирает единственный валидатор PhoneValidator, а тот вызывает Parser::getInstance()->parse($value)->isValid() — без второго аргумента. Внутри parse() пустая страна заменяется на Parser::getDefaultCountry(), поэтому один и тот же номер без + на двух порталах даст разный результат валидации.

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

Порядок источников в Parser::getDefaultCountry():

  1. Option::get('main', 'phone_number_default_country') — в опции хранится ID страны, а не двухбуквенный код. Значение задаётся в настройках главного модуля полем «Страна по умолчанию».
  2. Если опция пуста, вызывается detectCountry(): сначала REG_COUNTRY модуля bitrix24, затем портальная зона из списка br, cn, de, in, ru, ua, by, kz, fr, pl, uz, затем Context::getCurrent()->getLanguage() из списка br, cn, de, in, ru, ua, by, kz, fr, pl, и только в конце GeoIp\Manager::getCountryCode().
  3. Найденный код конвертируется в ID и записывается в опцию — первый же вызов фиксирует страну на всё время жизни портала.
  4. Наружу возвращается GetCountryCodeById($id), при неудаче — пустая строка.

Пустая строка означает, что MetadataProvider::getCountryMetadata('') вернёт false, и parse() отдаст невалидный PhoneNumber для любого номера без +. Номер с + разбирается по коду страны из самой строки, и $defaultCountry игнорируется — это единственный формат, устойчивый к настройкам портала.

В коде модуля страну задавайте явно вторым аргументом:

        <?php declare(strict_types=1);

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

$number = Parser::getInstance()->parse('8 (916) 123-45-67', 'RU');

$number->isValid();            // true
$number->getCountry();         // RU
$number->getCountryCode();     // 7
$number->format(Format::E164); // +79161234567

    

Сигнатура — PhoneNumber::format($formatType = '', $forceNationalPrefix = false). Константы класса Bitrix\Main\PhoneNumber\Format: E164 (значение 'E.164'), INTERNATIONAL, NATIONAL. При пустом $formatType вызывается Formatter::formatOriginal() — он сохраняет исходное написание и откатывается к сырой строке, если форматирование изменило состав цифр. Для невалидного номера format() всегда возвращает getRawNumber(), поэтому isValid() проверяем до форматирования, а не после.

В базе храните Format::E164 — это '+' . countryCode . nationalNumber без разделителей, единственный вид, пригодный для поиска дублей и сравнения:

        <?php declare(strict_types=1);

namespace Vendor\Module\Application\Service;

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

final class PhoneNormalizer
{
    public function __construct(private readonly string $defaultCountry = 'RU') {}

    public function normalize(string $raw): ?string
    {
        $number = Parser::getInstance()->parse($raw, $this->defaultCountry);

        return $number->isValid() ? $number->format(Format::E164) : null;
    }
}

    

Если валидация должна быть привязана к стране, напишите свой валидатор — интерфейс состоит из одного метода:

        <?php declare(strict_types=1);

namespace Vendor\Module\Validation\Validator;

use Bitrix\Main\Localization\LocalizableMessage;
use Bitrix\Main\PhoneNumber\Parser;
use Bitrix\Main\Validation\ValidationError;
use Bitrix\Main\Validation\ValidationResult;
use Bitrix\Main\Validation\Validator\ValidatorInterface;

final class CountryPhoneValidator implements ValidatorInterface
{
    public function __construct(private readonly string $country) {}

    public function validate(mixed $value): ValidationResult
    {
        $result = new ValidationResult();

        if (!is_string($value) || !Parser::getInstance()->parse($value, $this->country)->isValid())
        {
            $result->addError(new ValidationError(
                new LocalizableMessage('MAIN_VALIDATION_PHONE_INVALID'),
                failedValidator: $this,
            ));
        }

        return $result;
    }
}

    

Подключается он через собственный атрибут, унаследованный от AbstractPropertyValidationAttribute, с возвратом валидатора из getValidators().

Итог

#[Phone] проверяет номер относительно страны портала, а не относительно вашей предметной области, и эта страна может прийти из зоны Битрикс24, языка или GeoIP. Задавайте страну явно в Parser::parse() и храните результат в Format::E164.

Application::addBackgroundJob — что происходит после ответа клиенту

Application::addBackgroundJob — что происходит после ответа клиенту

Проблема

Метод \Bitrix\Main\Application::addBackgroundJob() часто используют по памяти: передают callback с use и надеются, что «задача выполнится где-то в фоне». На практике у метода строгая сигнатура и несколько неочевидных побочных эффектов из Application::terminate(), о которых молчит PHPDoc. Разберём реальное поведение по коду ядра main/lib/application.php.

Решение

Сигнатура из ядра (main/lib/application.php):

        public function addBackgroundJob(
    callable $job,
    array $args = [],
    $priority = self::JOB_PRIORITY_NORMAL
);

    

Аргументы задачи — это второй параметр $args, а не замыкание с use. Оба варианта работают (внутри вызов идёт через call_user_func_array), но штатный путь — именно $args:

        \Bitrix\Main\Application::getInstance()->addBackgroundJob(
    [\Vendor\Module\Application\Service\Notifier::class, 'sendWelcome'],
    [$userId, $locale],
    \Bitrix\Main\Application::JOB_PRIORITY_NORMAL,
);

    

Доступные приоритеты: JOB_PRIORITY_NORMAL = 100, JOB_PRIORITY_LOW = 50. Очередь построена на SplPriorityQueue — большее число выполняется раньше.

Что именно происходит после отправки ответа, видно в terminate():

        // псевдокод из Application::terminate()
\CMain::RunFinalActionsInternal();
session_write_close();              // 1. Сессия закрыта
$pool = $this->getConnectionPool();
$pool->useMasterOnly(true);         // 2. Только мастер БД
$this->runBackgroundJobs();         // 3. Запуск джобов
$pool->useMasterOnly(false);

    

К моменту запуска джобов HttpResponse::send() уже вызвал fastcgi_finish_request() — клиент получил ответ и закрыл соединение. Отсюда прикладные следствия:

  1. Писать в $_SESSION бесполезно — сессия закрыта. Нужна сессия — используйте отдельное хранилище или перенесите логику в Messenger.
  2. Все SQL-запросы идут в мастер, реплики не используются — держите задачи короткими.
  3. Исключения внутри джобов ловятся и логируются через ExceptionHandler::writeToLog(), но последнее исключение после прогона всей очереди пробрасывается наверх. На ответ клиента это не повлияет, но зафиксируется в логах php-fpm и может оборвать воркер.
  4. Задачи могут добавлять новые задачи — внешний while ($this->backgroundJobs->valid()) дренирует очередь до пустоты. Следите за идемпотентностью, чтобы не получить бесконечный цикл.
  5. Нет гарантии доставки: падение процесса (OOM, таймаут) — задача теряется. Для надёжной обработки с ретраями используйте Messenger.

Итог

addBackgroundJob — это не «очередь», а хвост текущего запроса с уже закрытой сессией и мастер-соединением. Подходит для короткой работы в несколько сотен миллисекунд: метрики, уведомления, отложенные логи. Всё, что требует гарантий, — только через Bitrix\Main\Messenger.

Как удаляются связи OneToMany и ManyToMany в D7 ORM

Как удаляются связи OneToMany и ManyToMany в D7 ORM

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

В D7-связях удаление часто понимают слишком упрощенно: если сущности связаны, значит ядро само "разрулит" каскад. Но в исходниках ORM поведение у OneToMany и ManyToMany принципиально разное, и ошибка в ожиданиях быстро приводит либо к лишнему удалению, либо к зависшим данным.

Ключевая точка здесь Bitrix\Main\ORM\Objectify\EntityObject::delete(). Для OneToMany метод смотрит на getCascadeDeletePolicy() и либо удаляет дочерние объекты, либо снимает ссылку через sysRemoveAllFromCollection(). Для ManyToMany логика иная: при удалении объекта ядро всегда очищает таблицу-посредник, а сами связанные сущности не удаляет. Это напрямую следует из связки EntityObject::delete(), sysRemoveAllFromCollection() и sysSaveRelations().

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

Если дочерняя запись принадлежит только одному владельцу, используйте OneToMany и явно задавайте политику удаления:

        <?php
use Bitrix\Main\ORM\Fields\Relations\CascadePolicy;
use Bitrix\Main\ORM\Fields\Relations\OneToMany;

final class OrderTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении заказа удалятся и его позиции
            (new OneToMany('ITEMS', OrderItemTable::class, 'ORDER'))
                ->configureCascadeDeletePolicy(CascadePolicy::FOLLOW),
        ];
    }
}

    

Если FOLLOW не указан, у OneToMany по умолчанию работает SET_NULL: ядро не удаляет дочерние строки, а отвязывает их от родителя. Это удобно для журналов, уведомлений и других записей, которые могут жить дольше основной сущности.

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

        <?php
use Bitrix\Main\ORM\Fields\Relations\ManyToMany;

final class ArticleTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении статьи ядро очистит только b_article_tag
            (new ManyToMany('TAGS', TagTable::class))
                ->configureTableName('b_article_tag'),
        ];
    }
}

    

После $article->delete() будут удалены строки из b_article_tag, потому что sysSaveRelations() удаляет записи посредника через data class медиаторной сущности. Сами теги останутся. Поэтому ManyToMany подходит там, где связь должна исчезнуть, а партнерская сущность продолжает использоваться в системе. Если же нужно удалять и "осиротевшие" записи, не полагайтесь на ManyToMany: описывайте таблицу связи явно или запускайте отдельный сервис очистки после удаления основной сущности.

Итог

Для OneToMany в D7 важно выбирать политику FOLLOW или SET_NULL осознанно. Для ManyToMany безопасно рассчитывать только на очистку таблицы связей, а не на удаление связанных объектов.

Как защитить агент импорта от двойного запуска с помощью 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.

Получение 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.

Обновление свойств и количества товаров в корзине 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 — это стандарт качественной разработки, который обеспечивает масштабируемость и стабильность вашего решения при любых обновлениях платформы.

Работа с изображениями через Bitrix\Main\File\Image

Работа с изображениями через Bitrix\Main\File\Image

Разработчики часто используют CFile::ResizeImageGet() для изменения размеров изображений, не подозревая, что эта функция является обёрткой над современным D7 API. Классы Bitrix\Main\File\Image предоставляют прямой доступ к операциям с изображениями, что даёт больше контроля и гибкости.

Основные операции с Image

        use Bitrix\Main\File\Image;
use Bitrix\Main\File\Image\Rectangle;
use Bitrix\Main\File\Image\Mask;

$image = new Image('/path/to/image.jpg');
$image->load();

// Получение информации о изображении
$info = $image->getInfo();
echo $info->getWidth() . 'x' . $info->getHeight(); // размеры
echo $info->getMime(); // MIME-тип
echo $info->getFormat(); // Image::FORMAT_JPEG, FORMAT_PNG, etc.

// Изменение размера (пропорционально)
$source = $image->getDimensions();
$destination = new Rectangle(800, 600);
$source->resize($destination, Image::RESIZE_PROPORTIONAL);
$image->resize($source, $destination);

// Сохранение с качеством 85%
$image->save(85);

    

Аналог unsharpmask из CFile::ResizeImageGet

        // CFile::ResizeImageGet по умолчанию применяет sharpen с precision=15
$mask = Mask::createSharpen(15);
$image->filter($mask);

    

Режимы изменения размера

        // RESIZE_PROPORTIONAL - сохраняет пропорции, вписывает в указанный прямоугольник
$source->resize($destination, Image::RESIZE_PROPORTIONAL);

// RESIZE_EXACT - кадрирует изображение по центру до точных размеров
$source->resize($destination, Image::RESIZE_EXACT);

// RESIZE_PROPORTIONAL_ALT - учитывает ориентацию (портрет/ландшафт)
$source->resize($destination, Image::RESIZE_PROPORTIONAL_ALT);

    

Расширенные возможности

        // Поворот и отражение
$image->rotate(90); // поворот на 90°
$image->flipHorizontal(); // зеркальное отражение
$image->autoRotate($exifOrientation); // автокоррекция по EXIF

// Размытие
$image->blur(10); // sigma от 1 до 100

// Водяной знак (изображение)
use Bitrix\Main\File\Image\ImageWatermark;

$watermark = new ImageWatermark('/path/to/watermark.png');
$watermark->setAlignment('right', 'bottom')
    ->setPadding(20)
    ->setAlpha(0.7);
$image->drawWatermark($watermark);

// Сохранение в другой формат
$image->saveAs('/path/to/output.webp', 85, Image::FORMAT_WEBP);

    

Выбор движка: GD или Imagick

        // По умолчанию используется GD
// Для Imagick зарегистрируйте сервис:
use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\File\Image\Imagick;

$serviceLocator = ServiceLocator::getInstance();
$serviceLocator->registerByCreator('main.imageEngine', fn() => new Imagick());

    

Классы Bitrix\Main\File\Image полностью покрывают функциональность CFile::ResizeImageGet(), включая proportional resize, exact crop, sharpen-фильтр и водяные знаки. Для типовых задач resizing проще использовать CFile::ResizeImageGet(), а для сложных сценариев с цепочкой операций — классы напрямую.

InsertIgnore стратегия для безопасной вставки без дублирования

InsertIgnore стратегия для безопасной вставки без дублирования

Проблема дублирования записей

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

Стратегия InsertIgnore

С версии 22.0 в Bitrix ORM появилась стратегия InsertIgnore, которая использует SQL-конструкцию INSERT IGNORE. Если запись с таким ключом существует, она игнорируется без ошибки. Это атомарная операция на уровне СУБД, что обеспечивает корректность даже при конкурентных запросах.

Переопределение стратегии в Table-классе

Самый простой способ — переопределить метод getAddStrategy() в вашем Table-классе:

        namespace Mycompany\MyModule;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Data\AddStrategy;

class UserTokenTable extends DataManager
{
    public static function getTableName()
    {
        return 'b_user_token';
    }
    
    public static function getMap()
    {
        return [
            'ID' => ['data_type' => 'integer', 'primary' => true, 'autocomplete' => true],
            'USER_ID' => ['data_type' => 'integer', 'required' => true],
            'TOKEN' => ['data_type' => 'string', 'required' => true],
        ];
    }
    
    // Переопределяем стратегию для всех операций add()
    protected static function getAddStrategy(): AddStrategy\Contract\AddStrategy
    {
        return new AddStrategy\InsertIgnore(static::getEntity());
    }
}

// Теперь add() использует INSERT IGNORE автоматически
$result = UserTokenTable::add([
    'ID' => 1,
	  'USER' => 1,
    'TOKEN' => 'abc123'
]);

// Если запись существует - игнорируется без ошибки
// $result->isSuccess() вернёт true
// $result->getId() вернёт ID существующей записи

    

Массовая вставка с InsertIgnore

Для массовой вставки данных используйте метод addMulti():

        use Bitrix\MyModule\UserTokenTable;

$tokens = [
    ['ID' => 10, 'USER_ID' => 1, 'TOKEN' => 'token1'],
    ['ID' => 11, 'USER_ID' => 2, 'TOKEN' => 'token2'],
    ['ID' => 12, 'USER_ID' => 3, 'TOKEN' => 'token3'],
    ['ID' => 10, 'USER_ID' => 1, 'TOKEN' => 'token1'], // дубликат - будет проигнорирован
];

// Все записи вставляются одним запросом
$result = UserTokenTable::addMulti($tokens);

// Проверка успешности
if ($result->isSuccess()) {
    // Операция выполнена, дубликаты проигнорированы
}

    

Указание уникальных полей

По умолчанию InsertIgnore проверяет первичный ключ. Если нужно проверять другие поля, передайте их в конструктор:

        protected static function getAddStrategy(): AddStrategy\Contract\AddStrategy
{
    // Проверять уникальность по полям USER_ID и TOKEN
    return new AddStrategy\InsertIgnore(
        static::getEntity(),
        ['USER_ID', 'TOKEN']
    );
}

    

Проверка изменений базы

Метод isDBChanged() в результате показывает, была ли реально изменена база:

        $result = UserTokenTable::add(['USER_ID' => 1, 'TOKEN' => 'abc']);

if ($result->isSuccess()) {
    if ($result->getData()['isDBChanged']) {
        // Запись была вставлена
    } else {
        // Запись уже существовала и была проигнорирована
    }
}

    

Важные ограничения

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

Когда использовать InsertIgnore

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

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

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

на связи

пишет…
Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти