diff --git a/editorial/QUALITY_STANDARD.md b/editorial/QUALITY_STANDARD.md new file mode 100644 index 0000000..6213c8b --- /dev/null +++ b/editorial/QUALITY_STANDARD.md @@ -0,0 +1,34 @@ +# Редакционный стандарт качества + +Этот стандарт применяется к каждой переработанной статье. Он нужен не для того, чтобы сделать все тексты одинаковыми, а чтобы читатель получал законченное расследование, а не яркий заголовок с короткой заметкой. + +## До написания + +- Выбрать конкретную проблему, наблюдаемый симптом и практический результат для читателя. +- Проверить как минимум два первичных, нормативных или официальных источника. Для исторического API отдельно назвать версионные ограничения. +- Собрать один воспроизводимый пример: код, запрос, конфигурацию, замер или диагностическую последовательность. +- Подобрать собственный визуальный материал: схема, диаграмма, скриншот с разрешением на публикацию или иллюстрация. У изображения должны быть осмысленные `alt` и подпись. + +## Каркас статьи + +- В первых двух абзацах назвать исходную ситуацию и цену ошибки. +- Показать механизм, а не только рецепт: что меняется, кто владеет состоянием, где проходит граница ответственности. +- Дать читателю рабочий пример и объяснить, какие значения в нём проектные. +- Добавить минимум одну таблицу: сравнение вариантов, матрицу симптомов, контракт данных или последовательность проверки. +- Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник. +- Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте. + +## Голос автора + +- Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу. +- Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник. +- Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным. +- Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода. + +## Тройное ревью перед публикацией + +1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний. +2. **Редактура и голос.** Проверить постановку проблемы, полноту раскрытия, естественность тона соответствующего года, повторы и ясность переходов. +3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку. + +Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`. diff --git a/editorial/README.md b/editorial/README.md index 5615b60..a65630c 100644 --- a/editorial/README.md +++ b/editorial/README.md @@ -7,7 +7,7 @@ - Период: январь 2018 — декабрь 2027. - Ритм: три публикации в месяц — практическая инструкция, объяснение механизма и разбор/кейс. - Уже опубликованные статьи занимают один слот в октябре 2018 и январе 2019; для соблюдения ритма к ним добавляются только две новые публикации. -- Каждый новый материал содержит проблему, минимальную схему, проверку, ограничения и ссылки на источники. +- Каждый переработанный материал проходит отдельный редакционный стандарт из `QUALITY_STANDARD.md`: проблема, исследование, пример, визуальное объяснение, проверка и источники. - Стилистика меняется от тёплой практической заметки 2018 года к спокойному системному разбору и наставническому тону 2027 года. ## Исследовательская библиотека @@ -33,4 +33,4 @@ ## Публикация -Скрипт \`web/scripts/publishEditorialArchive.mjs\` создаёт идемпотентный архив: удаляет только записи с префиксом \`editorial-\`, сохраняет исходные статьи и заново добавляет подготовленные публикации. После его запуска необходимо проверять JSON и выполнять \`npm run build\` из каталога \`web\`. +Первичный массовый генератор `web/scripts/publishEditorialArchive.mjs` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки. diff --git a/editorial/reviews/2018-01.md b/editorial/reviews/2018-01.md new file mode 100644 index 0000000..92efe27 --- /dev/null +++ b/editorial/reviews/2018-01.md @@ -0,0 +1,35 @@ +# Январь 2018 — ручное редакционное ревью + +Партия: + +- `editorial-2018-01-practice-bitrix-elements` +- `editorial-2018-01-mechanism-bitrix-elements` +- `editorial-2018-01-field-bitrix-elements` + +Дата проверки: 31 июля 2026 года. Тексты сохраняют даты исходной публикационной траектории; это дата реконструкции и редакционного выпуска. + +## 1. Факты и техника — пройдено + +- Сверены контракты `CIBlockElement::Add`, `SetPropertyValuesEx`, `GetList` и события `OnBeforeIBlockElementAdd` с официальной документацией Bitrix. +- Утверждение о товарном слое вынесено в отдельный блок: элемент инфоблока не объявлен достаточным условием видимости в каталоге. +- Устаревший `CCatalogProduct::Add` отмечен как исторический API со ссылкой на актуальную карточку документации, а не выдан за современный рецепт. +- В примеры добавлены явные `PRODUCT_IBLOCK_ID` и подключение модуля там, где они были скрытой зависимостью. +- В статьях нет обещаний, что очистка кеша, событие или повторный `Add` универсально устранят ошибку. + +## 2. Редактура и голос — пройдено + +- У каждой статьи свой вопрос: готовность операции, граница события и диагностика невидимости. Три текста не пересказывают друг друга. +- Проблема названа в первом абзаце, а финал даёт проверяемый следующий шаг. +- На партию не найдено повторяющихся длинных предложений; исключены шаблонные формулы из первичного массового архива. +- Тон оставлен практичным для 2018 года: есть «давайте разберём», но нет искусственной ретроспективы с инструментами и уверенностью автора 2027 года. +- Глубина после финальной правки: 5 102, 5 634 и 5 085 символов обычного текста; 9, 9 и 10 минут чтения соответственно. + +## 3. Визуал и выпуск — пройдено + +- Каждая статья содержит самостоятельный рисунок с `alt` и подписью; первая — авторскую редакционную иллюстрацию, две другие — адаптированные вертикальные SVG-схемы. +- В реальном рендере проверены ширины 1280px и 375px. На 375px нет горизонтального скролла страницы; таблицы прокручиваются внутри `.table-scroll`. +- После первой мобильной проверки широкие схемы заменены на вертикальные: текст и последовательность шагов остаются читаемыми. +- `xmllint` подтвердил корректность SVG. Автоматический аудит подтвердил наличие рисунка, таблицы, кода, источников и достаточной глубины для всех трёх материалов. +- `npm run build` успешно собрал 374 статические страницы; в консоли страницы не было предупреждений и ошибок. + +Статус: готово к публикации как первая качественно переработанная тройка. Остальной массовый архив не считается прошедшим этот стандарт и должен обновляться такими же проверяемыми тематическими партиями. diff --git a/web/app/globals.css b/web/app/globals.css index f665c57..6a3391a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -240,6 +240,39 @@ h3 { font-size: 14px; } +.article-content .table-scroll { + overflow-x: auto; + margin: 28px 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); +} + +.article-content table { + width: 100%; + min-width: 620px; + border-collapse: collapse; + font-size: 16px; +} + +.article-content th, +.article-content td { + padding: 14px 16px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.article-content th { + background: #f0ece2; + color: #443a2d; + font-weight: 700; +} + +.article-content tr:last-child td { + border-bottom: 0; +} + .article-content pre { overflow-x: auto; border-radius: 8px; diff --git a/web/data/articles.json b/web/data/articles.json index 8d0ba1a..7837f89 100644 --- a/web/data/articles.json +++ b/web/data/articles.json @@ -4992,45 +4992,48 @@ }, { "slug": "editorial-2018-01-field-bitrix-elements", - "title": "Bitrix API. Жизненный цикл элемента инфоблока: разбор типичной ошибки", + "title": "Bitrix API. Элемент есть в админке, но не виден в каталоге", "date": "2018-01-25T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", - "PHP" + "PHP", + "Диагностика" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Кейс о том, как элемент появился в админке, но не стал частью ожидаемого пользовательского сценария. В конце — последовательность проверки и критерий готовности.", - "contentHtml": "

