Загрузка
Все статьи

Как я писал микро-сервис для назначения партнёров турам в Битрикс24: разбор реального кода и боль работы со списками (Enum)

Как назначить партнёра туру в Битрикс24, если фронтенд присылает только текст, а REST API требует числовой ID? В этой статье я разбираю свой боевой PHP-скрипт: от вытягивания справочников enum до реализации нечеткого поиска подстрок и борьбы с «молчащими» ошибками метода crm.product.update. Реальный код, паранойя с логированием и обходные пути для неидеальных данных.

Привет! Меня зовут Артём, я бэкенд-разработчик. Если вы читали мои предыдущие статьи, то знаете, что я обожаю копаться во внутренностях Битрикс24. Обычно я рассказываю про масштабные интеграции с OAuth, Batch API и очередями сообщений. Но сегодня мы разберем совершенно другой кейс — узкоспециализированный, но невероятно частый.

Недавно ко мне обратился заказчик из сферы туризма (если кто знаком с Питером, то понимаете, каких именно краеведческих туров там больше, чем гранитных ступенек). У них была проблема: в Битрикс24 каталог товаров — это их туры. А у каждого тура есть свойство типа «Список» (ID 133), в котором хранятся партнёры (гиды, транспортные компании, музеи).

Менеджерам было лень открывать тяжёлые карточки товаров в самом Битриксе, они просили простую кнопку в их старой админке: «Выбрал тур -> Выбрал партнёра из выпадающего списка -> Нажал сохранить».

Звучит просто, правда? Но когда я начал писать интеграцию под это, я столкнулся с классическим «адом свойств типа Список в REST API Битрикс24». В этой статье я дам вам мой реальный рабочий код и подробно разберу каждую строчку — от того, как правильно получить справочник значений, до того, как реализовать нечеткий поиск названий, если данные из админки не совпадают с базой. Погнали.

Главная боль API Битрикс24: Свойства типа «Список» под капотом

Прежде чем я покажу вам код микро-сервиса, который я в итоге написал, нужно понять, почему эта задача вообще стала проблемой. Если вы не сталкивались с внутренностями REST API Битрикс24, вам может показаться, что обновить пару полей у товара — это дело пяти строчек на PHP. Но это не так.

Вся загвоздка кроется в том, как Битрикс24 хранит данные типа «Список» (в интерфейсе это выпадающие списки, множественные чекбоксы и т.д.). Когда вы в настройках CRM создаете свойство товара и добавляете туда значения (например, «ООО Ромашка», «ИП Иванов»), Битрикс сохраняет это не как простой текст. Под капотом это превращается в сущность enum.

У каждого элемента в этом списке есть две важные характеристики:

  1. VALUE — это то, что видит человек (текст «ООО Ромашка»).
  2. ID — это внутренний числовой идентификатор (например, 151, 152), под которым это лежит в базе данных.

Типичная ошибка новичка: попробовать отправить в метод crm.product.update просто строку с названием:

'fields' => ['PROPERTY_133' => 'ООО Ромашка']

Что сделает Битрикс? Он это молча проигнорирует. Свойство просто не обновится. REST API жестко требует, чтобы вы передавали строго ID значения списка, причем в виде массива, даже если значение всего одно:

'fields' => ['PROPERTY_133' => ['151']]

Но постойте, наша внешняя админка ничего не знает про эти ID! Она знает только текстовые названия из своей собственной базы. Как решить эту проблему? Нужно сначала спросить у Битрикса его справочник, найти там нужное текстовое название, вытянуть его ID, и только потом обновлять товар.

Метод crm.product.property.get и мой getEnumMap()

Чтобы получить все варианты списка для конкретного свойства (у нас это 133), нужно дернуть метод crm.product.property.get, передав ему ID свойства. В ответ придет достаточно громоздкая структура с кучей вложенных массивов, служебных полей и сортировок.

 

Чтобы не мучаться с парсингом этой структуры каждый раз в разных местах кода, я написал метод-обертку getEnumMap(). Его единственная задача — сходить в Битрикс и вернуть мне простую, удобную associative map (словарь), где ключ — это ID, а значение — это текст.

