This commit is contained in:
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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->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');
|
||||
}
|
||||
Reference in New Issue
Block a user