На реальном проекте эта история обычно начинается спокойно, а затем всплывает один неприятный крайний случай. Разберём «жизненный цикл элемента инфоблока». Типичная ситуация выглядит так: элемент появился в админке, но не стал частью ожидаемого пользовательского сценария. В такой момент легко срочно поправить видимый симптом, но полезнее пройти короткое расследование и оставить после него защиту для следующего раза.

\n

Последовательность разбора

\n
  1. Собрать симптомы до изменения конфигурации или кода.
  2. Проверить гипотезу самым маленьким безопасным экспериментом.
  3. Исправить причину, а не только видимый эффект.
  4. Добавить защиту или наблюдение, чтобы случай не вернулся незаметно.
\n

Минимальное доказательство

\n

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

\n
$required = ['IBLOCK_ID', 'NAME'];\nforeach ($required as $field) {\n    if (empty($fields[$field])) {\n        throw new InvalidArgumentException($field . ' is required');\n    }\n}
\n

Что меняется после исправления

\n

Исправление считается законченным, когда новый путь проверяется автоматически или наблюдается по явному сигналу. Иначе «жизненный цикл элемента инфоблока» вернётся в следующем релизе под другим именем. Добавление элемента — это не только вызов Add, но и контракт данных, событий и проверки результата.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 4 + "cover": "/assets/editorial/2018/bitrix-visibility-diagnostic.svg", + "excerpt": "Полевой разбор частой ошибки Bitrix: Add вернул ID, админка показывает элемент, но пользователь не видит его в каталоге. Ищем причину по слоям, а не очищаем кеш наугад.", + "contentHtml": "

Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.

\n

Постановка проблемы

\n

Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.

\n
\"Дерево
Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.
\n

Проверяем по слоям

\n
СлойЧто проверяемКак получить доказательство
ЗаписьAdd вернул ID, LAST_ERROR пустЛог результата и внешний ID операции
ИнфоблокACTIVE, даты, символьный код, разделКонтрольная выборка с теми же базовыми фильтрами
СвойстваОбязательная связь, SKU, картинка, проектные флагиЧтение конкретных свойств для созданного ID
КаталогЦена, остаток, доступность — если компонент их требуетПроверка конфигурации каталога и товарных параметров
Публичный путьФильтр компонента, права, кеш и индексПовтор сценария от имени нужного пользователя
\n

Контрольный запрос вместо догадки

\n

Документация CIBlockElement::GetList описывает фильтры ACTIVE, ACTIVE_DATE и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\n$result = CIBlockElement::GetList(\n    [],\n    [\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "=ID" => $elementId,\n        "ACTIVE" => "Y",\n        "ACTIVE_DATE" => "Y",\n    ],\n    false,\n    ["nTopCount" => 1],\n    ["ID", "IBLOCK_ID", "NAME", "CODE", "ACTIVE", "DATE_ACTIVE_FROM", "DATE_ACTIVE_TO"]\n);\n\n$row = $result->Fetch();\nif ($row === false) {\n    throw new RuntimeException("Элемент не проходит базовый публичный фильтр");\n}
\n

Где здесь каталог

\n

Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного CIBlockElement::Add недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить CCatalogProduct::Add, но текущая документация помечает его устаревшим и рекомендует модель \\Bitrix\\Catalog\\Model\\Product. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.

\n

Мини-матрица симптомов

\n
СимптомСамая частая причинаБезопасное следующее действие
Нет IDОшибка обязательного поля, свойства или правВывести LAST_ERROR и входной внешний ID
ID есть, базовый GetList пустACTIVE, дата, инфоблок или неверный IDСначала читать поля элемента без публичных фильтров
GetList есть, карточки нетДополнительный фильтр компонента, раздел, права, URLСравнить фильтр и маршрут компонента с контрольной выборкой
Карточка есть, нельзя купитьНе настроены параметры каталога, цена или остатокПроверить товарный слой отдельно от инфоблока
После изменения появляется не сразуКеш или индексПодтвердить корректность данных и только затем адресно обновлять кеш/индекс
\n

Почему не стоит начинать с очистки кеша

\n

Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.

\n

Чек-лист перед закрытием задачи

\n
  1. Зафиксировать ID созданного элемента и внешний идентификатор операции.
  2. Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.
  3. Повторить контрольную выборку с ACTIVE и ACTIVE_DATE.
  4. Проверить условия конкретного компонента: раздел, права, проектные фильтры, URL.
  5. Если это товар — отдельно проверить цену, остаток и доступность, не смешивая этот слой с данными инфоблока.
  6. Только после этого проверять кеш и индекс; зафиксировать, какое именно действие обновляет их в данном проекте.