/**
     * Возвращает карту [enumID => название]
     */
    public function getEnumMap(): array
    {
        $map    = [];
        $result = $this->call('crm.product.property.get', ['id' => 133]);

        if (!$result['success']) {
            return $map;
        }

        $values = $result['data']['VALUES'] ?? [];
        if (!is_array($values)) {
            return $map;
        }

        foreach ($values as $item) {
            $id = (string) ($item['ID'] ?? '');
            $name = $item['VALUE'] ?? $item['NAME'] ?? '';
            if ($id !== '') {
                $map[$id] = $name;
            }
        }

        return $map;
    }

Разберем этот кусок. Мы берем $result['data']['VALUES']. Я использую оператор ?? (null coalescing operator), чтобы избежать ошибок, если Битрикс вдруг решит не отдать поле VALUES (а в API Битрикса бывает всё, особенно после обновлений). Затем я явно привожу ID к строке (string). Это делается для того, чтобы позже, при сравнении массивов, у меня не было конфликтов типов (integer vs string), на которые PHP иногда капризничает.

В итоге я получаю массив вида:

[
    "151" => "ООО Ромашка",
    "152" => "ИП Иванов",
    "155" => "Старый гид Перов"
]

Теперь, когда мне нужно найти ID «ООО Ромашка», я просто делаю array_search('ООО Ромашка', $map). Красота.

Проблема «Грязных данных» и нечеткий поиск (Fuzzy Matching)

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

 

Но мы живем в реальном мире клиентских правок и легаси-кода. У заказчика была старая админка, написанная еще при царе Горохе. Фронтенд мог прислать мне всё что угодно: просто строку "Самый лучший гид", массив строк ["Самый лучший гид", "Другой gid"], или даже странный JSON-объект формата [{id: "165", name: "Самый"}].

 

Более того, в справочнике Битрикса партнер может быть записан как "Самый лучший gid (Иванов И.И.)", а из админки прилетит просто "Самый лучший gid". Обычное строгое сравнение через === тут сломается моментально, и партнер не назначится.

Моя реализация matchStrings()

Мне нужен был алгоритм нечеткого поиска (Fuzzy Matching). В PHP есть расширение php_similar_text, есть функции для вычисления расстояния Левенштейна. Но для моей задачи они были бы оверхедом. Справочник партнеров там на 30 элементов, нет смысла подключать тяжёлые библиотеки для вычисления сложности строк.

 

Я решил использовать простую, но очень эффективную проверку вхождения подстроки без учета регистра.

 

private function matchStrings(string $a, string $b): bool
{
    return stripos($a, $b) !== false || stripos($b, $a) !== false;
}

Как это работает на практике: Если мы ищем "Лучший gid", а в справочнике Битрикса лежит "Самый лучший gid (Питер)", функция проверяет две вещи:

  1. Входит ли "Лучший gid" в "Самый лучший gid (Питер)"? Функция stripos находит подстроку на позиции 6, возвращает не false, условие срабатывает.
  2. Если бы не вошло (например, мы бы искали "Питер"), функция проверила бы обратное вхождение: не содержится ли длинный справочник внутри короткого запроса.

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

Логика поиска и обновления: Метод processTour() под микроскопом

Это сердце моего скрипта. Метод processTour() принимает название тура и массив партнёров, а дальше начинается настоящая магия и проверка на прочность.

Шаг 1: Нормализация входящих данных

Первым делом мне нужно привести присланные данные к единому, предсказуемому виду — массиву строк. Я не знаю, в каком формате прилетят данные, поэтому проверяю все варианты:

 $partnerNames = [];
 $partnerIds   = [];
foreach ($partners as $p) {
    if (is_array($p)) {
        // Если прислали объект {id: "165", name: "Самый"}, вытаскиваем оба значения
        if (isset($p['name']) && is_string($p['name'])) {
            $partnerNames[] = $p['name'];
        }
        if (isset($p['id']) && is_string($p['id'])) {
            $partnerIds[] = $p['id'];
        }
    } elseif (is_string($p)) {
        $partnerNames[] = $p;
    }
}

Заметьте, я разделяю то, что может быть ID, и то, что может быть именем. Это нужно для следующего шага.

Шаг 2: Поиск самого товара (тура)

