{"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 и флаг успеха. Сравнивайте поля, внешний идентификатор, состояние пустоты и, если это важно, время изменения.
Начните с небольшой таблицы. В ней видны не только старое и новое имя, но также пустое состояние, источник и проверка результата.
| Поле | Legacy-вход | Каноническое значение | Проверка |
|---|---|---|---|
| Телефон | PERSONAL_PHONE, строка с пробелами | phone, нормализованная строка | read-back и формат |
EMAIL, исходный регистр | email, регистр по правилу контракта | валидность и точное чтение | |
| Связь | XML_ID | externalId | одна запись при retry |
| Не передан | ключ отсутствует | unchanged | старое значение не меняется |
| Очищен | ключ есть, значение пустое | clear | два разных теста |
Если пришёл неизвестный ключ, не угадывайте его назначение по похожему имени. Остановите преобразование и сообщите, какое поле не входит в контракт. Если email не проходит правило приложения, не превращайте его в пустую строку. Верните ошибку до записи.
Следующая функция — изолированный учебный пример. Она не подключается к 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 стал недействительным | Нормализация скрыла ошибку или правило шире контракта | Проверить валидатор до записи и значение после чтения | Вернуть ошибку, не записывать пустой заменитель |
missing, clear, invalid и unchanged.Документация Bitrix описывает публичный API, но не знает локальные события, права, пользовательские поля, обработчики и SQL-ограничения проекта. Успешный вызов в одной установке не доказывает совместимость другой. Версия ядра помогает сузить поиск, но не заменяет проверку фактической поверхности.
Read-back может отличаться от входа по допустимому правилу сервера. Телефон может получить другой формат. Время изменения зависит от среды. Такие расхождения нужно разделить на ожидаемую нормализацию и потерю смысла. Нельзя объявлять их одинаковыми только потому, что совпал ID.
Учебный JavaScript-пример не является production-валидатором. Проверка email.includes('@') намеренно упрощена. PHP-функция filter_var может быть частью технической проверки email, но она не определяет бизнес-правила, разрешённые домены и требуемое поведение пустого поля.
Если read-back невозможен, миграция не получает доказательство сохранения. В этом случае не расширяйте объём. Сначала добавьте безопасный способ проверить запись или оставьте адаптер за границей массового запуска. Отрицательное решение лучше тихой потери данных.
Одна миграция поля готова, если другой инженер может восстановить вход и получить тот же результат: mapping объясняет каждое переданное поле, missing и clear различаются, API-поверхность подтверждена, read-back совпадает по смысловым значениям, а повторный запуск не создаёт дубль.
Для отрицательных случаев есть отдельные доказательства: неизвестное поле останавливается, неверный email не превращается в пустую строку, отсутствие поля не очищает старое значение, недоступный модуль не приводит к частичному запуску. Только после этих проверок можно говорить о расширении на следующую выборку.
LAST_ERROR. Страница не описывает локальные события и mapping проекта.ID, XML_ID и PERSONAL_PHONE, а также связь с D7-представлением. Это описание API, не подтверждение состояния установки.