\n

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

\n\n

Итог

\n

Фраза «элемент есть в админке» говорит только о том, что одна запись сохранилась. Для каталога этого недостаточно. Если идти от ID к базовой выборке, от неё к товарному слою и только затем к кешу, причина обычно находится быстро. И самое приятное: на следующей похожей задаче уже не нужно вспоминать магическую кнопку очистки — есть нормальный порядок проверки.

", + "readingMinutes": 10 }, { "slug": "editorial-2018-01-mechanism-bitrix-elements", - "title": "Bitrix API. Почему важна тема: Жизненный цикл элемента инфоблока", + "title": "Bitrix API. Что на самом деле происходит вокруг CIBlockElement::Add", "date": "2018-01-15T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", - "PHP" + "PHP", + "Архитектура" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Разбираем, почему добавление элемента — это не только вызов Add, но и контракт данных, событий и проверки результата — и какие ошибки возникают, если этот механизм не учитывать.", - "contentHtml": "

Сначала хотелось просто применить готовый рецепт, но без понимания механизма он быстро превращается в набор случайных действий. Поэтому давайте разберём «жизненный цикл элемента инфоблока». Добавление элемента — это не только вызов Add, но и контракт данных, событий и проверки результата. Когда этот слой остаётся невидимым, команда начинает лечить следствие: добавляет таймаут, глобальную переменную, второй кеш или ещё одну повторную попытку.

\n

Модель происходящего

\n

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

\n
$id = $element->Add($fields);\nif ($id === false) {\n    error_log('Bitrix error: ' . $element->LAST_ERROR);\n    return null;\n}\nreturn (int) $id;
\n

Как проверить модель на практике

\n
  1. Назвать границу, на которой действует механизм.
  2. Зафиксировать, что считается успехом и отказом.
  3. Проверить, какие данные или ресурсы остаются после ошибки.
  4. Добавить измерение, которое подтвердит вывод в следующем проекте.
\n

Ограничения

\n

У этой модели нет магической силы: элемент появился в админке, но не стал частью ожидаемого пользовательского сценария. Поэтому в рабочем проекте нужно добавлять наблюдение, разумные лимиты и понятный путь отката. Чем дороже ошибка, тем важнее заранее проговорить этот случай.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/bitrix-add-lifecycle.svg", + "excerpt": "Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.", + "contentHtml": "

Когда Bitrix-проект разрастается, вокруг простого CIBlockElement::Add появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.

\n

Карта жизненного цикла

\n

Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».

\n
\"Последовательность
ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.
\n

Где живёт каждое правило

\n
МестоХорошая ответственностьЧто туда не стоит класть
Сервис созданияПроверка входа, подготовка полей, перевод ошибки в понятный результатГлобальные побочные эффекты для любого инфоблока
OnBeforeIBlockElementAddПоследний общий барьер: запрет пустого CODE, аудит общей политикиВнешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана
После записиОтправка события, фоновая реакция, журналирование успешной операцииИзменение результата, от которого зависит успех текущего Add
Публичный компонентФильтрация и отображение данныхИсправление отсутствующих обязательных данных «на лету»
\n

Минимальный предохранитель в событии

\n

Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nAddEventHandler(\n    "iblock",\n    "OnBeforeIBlockElementAdd",\n    ["CatalogElementGuard", "beforeAdd"]\n);\n\nfinal class CatalogElementGuard\n{\n    public static function beforeAdd(array &$fields): bool\n    {\n        if ((int)($fields["IBLOCK_ID"] ?? 0) !== PRODUCT_IBLOCK_ID) {\n            return true;\n        }\n\n        if (trim((string)($fields["CODE"] ?? "")) === "") {\n            global $APPLICATION;\n            $APPLICATION->ThrowException("Для товара нужен символьный код");\n            return false;\n        }\n\n        return true;\n    }\n}
\n

Почему событие не должно быть единственным валидатором

\n

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

\n

Сервис остаётся точкой диагностики