Теперь мне нужно найти товар в Битриксе по названию тура, которое прислал фронтенд. Я использую метод crm.product.list с фильтром по подстроке. В Битриксе для этого используется модификатор %:

 $filter = ['NAME' => '%' . $tour . '%'];
 $result = $this->call('crm.product.list', [
    'filter' => $filter,
    'select' => ['ID', 'NAME', 'PROPERTY_133'],
    'order'  => ['ID' => 'DESC'],
]);
 Важный нюанс: Посмотрите на строку 'order' => ['ID' => 'DESC']. Почему я сортирую по убыванию ID? У заказчика в базе были дубликаты названий туров: "Обзорная экскурсия 2022" и "Обзорная экскурсия 2023". Если бы я сортировал по возрастанию, мой скрипт всегда цеплялся бы за старый, неактуальный тур. Сортировка по убыванию гарантирует, что $result['data'][0] выдаст мне самый свежий товар. Простая, но спасительая архитектурное решение для неидеальных баз.
 

Шаг 3: Двухэтапный мэтчинг партнёра

Когда товар найден, мне нужно сопоставить присланного партнёра со справочником Битрикса. Я делаю это в два этапа для оптимизации:

// Шаг 1: Прямое совпадение по ID (Быстро и точно)
foreach ($partnerIds as $pId) {
    if (isset($enumMap[$pId])) {
        $matchedEnum = $pId;
        $matchedName = $enumMap[$pId];
        $matchMethod = 'by_id';
        break;
    }
}

// Шаг 2: Если ID не совпали — ищем по имени (Медленно, но надежно)
if ($matchedEnum === null) {
    foreach ($partnerNames as $partnerName) {
        foreach ($enumMap as $id => $name) {
            $matchResult = $this->matchStrings($name, $partnerName);
            if ($matchResult) {
                $matchedEnum = $id;
                $matchedName = $name;
                $matchMethod = 'by_name';
                break 2; // Выходим из обоих циклов
            }
        }
    }
}
 Если фронтенд прислал ID, мы просто проверяем его наличие в словаре enumMap — это операция со сложностью O(1). Если ID нет (или фронтенд сломался), мы запускаем вложенный цикл и прогоняем строки через мою функцию stripos. Да, это O(N*M), но при справочнике в 30 элементов на бэкенде это отработает за 1 миллисекунду, пользователь ничего не заметит.

Самая тонкая часть: Как правильно обновить свойство и как его ОЧИСТИТЬ

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

По ТЗ заказчика, если менеджер передал пустой список партнёров, это значит, что партнера у тура больше нет, и свойство нужно очистить (снять галочку).

Как вы думаете, как правильно отправить пустое значение в REST API Битрикс24?

  • Отправить null? Нет, Битрикс ругнется на формат.
  • Отправить пустую строку ""? Нет, Битрикс проигнорирует.
  • Отправить false? Тоже мимо.

Огромная засада Битрикс24 API: Чтобы очистить свойство типа «Список», вы обязаны отправить пустой массив [].

Посмотрите, как изящно и безопасно это решено в моем методе setProductProperty:

private function setProductProperty(int $productId, string $value): array
{
    // Если значение пустое, формируем пустой массив!
    $propValue = ($value === '') ? [] : [$value];

    $result = $this->call('crm.product.update', [
        'id'     => $productId,
        'fields' => ['PROPERTY_133' => $propValue],
    ]);

    if (!$result['success']) {
        return $this->error('Ошибка обновления товара: ' . $result['error']);
    }

    // Перечитываем товар для проверки
    $check = $this->call('crm.product.get', ['id' => $productId]);
    $realValue = ($check['success']) ? ($check['data']['PROPERTY_133'] ?? '') : '';

    return ['success' => true, 'real_property_133' => $realValue];
}

Тернарный оператор $propValue = ($value === '') ? [] : [$value]; — это щит от кривых данных. Если пришел ID партнера, он оборачивается в массив (как требует Битрикс). Если пришла пустота — отправляется пустой массив, и Битрикс честно очищает поле в базе.

Обратите внимание еще на одну деталь в этом методе. После обновления я делаю лишний запрос: $this->call('crm.product.get', ['id' => $productId]). Зачем? Помните, я говорил, что Битрикс часто врет? Он может вернуть success: true на update, но по какой-то внутренней причине не обновить поле (например, если у пользователя нет прав на изменение именно этого свойства, хотя права на сам товар есть). Перечитывая товар сразу после обновления, я возвращаю в фронтенд реальное значение поля real_property_133. Фронтенд может сравнить то, что просил, с тем, что реально стало, и показать алерт менеджеру, если что-то пошло не так.

