import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const articlesPath = join(webRoot, 'data', 'articles.json'); function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(lines) { return '
' + escapeHtml(lines.join('\n')) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function bulletList(items) { return ''; } function dataTable(headers, rows) { const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + head + body + '
'; } function sourceList(items) { return ''; } const bitrixAdd = { title: 'CIBlockElement::Add', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y', note: 'контракт метода, обработчики до и после записи, ID и LAST_ERROR', }; const bitrixProperties = { title: 'CIBlockElement::SetPropertyValuesEx', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/setpropertyvaluesex.php', note: 'точечное сохранение свойств и особенности пустых значений', }; const bitrixBeforeAdd = { title: 'OnBeforeIBlockElementAdd', url: 'https://dev.1c-bitrix.ru/api_help/iblock/events/onbeforeiblockelementadd.php', note: 'как обработчик может изменить поля или отменить запись', }; const bitrixGetList = { title: 'CIBlockElement::GetList', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php', note: 'фильтры ACTIVE, ACTIVE_DATE и выборка полей элемента', }; const catalogProduct = { title: 'CCatalogProduct::Add и актуальная модель Catalog', url: 'https://dev.1c-bitrix.ru/api_help/catalog/classes/ccatalogproduct/add.php', note: 'параметры товарного элемента и версия API', }; const practiceArticle = { slug: 'editorial-2018-01-practice-bitrix-elements', title: 'Bitrix API. Создаём элемент инфоблока так, чтобы ошибка не исчезла', categories: ['Bitrix', 'PHP', 'Практика'], cover: '/assets/editorial/2018/bitrix-catalog-workflow.png', excerpt: 'Разбираем создание элемента инфоблока как полноценную операцию: контракт полей, обработка LAST_ERROR, свойства, контрольная выборка и проверка публичного сценария.', readingMinutes: 9, contentHtml: [ paragraph('Иногда задача формулируется очень просто: «добавь товар через API». Первая версия обычно занимает десять строк — создаём CIBlockElement, вызываем Add, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.'), heading('Ситуация: ID — это ещё не готовый результат'), paragraph('Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод CIBlockElement::Add действительно возвращает ID при успехе и false при ошибке, а текст причины лежит в LAST_ERROR. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.'), figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'), heading('Сначала формулируем контракт операции'), paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'), paragraph('Ещё один пункт контракта — повторный запуск. Импорт может оборваться после ответа базы или до записи в журнал. Поэтому внешний идентификатор должен позволять отличить новую операцию от повтора. В проекте это обычно означает отдельную проверку существующей записи по внешнему ключу и явное правило: обновляем черновик, пропускаем готовую запись или останавливаемся с конфликтом. Сам Add за это правило не отвечает.'), dataTable( ['Участок', 'Что фиксируем', 'Чем доказываем'], [ ['Вход', 'name, внешний ID, категория, файлы', 'Валидация до вызова Bitrix и понятная ошибка для вызывающего кода'], ['Элемент', 'IBLOCK_ID, NAME, CODE, ACTIVE', 'Массив $fields можно залогировать без секретов'], ['Свойства', 'Какие свойства обязательны при первом сохранении', 'Они передаются в PROPERTY_VALUES или проверяются отдельно'], ['Результат', 'ID, URL, видимость в нужной выборке', 'Контрольный запрос и тест пользовательского сценария'], ], ), heading('Рабочий пример'), paragraph('Ниже пример для черновика товара. Я намеренно сохраняю элемент неактивным: пока импорт не завершил все обязательные действия, пользователю незачем видеть полуготовую карточку. Конкретные коды свойств и ID инфоблока должны быть вынесены в конфигурацию проекта, а не спрятаны в середине функции.'), codeBlock([ 'Add([', ' "IBLOCK_ID" => PRODUCT_IBLOCK_ID,', ' "NAME" => $name,', ' "CODE" => $code,', ' "ACTIVE" => "N",', ' "PROPERTY_VALUES" => [', ' "EXTERNAL_ID" => (string)($input["externalId"] ?? ""),', ' "BRAND" => (int)($input["brandId"] ?? 0),', ' ],', ' ]);', '', ' if ($id === false) {', ' throw new RuntimeException($element->LAST_ERROR ?: "Не удалось создать элемент");', ' }', '', ' return (int)$id;', '}', ]), heading('Почему свойства лучше не «доклеивать» вслепую'), paragraph('Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать PROPERTY_VALUES в том же вызове Add. Метод SetPropertyValuesEx полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает null, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.'), codeBlock([ ' "ready"]', ');', '', '// После важного изменения читаем нужное свойство в контрольном сценарии.', ]), heading('Четыре проверки после Add'), orderedList([ 'Проверяем, что вернулся положительный ID; при false сохраняем LAST_ERROR, входной внешний идентификатор и контекст операции.', 'Читаем элемент в том же инфоблоке и убеждаемся, что поля NAME, CODE и нужные свойства действительно сохранены.', 'Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.', 'Только после этого включаем элемент или помечаем импортированную запись как готовую.', ]), heading('Чего я бы не делал'), bulletList([ 'Не игнорировал бы результат Add в надежде, что ошибка «сама попадёт в журнал».', 'Не делал бы элемент активным до заполнения зависимых данных.', 'Не генерировал бы CODE без правила уникальности: два одинаковых названия неизбежно встретятся.', 'Не очищал бы весь кеш первым действием. Сначала нужно доказать, что проблема именно в кеше, а не в данных или фильтре.', ]), heading('Проверяемые источники'), sourceList([bitrixAdd, bitrixProperties, bitrixGetList]), heading('Итог'), paragraph('Сам вызов CIBlockElement::Add несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять LAST_ERROR и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.'), ].join('\n'), }; const mechanismArticle = { slug: 'editorial-2018-01-mechanism-bitrix-elements', title: 'Bitrix API. Что на самом деле происходит вокруг CIBlockElement::Add', categories: ['Bitrix', 'PHP', 'Архитектура'], cover: '/assets/editorial/2018/bitrix-add-lifecycle.svg', excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.', readingMinutes: 9, contentHtml: [ paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого CIBlockElement::Add: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'), heading('Карта жизненного цикла'), paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'), figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'), heading('Где живёт каждое правило'), dataTable( ['Место', 'Хорошая ответственность', 'Что туда не стоит класть'], [ ['Сервис создания', 'Проверка входа, подготовка полей, перевод ошибки в понятный результат', 'Глобальные побочные эффекты для любого инфоблока'], ['OnBeforeIBlockElementAdd', 'Последний общий барьер: запрет пустого CODE, аудит общей политики', 'Внешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана'], ['После записи', 'Отправка события, фоновая реакция, журналирование успешной операции', 'Изменение результата, от которого зависит успех текущего Add'], ['Публичный компонент', 'Фильтрация и отображение данных', 'Исправление отсутствующих обязательных данных «на лету»'], ], ), heading('Минимальный предохранитель в событии'), paragraph('Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.'), codeBlock([ 'ThrowException("Для товара нужен символьный код");', ' return false;', ' }', '', ' return true;', ' }', '}', ]), heading('Почему событие не должно быть единственным валидатором'), paragraph('Потому что событие не знает намерения конкретной операции. Один экран может создавать черновик без картинки, другой — импортировать поставщика, третий — мигрировать старые записи. Если все проверки спрятать в OnBeforeIBlockElementAdd, получится глобальная функция с десятком условий и неожиданными побочными эффектами. Сервис создания должен объяснять, почему он принимает или отклоняет вход. Событие лишь страхует инвариант, который действует для всех.'), heading('Сервис остаётся точкой диагностики'), codeBlock([ 'Add($fields);', '', ' if ($id === false) {', ' $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";', ' throw new RuntimeException($message);', ' }', '', ' return (int)$id;', '}', ]), heading('После записи — это уже другой разговор'), paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как Add вернул ID, повторный вызов Add из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'), paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'), heading('Три вопроса до запуска'), orderedList([ 'Какой инвариант действительно общий для всех способов создания элемента, а какой относится только к форме или импорту?', 'Где вызывающий код получит причину отказа: в результате сервиса, в LAST_ERROR или в отдельном журнале операции?', 'Как повторный запуск отличит новую запись от уже созданной и не превратит сбой обработчика в дубликат?', ]), heading('Как тестировать такую связку'), paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'), dataTable( ['Сценарий', 'Ожидание', 'Где искать ошибку при сбое'], [ ['Корректный элемент', 'Сервис возвращает ID, элемент читается', 'Поля сервиса и конфигурация инфоблока'], ['Пустой CODE', 'Запись отменена, причина понятна вызывающему коду', 'Обработчик OnBeforeIBlockElementAdd'], ['Другой инфоблок', 'Охранник не вмешивается', 'Слишком широкое условие в обработчике'], ['Импорт или CLI', 'Результат тот же, что из формы', 'Скрытая зависимость от HTTP-сессии или интерфейса'], ], ), heading('Проверяемые источники'), sourceList([bitrixAdd, bitrixBeforeAdd, bitrixProperties]), heading('Итог'), paragraph('События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных init.php-заклинаний: у каждого правила появляется место и причина.'), ].join('\n'), }; const fieldArticle = { slug: 'editorial-2018-01-field-bitrix-elements', title: 'Bitrix API. Элемент есть в админке, но не виден в каталоге', categories: ['Bitrix', 'PHP', 'Диагностика'], cover: '/assets/editorial/2018/bitrix-visibility-diagnostic.svg', excerpt: 'Полевой разбор частой ошибки Bitrix: Add вернул ID, админка показывает элемент, но пользователь не видит его в каталоге. Ищем причину по слоям, а не очищаем кеш наугад.', readingMinutes: 10, contentHtml: [ paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'), heading('Постановка проблемы'), paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'), paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'), figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'), heading('Проверяем по слоям'), dataTable( ['Слой', 'Что проверяем', 'Как получить доказательство'], [ ['Запись', 'Add вернул ID, LAST_ERROR пуст', 'Лог результата и внешний ID операции'], ['Инфоблок', 'ACTIVE, даты, символьный код, раздел', 'Контрольная выборка с теми же базовыми фильтрами'], ['Свойства', 'Обязательная связь, SKU, картинка, проектные флаги', 'Чтение конкретных свойств для созданного ID'], ['Каталог', 'Цена, остаток, доступность — если компонент их требует', 'Проверка конфигурации каталога и товарных параметров'], ['Публичный путь', 'Фильтр компонента, права, кеш и индекс', 'Повтор сценария от имени нужного пользователя'], ], ), heading('Контрольный запрос вместо догадки'), paragraph('Документация CIBlockElement::GetList описывает фильтры ACTIVE, ACTIVE_DATE и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.'), codeBlock([ ' PRODUCT_IBLOCK_ID,', ' "=ID" => $elementId,', ' "ACTIVE" => "Y",', ' "ACTIVE_DATE" => "Y",', ' ],', ' false,', ' ["nTopCount" => 1],', ' ["ID", "IBLOCK_ID", "NAME", "CODE", "ACTIVE", "DATE_ACTIVE_FROM", "DATE_ACTIVE_TO"]', ');', '', '$row = $result->Fetch();', 'if ($row === false) {', ' throw new RuntimeException("Элемент не проходит базовый публичный фильтр");', '}', ]), heading('Где здесь каталог'), paragraph('Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного CIBlockElement::Add недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить CCatalogProduct::Add, но текущая документация помечает его устаревшим и рекомендует модель \\Bitrix\\Catalog\\Model\\Product. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.'), heading('Мини-матрица симптомов'), dataTable( ['Симптом', 'Самая частая причина', 'Безопасное следующее действие'], [ ['Нет ID', 'Ошибка обязательного поля, свойства или прав', 'Вывести LAST_ERROR и входной внешний ID'], ['ID есть, базовый GetList пуст', 'ACTIVE, дата, инфоблок или неверный ID', 'Сначала читать поля элемента без публичных фильтров'], ['GetList есть, карточки нет', 'Дополнительный фильтр компонента, раздел, права, URL', 'Сравнить фильтр и маршрут компонента с контрольной выборкой'], ['Карточка есть, нельзя купить', 'Не настроены параметры каталога, цена или остаток', 'Проверить товарный слой отдельно от инфоблока'], ['После изменения появляется не сразу', 'Кеш или индекс', 'Подтвердить корректность данных и только затем адресно обновлять кеш/индекс'], ], ), heading('Почему не стоит начинать с очистки кеша'), paragraph('Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.'), heading('Чек-лист перед закрытием задачи'), orderedList([ 'Зафиксировать ID созданного элемента и внешний идентификатор операции.', 'Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.', 'Повторить контрольную выборку с ACTIVE и ACTIVE_DATE.', 'Проверить условия конкретного компонента: раздел, права, проектные фильтры, URL.', 'Если это товар — отдельно проверить цену, остаток и доступность, не смешивая этот слой с данными инфоблока.', 'Только после этого проверять кеш и индекс; зафиксировать, какое именно действие обновляет их в данном проекте.', ]), heading('Проверяемые источники'), sourceList([bitrixAdd, bitrixGetList, catalogProduct]), heading('Итог'), paragraph('Фраза «элемент есть в админке» говорит только о том, что одна запись сохранилась. Для каталога этого недостаточно. Если идти от ID к базовой выборке, от неё к товарному слою и только затем к кешу, причина обычно находится быстро. И самое приятное: на следующей похожей задаче уже не нужно вспоминать магическую кнопку очистки — есть нормальный порядок проверки.'), ].join('\n'), }; const revisions = [practiceArticle, mechanismArticle, fieldArticle]; const archive = JSON.parse(await readFile(articlesPath, 'utf8')); const revisionBySlug = new Map(revisions.map((article) => [article.slug, article])); for (const revision of revisions) { if (!archive.some((article) => article.slug === revision.slug)) { throw new Error('Article not found: ' + revision.slug); } if (!revision.contentHtml.includes('
') || !revision.contentHtml.includes('')) { throw new Error('Visual or table missing: ' + revision.slug); } } const updated = archive.map((article) => { const revision = revisionBySlug.get(article.slug); return revision ? { ...article, ...revision } : article; }); if (process.argv.includes('--print-revisions')) { console.log(JSON.stringify(revisions, null, 2)); } else { console.log('Usage: node web/scripts/upgrade-2018-01.mjs --print-revisions'); }