\n
<?php\n\nfunction addCatalogElement(array $fields): int\n{\n    if (!\\Bitrix\\Main\\Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add($fields);\n\n    if ($id === false) {\n        $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";\n        throw new RuntimeException($message);\n    }\n\n    return (int)$id;\n}
\n

После записи — это уже другой разговор

\n

Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как Add вернул ID, повторный вызов Add из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.

\n

Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.

\n

Как тестировать такую связку

\n

В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.

\n
СценарийОжиданиеГде искать ошибку при сбое
Корректный элементСервис возвращает ID, элемент читаетсяПоля сервиса и конфигурация инфоблока
Пустой CODEЗапись отменена, причина понятна вызывающему кодуОбработчик OnBeforeIBlockElementAdd
Другой инфоблокОхранник не вмешиваетсяСлишком широкое условие в обработчике
Импорт или CLIРезультат тот же, что из формыСкрытая зависимость от HTTP-сессии или интерфейса
\n

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

\n\n

Итог

\n

События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных init.php-заклинаний: у каждого правила появляется место и причина.

", + "readingMinutes": 9 }, { "slug": "editorial-2018-01-practice-bitrix-elements", - "title": "Bitrix API. Жизненный цикл элемента инфоблока: рабочая схема", + "title": "Bitrix API. Создаём элемент инфоблока так, чтобы ошибка не исчезла", "date": "2018-01-07T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", - "PHP" + "PHP", + "Практика" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Практическая заметка о том, как создать элемент так, чтобы обязательные поля, свойства и ошибки не потерялись. С минимальной схемой, проверкой результата и ограничениями.", - "contentHtml": "

Периодически в проекте встречается задача, которая с виду кажется мелкой, а потом съедает полдня. В этот раз разбираюсь с темой «жизненный цикл элемента инфоблока». Цель заметки — создать элемент так, чтобы обязательные поля, свойства и ошибки не потерялись. Не будем начинать с большой переделки: сначала соберём минимальный сценарий, который можно показать коллеге и повторить на чистой среде.

\n

Минимальная рабочая схема

\n

Первое правило здесь простое: отделяем входные данные от побочного эффекта. До того как менять состояние системы, проверяем условия, назначаем понятный идентификатор операции и оставляем достаточно контекста для диагностики. Добавление элемента — это не только вызов Add, но и контракт данных, событий и проверки результата.

\n
CModule::IncludeModule('iblock');\n$element = new CIBlockElement();\n$id = $element->Add([\n    'IBLOCK_ID' => 12,\n    'NAME' => $name,\n    'ACTIVE' => 'Y',\n    'PROPERTY_VALUES' => $properties,\n]);\nif (!$id) {\n    throw new RuntimeException($element->LAST_ERROR);\n}
\n

Что проверяем после запуска

\n
  1. Сформулировать вход и ожидаемый результат: создать элемент так, чтобы обязательные поля, свойства и ошибки не потерялись.
  2. Выполнить минимальный сценарий отдельно от остальной системы.
  3. Проверить отрицательный путь: элемент появился в админке, но не стал частью ожидаемого пользовательского сценария.
  4. Сохранить наблюдаемый результат в тесте, логе или коротком runbook.
\n

Где чаще всего ошибаются

\n

Опасность темы «жизненный цикл элемента инфоблока» не в сложном синтаксисе, а в неявных предположениях. Элемент появился в админке, но не стал частью ожидаемого пользовательского сценария. Если решение зависит от версии среды, внешней системы или прав пользователя, это лучше проверить отдельным шагом и записать рядом с кодом.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/bitrix-catalog-workflow.png", + "excerpt": "Разбираем создание элемента инфоблока как полноценную операцию: контракт полей, обработка LAST_ERROR, свойства, контрольная выборка и проверка публичного сценария.", + "contentHtml": "

Иногда задача формулируется очень просто: «добавь товар через API». Первая версия обычно занимает десять строк — создаём CIBlockElement, вызываем Add, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.

\n

Ситуация: ID — это ещё не готовый результат

\n

Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод CIBlockElement::Add действительно возвращает ID при успехе и false при ошибке, а текст причины лежит в LAST_ERROR. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.

\n
\"Разработчик
Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.
\n

Сначала формулируем контракт операции

\n

Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.

\n
УчастокЧто фиксируемЧем доказываем
Входname, внешний ID, категория, файлыВалидация до вызова Bitrix и понятная ошибка для вызывающего кода
ЭлементIBLOCK_ID, NAME, CODE, ACTIVEМассив $fields можно залогировать без секретов
СвойстваКакие свойства обязательны при первом сохраненииОни передаются в PROPERTY_VALUES или проверяются отдельно
РезультатID, URL, видимость в нужной выборкеКонтрольный запрос и тест пользовательского сценария
\n

Рабочий пример

\n

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

\n
<?php\n\nuse Bitrix\\Main\\Loader;\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nfunction createProductDraft(array $input): int\n{\n    if (!Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $name = trim((string)($input["name"] ?? ""));\n    $code = trim((string)($input["code"] ?? ""));\n\n    if ($name === "" || $code === "") {\n        throw new InvalidArgumentException("Нужны NAME и CODE");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add([\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "NAME" => $name,\n        "CODE" => $code,\n        "ACTIVE" => "N",\n        "PROPERTY_VALUES" => [\n            "EXTERNAL_ID" => (string)($input["externalId"] ?? ""),\n            "BRAND" => (int)($input["brandId"] ?? 0),\n        ],\n    ]);\n\n    if ($id === false) {\n        throw new RuntimeException($element->LAST_ERROR ?: "Не удалось создать элемент");\n    }\n\n    return (int)$id;\n}
\n

Почему свойства лучше не «доклеивать» вслепую

\n

Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать PROPERTY_VALUES в том же вызове Add. Метод SetPropertyValuesEx полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает null, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.

\n
<?php\n\n// Осознанное точечное изменение, а не «попробуем и забудем».\nCIBlockElement::SetPropertyValuesEx(\n    $elementId,\n    PRODUCT_IBLOCK_ID,\n    ["SYNC_STATUS" => "ready"]\n);\n\n// После важного изменения читаем нужное свойство в контрольном сценарии.
\n

Четыре проверки после Add

\n
  1. Проверяем, что вернулся положительный ID; при false сохраняем LAST_ERROR, входной внешний идентификатор и контекст операции.
  2. Читаем элемент в том же инфоблоке и убеждаемся, что поля NAME, CODE и нужные свойства действительно сохранены.
  3. Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.
  4. Только после этого включаем элемент или помечаем импортированную запись как готовую.
\n

Чего я бы не делал

\n\n

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

\n\n

Итог

\n

Сам вызов CIBlockElement::Add несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять LAST_ERROR и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.

", + "readingMinutes": 9 }, { "slug": "о-tilix-и-d-интервью-с-геральдом-нанном", diff --git a/web/package.json b/web/package.json index 6ca30da..93f9a2d 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "admin:posts": "node ../local-admin/posts-admin.mjs", "build": "next build", + "audit:articles": "node scripts/audit-quality-batch.mjs", "start": "next start" }, "dependencies": { diff --git a/web/public/assets/editorial/2018/bitrix-add-lifecycle.svg b/web/public/assets/editorial/2018/bitrix-add-lifecycle.svg new file mode 100644 index 0000000..27c49a6 --- /dev/null +++ b/web/public/assets/editorial/2018/bitrix-add-lifecycle.svg @@ -0,0 +1,58 @@ + + Жизненный цикл CIBlockElement Add + Вертикальная последовательность: входные данные, общий обработчик, запись, контрольная публичная выборка и готовый пользовательский результат. + + + + + + + + + Путь CIBlockElement::Add + ID — важная контрольная точка, но не финальный критерий готовности. + + + 1. Форма или импорт → сервис + Поля, внешний ключ и контекст операции. + + + + + 2. OnBeforeIBlockElementAdd + Общий инвариант: уточнить поля + или отменить запись с понятной причиной. + + + + + 3. CIBlockElement::Add + Возвращает ID либо false + LAST_ERROR. + + + + + 4. Контрольная публичная выборка + ACTIVE, даты, раздел, свойства, URL, + а для каталога — цена и остаток по сценарию. + + + + + 5. Пользователь видит готовый результат + Только теперь можно считать сценарий завершённым. + + «Элемент сохранён» и «элемент готов» — разные состояния. + diff --git a/web/public/assets/editorial/2018/bitrix-catalog-workflow.png b/web/public/assets/editorial/2018/bitrix-catalog-workflow.png new file mode 100644 index 0000000..571dab1 Binary files /dev/null and b/web/public/assets/editorial/2018/bitrix-catalog-workflow.png differ diff --git a/web/public/assets/editorial/2018/bitrix-visibility-diagnostic.svg b/web/public/assets/editorial/2018/bitrix-visibility-diagnostic.svg new file mode 100644 index 0000000..e344455 --- /dev/null +++ b/web/public/assets/editorial/2018/bitrix-visibility-diagnostic.svg @@ -0,0 +1,61 @@ + + Диагностика элемента, который не виден в каталоге + Вертикальное дерево вопросов от результата Add до публичного отображения товара. + + + + + + + + + Элемент не виден в каталоге + Не начинаем с кеша. Идём от факта записи к публичной выборке. + + + 1. Add вернул ID и нет LAST_ERROR? + Нет → логируем входные поля, права и причину ошибки. + + да + + + 2. ACTIVE, даты и раздел подходят? + Нет → исправляем условия публикации, а не шаблон. + + да + + + 3. Есть свойства и товарный слой? + Нет → проверяем SKU, цену, остаток и обязательные связи. + + да + + + 4. Совпадает фильтр публичного компонента? + Нет → сверяем права, URL, проектные условия и раздел. + + да + + + 5. Только теперь проверяем кеш и индекс + Данные уже доказанно корректны — ищем задержку слоя выдачи. + + + + Пользователь видит нужную карточку + И причина зафиксирована, чтобы не лечить её наугад снова. + + «Нет» на любом шаге — исправляем именно этот слой. + diff --git a/web/scripts/audit-quality-batch.mjs b/web/scripts/audit-quality-batch.mjs new file mode 100644 index 0000000..5631a33 --- /dev/null +++ b/web/scripts/audit-quality-batch.mjs @@ -0,0 +1,84 @@ +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); +const articlesPath = join(webRoot, 'data', 'articles.json'); +const slugs = process.argv.slice(2); + +if (slugs.length === 0) { + throw new Error('Usage: node scripts/audit-quality-batch.mjs [...slug]'); +} + +const archive = JSON.parse(await readFile(articlesPath, 'utf8')); +const genericPhrases = [ + 'У этой модели нет магической силы', + 'Материалы для проверки', + 'Если держать этот порядок, решение остаётся понятным', +]; +let failed = false; + +function count(content, expression) { + return (content.match(expression) || []).length; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +for (const slug of slugs) { + const article = archive.find((candidate) => candidate.slug === slug); + const issues = []; + + if (!article) { + console.error('FAIL ' + slug + ': статья не найдена'); + failed = true; + continue; + } + + const content = article.contentHtml; + const text = plainText(content); + const imageSources = [...content.matchAll(/]+src="([^"]+)"/g)].map((match) => match[1]); + + if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения'); + if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста'); + if (count(content, /

/g) < 5) issues.push('меньше пяти смысловых разделов'); + if (count(content, /
/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения'); + if (count(content, /
/g) < 1) issues.push('у иллюстрации нет подписи'); + if (count(content, //g) < 1 || count(content, //g) < 1) issues.push('нет доступной таблицы'); + if (count(content, /
/g) < 1) issues.push('нет воспроизводимого примера');
+  if (count(content, /' + 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 '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +function dataTable(headers, rows) { + const head = '
' + headers.map((header) => '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + ''; + return '
' + header + '
' + cell + '
' + 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: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'), + 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('Как тестировать такую связку'), + 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, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'), + 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'); +}