Паранойя разработчика: Как и зачем я логирую каждый шаг

В предыдущих статьях я говорил, что сложные интеграции нужно делать асинхронно через RabbitMQ. Но этот скрипт — микро-сервис для внутренних нужд, им пользуются 3 менеджера. Я сделал его синхронно, но компенсировал это сумасшедшим логированием.

 

Знаете, какая самая частая проблема при работе с crm.product.update? Ответ Битрикса: {"result": true}. Он возвращает HTTP 200 OK и true даже тогда, когда вы передали ему откровенную дичь в полях (если это не вызвало фатальную ошибку валидации на уровне ядра, а просто игнорировалось логикой свойств). Вы будете смотреть в карточку товара в браузере, видеть, что ничего не изменилось, и не понимать, где баг — в вашем коде или в Битриксе.

Поэтому в моем методе processTour() разбросаны десятки вызовов $this->writeLog(). Я логирую вообще всё:

  1. Какой тур пришел в исходном виде.
  2. Какие сырые данные прислал фронтенд.
  3. Какую карту enum выдал Битрикс на этот момент.
  4. Какие ID нашел фильтр по подстроке в товарах.
  5. Какое старое значение было у PROPERTY_133.
  6. Какой новый enum ID я вычислил через stripos.
 $this->writeLog($logFile, "Текущее значение PROPERTY_133:");
 $this->writeLog($logFile, "  - raw: " . $prop133Debug);
 $this->writeLog($logFile, "  - normalized IDs: " . json_encode($currentIds));

Почему FILE_APPEND | LOCK_EX — это критично?

В функции writeLog я использую стандартный file_put_contents. Но заметьте флаги в конце: FILE_APPEND | LOCK_EX.

Представьте ситуацию: менеджер случайно нажал кнопку "Сохранить" в админке два раза быстро (или браузер затупил и отправил дабл-пост). В PHP запустятся два параллельных процесса (если используется php-fpm). Оба процесса одновременно попытаются открыть текстовый файл лога и записать туда строку.

Без флага LOCK_EX (эксклюзивной блокировки файла) строки лога перемешаются в кашу: половина строки от первого запроса впишется, потом впишется целиком строка от второго запроса, и потом допишется первая. Вы получите невалидный JSON или сломанный текст в логе, который невозможно прочитать.

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

Построение микро-роутера на голом PHP

В самом низу моего файла лежит роутинг. Многие сейчас подумают: "Артём, ты что, в 2024 году не использовал ни Laravel, ни Symfony, ни хотя бы Slim Framework?".

Для микро-сервиса, который делает ровно 3 действия, разворачивать целый фреймворк — это убить муху молотком. Цель этого скрипта — быть закинутым по FTP в любую папку на любом самом дешевом shared-хостинге и начать работать мгновенно. Никаких композеров, никаких зависимостей, один файл.

header('Content-Type: application/json; charset=utf-8');
 $b24 = new B24PartnerManager();

