{"index":31,"slug":"editorial-2027-02-field-bitrix-lessons","title":"Миграция пользовательского поля Bitrix: сохранить смысл, а не только значение","excerpt":"Как перенести поле пользователя из legacy API Bitrix в новый контракт без тихой потери данных: mapping, пустые значения, read-back, повторный запуск и критерий готовности.","contentHtml":"

Симптом появляется после успешного вызова Bitrix API. Пользователь получил новый ID, но телефон остался пустым. Email сохранился в другом регистре. Повторный запуск создал вторую связь с внешней системой. В логах есть только true от CUser::Update, поэтому команда не видит, на каком шаге исчезло значение.

Цена ошибки выше, чем одна неверная строка. По телефону не находится пользователь. Уведомление уходит на старый адрес. Импорт нельзя безопасно повторить. Если исходное значение уже перезаписано, восстановление зависит от резервной копии или ручного поиска.

Тезис: миграция поля готова не тогда, когда API принял запрос. Она готова, когда команда может показать mapping, прочитать сохранённый смысл обратно и повторить тот же вход без дубля или неожиданной очистки.

Механизм: поле проходит четыре границы

Legacy-код обычно передаёт массив с именами Bitrix: PERSONAL_PHONE, EMAIL, XML_ID. Новый код хочет получить объект вроде { phone, email, externalId }. Это не простая замена имён. Каждое поле имеет формат, правило пустого значения и обратное представление.

Первая граница — вход. Отсутствующий PERSONAL_PHONE может означать «не менять телефон», а пустая строка — «очистить телефон». Если привести оба состояния к null, адаптер потеряет команду пользователя.

Вторая граница — нормализация. Для телефона допустимы пробелы и разные формы записи, но правило должно быть конкретным. Для email можно привести регистр к нижнему, если это разрешает контракт приложения. Нельзя применять одну функцию ко всем значениям: XML_ID, комментарий и парольный хэш имеют разные правила.

Третья граница — запись. Официальный метод CUser::Update принимает ID и массив полей. Успех означает, что метод не сообщил об ошибке. Он не доказывает, что downstream-обработчик, индекс или внешний обмен увидели ожидаемый смысл.

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

\"Цикл
Успешная запись занимает середину цикла. Доказательство результата появляется после повторного чтения и проверки повторяемости.

Mapping должен описывать смысл

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

ПолеLegacy-входКаноническое значениеПроверка
ТелефонPERSONAL_PHONE, строка с пробеламиphone, нормализованная строкаread-back и формат
EmailEMAIL, исходный регистрemail, регистр по правилу контрактавалидность и точное чтение
СвязьXML_IDexternalIdодна запись при retry
Не переданключ отсутствуетunchangedстарое значение не меняется
Очищенключ есть, значение пустоеclearдва разных теста

Если пришёл неизвестный ключ, не угадывайте его назначение по похожему имени. Остановите преобразование и сообщите, какое поле не входит в контракт. Если email не проходит правило приложения, не превращайте его в пустую строку. Верните ошибку до записи.

Учебный пример: нормализация до вызова Bitrix

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

function toCanonical(input) {\n  const result = {};\n\n  if (Object.hasOwn(input, 'PERSONAL_PHONE')) {\n    if (input.PERSONAL_PHONE === '') {\n      result.phone = { action: 'clear' };\n    } else {\n      const phone = String(input.PERSONAL_PHONE).trim();\n      if (!phone) throw new Error('phone-invalid');\n      result.phone = { action: 'set', value: phone };\n    }\n  }\n\n  if (Object.hasOwn(input, 'EMAIL')) {\n    const email = String(input.EMAIL).trim().toLowerCase();\n    if (!email.includes('@')) throw new Error('email-invalid');\n    result.email = { action: 'set', value: email };\n  }\n\n  if (Object.hasOwn(input, 'XML_ID')) {\n    result.externalId = { action: 'set', value: String(input.XML_ID) };\n  }\n\n  return result;\n}\n\nconst value = toCanonical({\n  PERSONAL_PHONE: ' +7 900 000-00-00 ',\n  EMAIL: 'User@Example.TEST',\n  XML_ID: 'crm-17',\n});\n\n// phone: set '+7 900 000-00-00'\n// email: set 'user@example.test'\n// externalId: set 'crm-17'

Для учебного входа функция удаляет внешние пробелы, приводит email к нижнему регистру и оставляет связь как строку. Это выбранные правила примера, а не универсальная политика Bitrix. В реальном проекте их нужно заменить правилами доменного контракта.

Адаптер записи строится после такой проверки. Для unchanged поле не добавляется. Для clear передаётся явное значение очистки, согласованное с API и проектом.

$fields = [];\n\nif ($canonical['phone']['action'] === 'set') {\n    $fields['PERSONAL_PHONE'] = $canonical['phone']['value'];\n}\n\nif ($canonical['phone']['action'] === 'clear') {\n    $fields['PERSONAL_PHONE'] = '';\n}\n\nif ($canonical['email']['action'] === 'set') {\n    $fields['EMAIL'] = $canonical['email']['value'];\n}\n\n$user = new CUser;\nif (!$user->Update($userId, $fields)) {\n    throw new RuntimeException($user->LAST_ERROR);\n}

Код показывает только форму вызова. Он не проверяет права, события, пользовательские поля и транзакцию. Перед использованием нужно подтвердить, что модуль и нужная поверхность API доступны в конкретной установке. Для D7 и legacy-пути нельзя считать классы взаимозаменяемыми без отдельного mapping.

Симптомы и проверки

СимптомПричинаПроверкаДействие
API вернул успех, поле пустоеНеверное имя, формат или обработчик изменил значениеСравнить mapping, raw-вход и read-backИсправить границу и повторить на одной записи
Повторный запуск создаёт дубльНет стабильного внешнего ключа или retry неидемпотентенПовторить вход по XML_ID и проверить количество записейЗакрепить ключ операции и запретить создание без него
Поле исчезает при частичном обновленииMissing и clear сведены к одному значениюПроверить отсутствие ключа и пустую строку отдельноПередавать очистку только явной командой
Один сервер принимает вызов, другой — нетМодуль не подключён или поверхности API различаютсяПроверить IncludeModule, класс и метод в целевой средеОстановить миграцию и выбрать совместимый adapter
Email стал недействительнымНормализация скрыла ошибку или правило шире контрактаПроверить валидатор до записи и значение после чтенияВернуть ошибку, не записывать пустой заменитель

Порядок действий

  1. Выберите одно поле и один стабильный идентификатор записи. Не начинайте с массового запуска.
  2. Снимите фактический вход: ключи, типы, пустые значения, внешний ID и источник данных.
  3. Составьте mapping table. Отдельно назовите состояния missing, clear, invalid и unchanged.
  4. Проверьте доступность модуля и метода в целевой версии Bitrix. Если условие не выполнено, остановите изменение.
  5. Прогоните нормализацию на обезличенных значениях. Проверьте пробелы, регистр, пустоту, неверный формат и неизвестный ключ.
  6. Запишите одну запись через адаптер. Сохраните безопасный идентификатор операции, не помещая персональные данные в лог.
  7. Сделайте read-back: сравните поля, пустые состояния, внешний ID и побочные признаки, важные для приложения.
  8. Повторите тот же вход. Убедитесь, что запись не дублируется, значение не меняется без правила, а операция остаётся обратимой.
  9. Только после этого расширяйте выборку. Любое расхождение сначала классифицируйте как mapping, формат, права, событие или версию API.

Ограничения

Документация Bitrix описывает публичный API, но не знает локальные события, права, пользовательские поля, обработчики и SQL-ограничения проекта. Успешный вызов в одной установке не доказывает совместимость другой. Версия ядра помогает сузить поиск, но не заменяет проверку фактической поверхности.

Read-back может отличаться от входа по допустимому правилу сервера. Телефон может получить другой формат. Время изменения зависит от среды. Такие расхождения нужно разделить на ожидаемую нормализацию и потерю смысла. Нельзя объявлять их одинаковыми только потому, что совпал ID.

Учебный JavaScript-пример не является production-валидатором. Проверка email.includes('@') намеренно упрощена. PHP-функция filter_var может быть частью технической проверки email, но она не определяет бизнес-правила, разрешённые домены и требуемое поведение пустого поля.

Если read-back невозможен, миграция не получает доказательство сохранения. В этом случае не расширяйте объём. Сначала добавьте безопасный способ проверить запись или оставьте адаптер за границей массового запуска. Отрицательное решение лучше тихой потери данных.

Проверяемый критерий готовности

Одна миграция поля готова, если другой инженер может восстановить вход и получить тот же результат: mapping объясняет каждое переданное поле, missing и clear различаются, API-поверхность подтверждена, read-back совпадает по смысловым значениям, а повторный запуск не создаёт дубль.

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

Проверяемые источники

"}