upgrade January 2018 Bitrix articles
Build and deploy / deploy (push) Successful in 21s

This commit is contained in:
2026-07-31 01:44:45 +03:00
parent 18f25995bc
commit 44c99a2640
12 changed files with 712 additions and 20 deletions
+34
View File
@@ -0,0 +1,34 @@
# Редакционный стандарт качества
Этот стандарт применяется к каждой переработанной статье. Он нужен не для того, чтобы сделать все тексты одинаковыми, а чтобы читатель получал законченное расследование, а не яркий заголовок с короткой заметкой.
## До написания
- Выбрать конкретную проблему, наблюдаемый симптом и практический результат для читателя.
- Проверить как минимум два первичных, нормативных или официальных источника. Для исторического API отдельно назвать версионные ограничения.
- Собрать один воспроизводимый пример: код, запрос, конфигурацию, замер или диагностическую последовательность.
- Подобрать собственный визуальный материал: схема, диаграмма, скриншот с разрешением на публикацию или иллюстрация. У изображения должны быть осмысленные `alt` и подпись.
## Каркас статьи
- В первых двух абзацах назвать исходную ситуацию и цену ошибки.
- Показать механизм, а не только рецепт: что меняется, кто владеет состоянием, где проходит граница ответственности.
- Дать читателю рабочий пример и объяснить, какие значения в нём проектные.
- Добавить минимум одну таблицу: сравнение вариантов, матрицу симптомов, контракт данных или последовательность проверки.
- Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник.
- Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте.
## Голос автора
- Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу.
- Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник.
- Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным.
- Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода.
## Тройное ревью перед публикацией
1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний.
2. **Редактура и голос.** Проверить постановку проблемы, полноту раскрытия, естественность тона соответствующего года, повторы и ясность переходов.
3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку.
Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`.
+2 -2
View File
@@ -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` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки.
+35
View File
@@ -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 статические страницы; в консоли страницы не было предупреждений и ошибок.
Статус: готово к публикации как первая качественно переработанная тройка. Остальной массовый архив не считается прошедшим этот стандарт и должен обновляться такими же проверяемыми тематическими партиями.
+33
View File
@@ -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;
+21 -18
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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": {
@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1360" role="img" aria-labelledby="title desc">
<title id="title">Жизненный цикл CIBlockElement Add</title>
<desc id="desc">Вертикальная последовательность: входные данные, общий обработчик, запись, контрольная публичная выборка и готовый пользовательский результат.</desc>
<defs>
<style>
.bg { fill: #fffdfa; }
.title { fill: #1e252d; font: 700 48px Arial, sans-serif; }
.sub { fill: #64707d; font: 28px Arial, sans-serif; }
.step { fill: #edf5f2; stroke: #216869; stroke-width: 3; }
.event { fill: #eef3fa; stroke: #355c9a; stroke-width: 3; }
.check { fill: #fff4e8; stroke: #b4432a; stroke-width: 3; }
.result { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
.step-title { fill: #1e252d; font: 700 34px Arial, sans-serif; text-anchor: middle; }
.step-copy { fill: #44515d; font: 26px Arial, sans-serif; text-anchor: middle; }
.note { fill: #65492e; font: 700 28px Arial, sans-serif; text-anchor: middle; }
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
</style>
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
</marker>
</defs>
<rect class="bg" width="1000" height="1360"/>
<text class="title" x="80" y="84">Путь CIBlockElement::Add</text>
<text class="sub" x="80" y="128">ID — важная контрольная точка, но не финальный критерий готовности.</text>
<rect class="step" x="100" y="200" width="800" height="138" rx="26"/>
<text class="step-title" x="500" y="253">1. Форма или импорт → сервис</text>
<text class="step-copy" x="500" y="300">Поля, внешний ключ и контекст операции.</text>
<path class="arrow" d="M500 338 V415"/>
<rect class="event" x="100" y="430" width="800" height="148" rx="26"/>
<text class="step-title" x="500" y="484">2. OnBeforeIBlockElementAdd</text>
<text class="step-copy" x="500" y="528">Общий инвариант: уточнить поля</text>
<text class="step-copy" x="500" y="563">или отменить запись с понятной причиной.</text>
<path class="arrow" d="M500 578 V655"/>
<rect class="step" x="100" y="670" width="800" height="140" rx="26"/>
<text class="step-title" x="500" y="724">3. CIBlockElement::Add</text>
<text class="step-copy" x="500" y="770">Возвращает ID либо false + LAST_ERROR.</text>
<path class="arrow" d="M500 810 V887"/>
<rect class="check" x="100" y="902" width="800" height="166" rx="26"/>
<text class="step-title" x="500" y="956">4. Контрольная публичная выборка</text>
<text class="step-copy" x="500" y="1000">ACTIVE, даты, раздел, свойства, URL,</text>
<text class="step-copy" x="500" y="1035">а для каталога — цена и остаток по сценарию.</text>
<path class="arrow" d="M500 1068 V1145"/>
<rect class="result" x="100" y="1160" width="800" height="118" rx="26"/>
<text class="step-title" x="500" y="1212">5. Пользователь видит готовый результат</text>
<text class="step-copy" x="500" y="1252">Только теперь можно считать сценарий завершённым.</text>
<text class="note" x="500" y="1324">«Элемент сохранён» и «элемент готов» — разные состояния.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1560" role="img" aria-labelledby="title desc">
<title id="title">Диагностика элемента, который не виден в каталоге</title>
<desc id="desc">Вертикальное дерево вопросов от результата Add до публичного отображения товара.</desc>
<defs>
<style>
.bg { fill: #fffdfa; }
.title { fill: #1e252d; font: 700 46px Arial, sans-serif; }
.sub { fill: #64707d; font: 28px Arial, sans-serif; }
.question { fill: #edf2f7; stroke: #4e6a85; stroke-width: 3; }
.failure { fill: #fff0eb; stroke: #b4432a; stroke-width: 3; }
.success { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
.box-title { fill: #1e252d; font: 700 33px Arial, sans-serif; text-anchor: middle; }
.box-copy { fill: #44515d; font: 25px Arial, sans-serif; text-anchor: middle; }
.yes { fill: #216869; font: 700 27px Arial, sans-serif; }
.no { fill: #b4432a; font: 700 27px Arial, sans-serif; }
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
</style>
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
</marker>
</defs>
<rect class="bg" width="1000" height="1560"/>
<text class="title" x="70" y="82">Элемент не виден в каталоге</text>
<text class="sub" x="70" y="128">Не начинаем с кеша. Идём от факта записи к публичной выборке.</text>
<rect class="question" x="100" y="200" width="800" height="140" rx="26"/>
<text class="box-title" x="500" y="255">1. Add вернул ID и нет LAST_ERROR?</text>
<text class="box-copy" x="500" y="303">Нет → логируем входные поля, права и причину ошибки.</text>
<path class="arrow" d="M500 340 V410"/>
<text class="yes" x="524" y="386">да</text>
<rect class="question" x="100" y="425" width="800" height="140" rx="26"/>
<text class="box-title" x="500" y="480">2. ACTIVE, даты и раздел подходят?</text>
<text class="box-copy" x="500" y="528">Нет → исправляем условия публикации, а не шаблон.</text>
<path class="arrow" d="M500 565 V635"/>
<text class="yes" x="524" y="611">да</text>
<rect class="question" x="100" y="650" width="800" height="140" rx="26"/>
<text class="box-title" x="500" y="705">3. Есть свойства и товарный слой?</text>
<text class="box-copy" x="500" y="753">Нет → проверяем SKU, цену, остаток и обязательные связи.</text>
<path class="arrow" d="M500 790 V860"/>
<text class="yes" x="524" y="836">да</text>
<rect class="question" x="100" y="875" width="800" height="140" rx="26"/>
<text class="box-title" x="500" y="930">4. Совпадает фильтр публичного компонента?</text>
<text class="box-copy" x="500" y="978">Нет → сверяем права, URL, проектные условия и раздел.</text>
<path class="arrow" d="M500 1015 V1085"/>
<text class="yes" x="524" y="1061">да</text>
<rect class="failure" x="100" y="1100" width="800" height="140" rx="26"/>
<text class="box-title" x="500" y="1155">5. Только теперь проверяем кеш и индекс</text>
<text class="box-copy" x="500" y="1203">Данные уже доказанно корректны — ищем задержку слоя выдачи.</text>
<path class="arrow" d="M500 1240 V1310"/>
<rect class="success" x="100" y="1325" width="800" height="130" rx="26"/>
<text class="box-title" x="500" y="1380">Пользователь видит нужную карточку</text>
<text class="box-copy" x="500" y="1428">И причина зафиксирована, чтобы не лечить её наугад снова.</text>
<text class="no" x="500" y="1510">«Нет» на любом шаге — исправляем именно этот слой.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+84
View File
@@ -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 <article-slug> [...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(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста');
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения');
if (count(content, /<figcaption>/g) < 1) issues.push('у иллюстрации нет подписи');
if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы');
if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
if (content.includes('undefined') || content.includes('[object Object]')) issues.push('в тексте есть след генерации');
for (const phrase of genericPhrases) {
if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»');
}
for (const source of imageSources.filter((value) => value.startsWith('/'))) {
try {
await access(join(webRoot, 'public', source));
} catch {
issues.push('не найден локальный visual asset: ' + source);
}
}
if (issues.length > 0) {
failed = true;
console.error('FAIL ' + slug + ': ' + issues.join('; '));
} else {
console.log(
'PASS ' + slug
+ ': ' + text.length + ' chars, '
+ count(content, /<figure>/g) + ' figure, '
+ count(content, /<table>/g) + ' table, '
+ count(content, /<pre><code>/g) + ' code example',
);
}
}
if (failed) process.exitCode = 1;
+5
View File
@@ -2,6 +2,11 @@ import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
throw new Error(
'The bootstrap archive generator is retired: it would overwrite reviewed editorial articles. '
+ 'Use a reviewed month-specific upgrade and apply the resulting patch instead.',
);
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const articlesPath = join(webRoot, 'data', 'articles.json');
+378
View File
@@ -0,0 +1,378 @@
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
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». Первая версия обычно занимает десять строк — создаём <code>CIBlockElement</code>, вызываем <code>Add</code>, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.'),
heading('Ситуация: ID — это ещё не готовый результат'),
paragraph('Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод <code>CIBlockElement::Add</code> действительно возвращает ID при успехе и <code>false</code> при ошибке, а текст причины лежит в <code>LAST_ERROR</code>. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.'),
figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'),
heading('Сначала формулируем контракт операции'),
paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'),
dataTable(
['Участок', 'Что фиксируем', 'Чем доказываем'],
[
['Вход', '<code>name</code>, внешний ID, категория, файлы', 'Валидация до вызова Bitrix и понятная ошибка для вызывающего кода'],
['Элемент', '<code>IBLOCK_ID</code>, <code>NAME</code>, <code>CODE</code>, <code>ACTIVE</code>', 'Массив <code>$fields</code> можно залогировать без секретов'],
['Свойства', 'Какие свойства обязательны при первом сохранении', 'Они передаются в <code>PROPERTY_VALUES</code> или проверяются отдельно'],
['Результат', 'ID, URL, видимость в нужной выборке', 'Контрольный запрос и тест пользовательского сценария'],
],
),
heading('Рабочий пример'),
paragraph('Ниже пример для черновика товара. Я намеренно сохраняю элемент неактивным: пока импорт не завершил все обязательные действия, пользователю незачем видеть полуготовую карточку. Конкретные коды свойств и ID инфоблока должны быть вынесены в конфигурацию проекта, а не спрятаны в середине функции.'),
codeBlock([
'<?php',
'',
'use Bitrix\\Main\\Loader;',
'',
'const PRODUCT_IBLOCK_ID = 12;',
'',
'function createProductDraft(array $input): int',
'{',
' if (!Loader::includeModule("iblock")) {',
' throw new RuntimeException("Модуль iblock не подключён");',
' }',
'',
' $name = trim((string)($input["name"] ?? ""));',
' $code = trim((string)($input["code"] ?? ""));',
'',
' if ($name === "" || $code === "") {',
' throw new InvalidArgumentException("Нужны NAME и CODE");',
' }',
'',
' $element = new CIBlockElement();',
' $id = $element->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('Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать <code>PROPERTY_VALUES</code> в том же вызове <code>Add</code>. Метод <code>SetPropertyValuesEx</code> полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает <code>null</code>, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.'),
codeBlock([
'<?php',
'',
'// Осознанное точечное изменение, а не «попробуем и забудем».',
'CIBlockElement::SetPropertyValuesEx(',
' $elementId,',
' PRODUCT_IBLOCK_ID,',
' ["SYNC_STATUS" => "ready"]',
');',
'',
'// После важного изменения читаем нужное свойство в контрольном сценарии.',
]),
heading('Четыре проверки после Add'),
orderedList([
'Проверяем, что вернулся положительный ID; при <code>false</code> сохраняем <code>LAST_ERROR</code>, входной внешний идентификатор и контекст операции.',
'Читаем элемент в том же инфоблоке и убеждаемся, что поля <code>NAME</code>, <code>CODE</code> и нужные свойства действительно сохранены.',
'Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.',
'Только после этого включаем элемент или помечаем импортированную запись как готовую.',
]),
heading('Чего я бы не делал'),
bulletList([
'Не игнорировал бы результат <code>Add</code> в надежде, что ошибка «сама попадёт в журнал».',
'Не делал бы элемент активным до заполнения зависимых данных.',
'Не генерировал бы <code>CODE</code> без правила уникальности: два одинаковых названия неизбежно встретятся.',
'Не очищал бы весь кеш первым действием. Сначала нужно доказать, что проблема именно в кеше, а не в данных или фильтре.',
]),
heading('Проверяемые источники'),
sourceList([bitrixAdd, bitrixProperties, bitrixGetList]),
heading('Итог'),
paragraph('Сам вызов <code>CIBlockElement::Add</code> несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять <code>LAST_ERROR</code> и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.'),
].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-проект разрастается, вокруг простого <code>CIBlockElement::Add</code> появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
heading('Карта жизненного цикла'),
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION-&gt;ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
heading('Где живёт каждое правило'),
dataTable(
['Место', 'Хорошая ответственность', 'Что туда не стоит класть'],
[
['Сервис создания', 'Проверка входа, подготовка полей, перевод ошибки в понятный результат', 'Глобальные побочные эффекты для любого инфоблока'],
['OnBeforeIBlockElementAdd', 'Последний общий барьер: запрет пустого CODE, аудит общей политики', 'Внешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана'],
['После записи', 'Отправка события, фоновая реакция, журналирование успешной операции', 'Изменение результата, от которого зависит успех текущего Add'],
['Публичный компонент', 'Фильтрация и отображение данных', 'Исправление отсутствующих обязательных данных «на лету»'],
],
),
heading('Минимальный предохранитель в событии'),
paragraph('Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.'),
codeBlock([
'<?php',
'',
'const PRODUCT_IBLOCK_ID = 12;',
'',
'AddEventHandler(',
' "iblock",',
' "OnBeforeIBlockElementAdd",',
' ["CatalogElementGuard", "beforeAdd"]',
');',
'',
'final class CatalogElementGuard',
'{',
' public static function beforeAdd(array &$fields): bool',
' {',
' if ((int)($fields["IBLOCK_ID"] ?? 0) !== PRODUCT_IBLOCK_ID) {',
' return true;',
' }',
'',
' if (trim((string)($fields["CODE"] ?? "")) === "") {',
' global $APPLICATION;',
' $APPLICATION->ThrowException("Для товара нужен символьный код");',
' return false;',
' }',
'',
' return true;',
' }',
'}',
]),
heading('Почему событие не должно быть единственным валидатором'),
paragraph('Потому что событие не знает намерения конкретной операции. Один экран может создавать черновик без картинки, другой — импортировать поставщика, третий — мигрировать старые записи. Если все проверки спрятать в <code>OnBeforeIBlockElementAdd</code>, получится глобальная функция с десятком условий и неожиданными побочными эффектами. Сервис создания должен объяснять, почему он принимает или отклоняет вход. Событие лишь страхует инвариант, который действует для всех.'),
heading('Сервис остаётся точкой диагностики'),
codeBlock([
'<?php',
'',
'function addCatalogElement(array $fields): int',
'{',
' if (!\\Bitrix\\Main\\Loader::includeModule("iblock")) {',
' throw new RuntimeException("Модуль iblock не подключён");',
' }',
'',
' $element = new CIBlockElement();',
' $id = $element->Add($fields);',
'',
' if ($id === false) {',
' $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";',
' throw new RuntimeException($message);',
' }',
'',
' return (int)$id;',
'}',
]),
heading('После записи — это уже другой разговор'),
paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как <code>Add</code> вернул ID, повторный вызов <code>Add</code> из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'),
paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'),
heading('Как тестировать такую связку'),
paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'),
dataTable(
['Сценарий', 'Ожидание', 'Где искать ошибку при сбое'],
[
['Корректный элемент', 'Сервис возвращает ID, элемент читается', 'Поля сервиса и конфигурация инфоблока'],
['Пустой CODE', 'Запись отменена, причина понятна вызывающему коду', 'Обработчик OnBeforeIBlockElementAdd'],
['Другой инфоблок', 'Охранник не вмешивается', 'Слишком широкое условие в обработчике'],
['Импорт или CLI', 'Результат тот же, что из формы', 'Скрытая зависимость от HTTP-сессии или интерфейса'],
],
),
heading('Проверяемые источники'),
sourceList([bitrixAdd, bitrixBeforeAdd, bitrixProperties]),
heading('Итог'),
paragraph('События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных <code>init.php</code>-заклинаний: у каждого правила появляется место и причина.'),
].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('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
heading('Проверяем по слоям'),
dataTable(
['Слой', 'Что проверяем', 'Как получить доказательство'],
[
['Запись', '<code>Add</code> вернул ID, <code>LAST_ERROR</code> пуст', 'Лог результата и внешний ID операции'],
['Инфоблок', '<code>ACTIVE</code>, даты, символьный код, раздел', 'Контрольная выборка с теми же базовыми фильтрами'],
['Свойства', 'Обязательная связь, SKU, картинка, проектные флаги', 'Чтение конкретных свойств для созданного ID'],
['Каталог', 'Цена, остаток, доступность — если компонент их требует', 'Проверка конфигурации каталога и товарных параметров'],
['Публичный путь', 'Фильтр компонента, права, кеш и индекс', 'Повтор сценария от имени нужного пользователя'],
],
),
heading('Контрольный запрос вместо догадки'),
paragraph('Документация <code>CIBlockElement::GetList</code> описывает фильтры <code>ACTIVE</code>, <code>ACTIVE_DATE</code> и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.'),
codeBlock([
'<?php',
'',
'const PRODUCT_IBLOCK_ID = 12;',
'',
'$result = CIBlockElement::GetList(',
' [],',
' [',
' "IBLOCK_ID" => 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('Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного <code>CIBlockElement::Add</code> недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить <code>CCatalogProduct::Add</code>, но текущая документация помечает его устаревшим и рекомендует модель <code>\\Bitrix\\Catalog\\Model\\Product</code>. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.'),
heading('Мини-матрица симптомов'),
dataTable(
['Симптом', 'Самая частая причина', 'Безопасное следующее действие'],
[
['Нет ID', 'Ошибка обязательного поля, свойства или прав', 'Вывести <code>LAST_ERROR</code> и входной внешний ID'],
['ID есть, базовый GetList пуст', 'ACTIVE, дата, инфоблок или неверный ID', 'Сначала читать поля элемента без публичных фильтров'],
['GetList есть, карточки нет', 'Дополнительный фильтр компонента, раздел, права, URL', 'Сравнить фильтр и маршрут компонента с контрольной выборкой'],
['Карточка есть, нельзя купить', 'Не настроены параметры каталога, цена или остаток', 'Проверить товарный слой отдельно от инфоблока'],
['После изменения появляется не сразу', 'Кеш или индекс', 'Подтвердить корректность данных и только затем адресно обновлять кеш/индекс'],
],
),
heading('Почему не стоит начинать с очистки кеша'),
paragraph('Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.'),
heading('Чек-лист перед закрытием задачи'),
orderedList([
'Зафиксировать ID созданного элемента и внешний идентификатор операции.',
'Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.',
'Повторить контрольную выборку с <code>ACTIVE</code> и <code>ACTIVE_DATE</code>.',
'Проверить условия конкретного компонента: раздел, права, проектные фильтры, 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('<figure>') || !revision.contentHtml.includes('<table>')) {
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');
}