try {
    $action = $_GET['action'] ?? $_POST['action'] ?? '';

    // Умный дефолт: если нет action, но есть tour — значит это process
    if ($action === '' && isset($_POST['tour'])) {
        $action = 'process';
    }
    if ($action === '' && (isset($_POST['product_id']) || isset($_GET['product_id']))) {
        $action = 'update_partner';
    }

    switch ($action) {
        case 'list_partners':
            // Отдаем справочник для выпадающего списка на фронтенде
            echo json_encode([
                'success'  => true,
                'count'    => count($b24->getEnumMap()),
                'partners' => $b24->getEnumMap(),
            ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
            break;

        case 'process':
            // ... вызов processTour()
            break;

        case 'update_partner':
            // ... прямое обновление
            break;

        default:
            http_response_code(400);
            echo json_encode($b24->error('Неизвестное действие'), JSON_UNESCAPED_UNICODE);
    }

} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode([
        'success' => false,
        'error'   => 'Exception: ' . $e->getMessage(),
        'file'    => $e->getFile(),
        'line'    => $e->getLine(),
    ], JSON_UNESCAPED_UNICODE);
}

Обратите внимание на блок try...catch (Throwable $e) в самом конце. Это абсолютный мастхэв для любого публичного API. Если где-то в curl_init произойдет фатальная ошибка PHP (например, упадет память, или хостинг заблокирует исходящие соединения), скрипт не выплюнет пользователю белый экран с голым текстом Fatal error: ... in /var/www/assets/partners/b24.php on line 42. Это не только выглядит ужасно, но и является дырой в безопасности (слив путей на сервере).

Мой скрипт перехватит любую ошибку (включая Error и Exception через Throwable), аккуратно вернет клиенту JSON с success: false и текстом ошибки, а реальный stack trace запишется в лог-файл, который видите только я.

Рефакторинг: Что бы я улучшил сейчас

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

1. Кеширование getEnumMap() Сейчас при каждом клике менеджера "Сохранить" скрипт делает 3-4 запроса к API Битрикса. Один из них — выкачивание всего списка значений свойства 133. Справочник партнеров меняется редко (раз в месяц добавляется новый гид). Делать запрос в Битрикс за этим справочником при каждом сохранении — это неоправданная нагрузка на API (особенно на тарифах с лимитами в 2 запроса в секунду).

Я бы добавил простейшее файловое кеширование:

 $cacheFile = __DIR__ . '/cache_enum_133.json';
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 3600) {
    return json_decode(file_get_contents($cacheFile), true);
}
// ... иначе дергаем API, сохраняем результат в $cacheFile и возвращаем

Это снизило бы время ответа скрипта и нагрузку на Битрикс в 2 раза.

2. CURLOPT_SSL_VERIFYPEER = false В моем базовом методе call() вы увидите эту строку. Я выключил проверку SSL сертификатов целенаправленно, потому что знал, что скрипт будет лежать на дешевом хостинге заказчика, где часто криво настроены корневые сертификаты, и curl отказывался ходить в Битрикс.

Но в Enterprise-решениях никогда так не делайте. Это открывает возможность для Man-In-The-Middle (MITM) атак. Кто-то на узле связи может подменить сертификат Битрикса и перехватить ваш webhook или токен авторизации. В идеале нужно правильно настроить CA bundle на сервере и оставить CURLOPT_SSL_VERIFYPEER = true.

Итоги

Интеграция со справочниками (enum-свойствами) в Битрикс24 через REST API — это всегда боль. Вы не можете работать с ними по человечески (через текст), вы обязаны переводить текст во внутренние числовые ID.

 

Мой опыт показывает: не пытайтесь заставить фронтенд всегда присылать вам правильные ID. Пишите бэкенд-трансформаторы. Выкачивайте карту enum, делайте локальный нечеткий поиск (хоть тем же stripos), нормализуйте любые форматы входящих данных в единный массив, и, самое главное, пишите подробные логи каждого шага с блокировками файлов, потому что API Битрикс24 иногда молчит об ошибках валидации.

Надеюсь, этот разбор моего боевого кода сэкономит вам пару бутылок валерьянки при следующей интеграции. Если у вас есть свои грабли, на которые вы наступили при работе со свойствами товаров — делитесь в комментариях, обсудим!

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Сложности с разделами каталога в Битрикс24 через REST API: игра с деревом

Обновляете товар через API, передаете SECTION_ID, но он не появляется в нужной папке. В этой статье я объясняю разницу между простым SECTION_ID и сложным деревом инфоблоков, как правильно выстроить иерархию и почему метод crm.product.update не умеет создавать папки.

5 июля 2026 1 мин

Битрикс24: бизнес-процессы и роботы — автоматизация CRM без написания кода

Как мы автоматизировали воронку сделок в Битрикс24 с помощью бизнес-процессов и роботов — реальный кейс с SLA, автоназначением ответственных и мониторингом сбоев.

13 июня 2026 8 мин

Битрикс24: batch-запросы и обработка 10 000 элементов без таймаута

Обычный crm.deal.list возвращает 50 элементов. Для синхронизации 10 000 сделок нужно 200 запросов — и это при условии, что ни один не упадёт по таймауту. Batch-метод Битрикс24 позволяет отправить до 50 команд в одном запросе, но у него свои подводные камни: лимит на размер запроса, ошибки частичной обработки и полное отсутствие транзакционности.

12 мая 2026 9 мин