Привет! Меня зовут Артём, я бэкенд-разработчик. Если вы читали мои предыдущие статьи, то знаете, что я обожаю копаться во внутренностях Битрикс24. Обычно я рассказываю про масштабные интеграции с OAuth, Batch API и очередями сообщений. Но сегодня мы разберем совершенно другой кейс — узкоспециализированный, но невероятно частый.
Недавно ко мне обратился заказчик из сферы туризма (если кто знаком с Питером, то понимаете, каких именно краеведческих туров там больше, чем гранитных ступенек). У них была проблема: в Битрикс24 каталог товаров — это их туры. А у каждого тура есть свойство типа «Список» (ID 133), в котором хранятся партнёры (гиды, транспортные компании, музеи).
Менеджерам было лень открывать тяжёлые карточки товаров в самом Битриксе, они просили простую кнопку в их старой админке: «Выбрал тур -> Выбрал партнёра из выпадающего списка -> Нажал сохранить».
Звучит просто, правда? Но когда я начал писать интеграцию под это, я столкнулся с классическим «адом свойств типа Список в REST API Битрикс24». В этой статье я дам вам мой реальный рабочий код и подробно разберу каждую строчку — от того, как правильно получить справочник значений, до того, как реализовать нечеткий поиск названий, если данные из админки не совпадают с базой. Погнали.
Главная боль API Битрикс24: Свойства типа «Список» под капотом
Прежде чем я покажу вам код микро-сервиса, который я в итоге написал, нужно понять, почему эта задача вообще стала проблемой. Если вы не сталкивались с внутренностями REST API Битрикс24, вам может показаться, что обновить пару полей у товара — это дело пяти строчек на PHP. Но это не так.
Вся загвоздка кроется в том, как Битрикс24 хранит данные типа «Список» (в интерфейсе это выпадающие списки, множественные чекбоксы и т.д.). Когда вы в настройках CRM создаете свойство товара и добавляете туда значения (например, «ООО Ромашка», «ИП Иванов»), Битрикс сохраняет это не как простой текст. Под капотом это превращается в сущность enum.
У каждого элемента в этом списке есть две важные характеристики:
- VALUE — это то, что видит человек (текст «ООО Ромашка»).
- 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 (Питер)", функция проверяет две вещи:
- Входит ли
"Лучший gid"в"Самый лучший gid (Питер)"? Функцияstriposнаходит подстроку на позиции 6, возвращает неfalse, условие срабатывает. - Если бы не вошло (например, мы бы искали "Питер"), функция проверила бы обратное вхождение: не содержится ли длинный справочник внутри короткого запроса.
Да, у этого подхода есть минусы. Если я буду искать " 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(). Я логирую вообще всё:
- Какой тур пришел в исходном виде.
- Какие сырые данные прислал фронтенд.
- Какую карту
enumвыдал Битрикс на этот момент. - Какие ID нашел фильтр по подстроке в товарах.
- Какое старое значение было у
PROPERTY_133. - Какой новый
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 иногда молчит об ошибках валидации.
Надеюсь, этот разбор моего боевого кода сэкономит вам пару бутылок валерьянки при следующей интеграции. Если у вас есть свои грабли, на которые вы наступили при работе со свойствами товаров — делитесь в комментариях, обсудим!
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье