Тема
ТЗ — Импорт данных (cms/import)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: masha (
masha/src/app/Domain/Import/ExcelImportService.php,masha/src/app/Domain/Wb/), universal (universal/src/app/Services/Import/*) Статус: ТЗ к разработке
Назначение и возможности
Универсальный пайплайн импорта данных: источник (CSV/XML/API) → маппинг полей → валидация → батчевый upsert. Рассчитан на highload — большие файлы обрабатываются чанками через Bus::batch, без построчной нагрузки на кеш и БД.
- Источники: загрузка файла (CSV/XML), staged-upload JSONL для машинного bulk-импорта (интеграции, по образцу api-lessons §8), внешний API (переиспользует
cms/integrations-bus) - Конфигурируемый маппинг колонок/полей на атрибуты модели, сохраняемые маппинг-профили как отдельные сущности (
cms_import_mappings), переиспользуемые между задачами - Батчевый upsert в доменные сущности других модулей — только через провайдес-контракт
import-target(реестрcms/core-contracts, ревизия ядра 14.07.2026, п.9), не raw mass-assignment по имени модели - Валидация каждой строки с накоплением построчных ошибок (не прерывает весь импорт)
- Батчевый upsert чанками через
Bus::batch, идемпотентность поexternal_id - Dry-run режим — прогон валидации без записи в БД
- Отчёт об ошибках построчно (CSV) или JSONL «строка N = результат строки N» для bulk-канала, доступный для скачивания
- Батчевая инвалидация кеша по завершении импорта, а не на каждую строку
- Highload-путь:
COPY/чанки для PostgreSQL вместо построчныхINSERT - Аварийная приостановка приёма новых заданий (
import.new_jobs_paused) без выключения модуля
Не путать с cms:export/cms:import ядра (data-exchange.md, раздел «Перенос типов контента») — та пара переносит схемы типов контента между инсталляциями (dev → prod, manifest.json
- apply-токен).
cms/import— про пользовательские данные и файлы (прайсы, фиды, выгрузки из внешних систем) внутри одной инсталляции, механизм и модель данных полностью разные.
Зависимости и выключение
requires: ядро · suggests: cms/integrations-bus
Поведение при выключении: UI и команды импорта недоступны, ранее импортированные данные не затрагиваются — деградация, не поломка. Задания в статусе running на момент выключения не прерываются штатно (модуль не реализует принудительную остановку своих же джоб) — джоба доработает до конца, попытка создать новое задание через API/UI при выключенном модуле получает 404/деградацию UI. cms/integrations-bus отсутствует/выключен → источник «внешний API» недоступен в мастере создания задания, доступен только источник «файл»/JSONL.
Модули-владельцы доменных сущностей (каталог, товары и т.д.) не объявляются в requires/ suggests — связь односторонняя, обнаруживается через реестр provides-контрактов import-target (канал 3, ревизия ядра 14.07.2026, п.9): владелец сам решает, принимать ли bulk-импорт, реализуя контракт при bootstrap. Нет ни одной реализации — мастер предлагает только сущности ядра (PageRepository/ContentRepository/TaxonomyRepository) — штатная деградация канала 3.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_import_jobs | id, source_type, mapping (json), status, total_rows, processed_rows, dry_run | шапка задания импорта |
cms_import_job_errors | id, import_job_id, row_number, errors (json), raw_row (json) | построчные ошибки |
cms_import_mappings | id, name, target_model, mapping (json) | сохранённые пресеты маппинга |
Заметки: status — PHP Enum; mapping/errors/raw_row — JSONB с касом 'array' (GIN не обязателен — структурный конфиг, не поиск); FK import_job_id — constrained() + index(). Индекс (import_job_id, row_number) на cms_import_job_errors под keyset-пагинацию отчёта; индекс по created_at под пакетную очистку по import.error_report_retention_days (BRIN-кандидат при большом объёме журнала). cms_import_jobs индексируется по status — под виджет «активные/зависшие задания» в дашборде и health-чек.
cms_import_mappings — маппинг-профиль как полноценная сущность, не встроенный JSON внутри задания: target_model в ней хранит имя реализации import-target (не произвольный Eloquent- класс), профиль переиспользуется между задачами одного типа фида (например «фид Wildberries» — один профиль, применяемый к каждой новой выгрузке); уникальный индекс (name), индекс по target_model под фильтр «профили для этой цели» в мастере создания задания.
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Filament/POST /api/v1/admin/import/jobs | source_type (file/jsonl_bulk/api), file (CSV/XML) или staged-upload id, mapping/mapping_id, target (имя реализации import-target либо сущность ядра), dry_run | FormRequest whitelist: source_type — enum, file — через медиатеку (MIME/размер/import.max_file_size_mb), target — только из реестра зарегистрированных import-target-реализаций + whitelist сущностей ядра (import.allowed_target_models, см. «Крайние случаи»), mapping — только объявленные атрибуты цели, без системных полей (id, created_at, deleted_at, ключи авторизации) |
| Содержимое CSV/XML-файла | построчные значения по колонкам | построчная валидация: rules движка полей по целевой сущности (тип, обязательность, формат), кодировка приводится к UTF-8 на входе потокового чтения |
| Staged-upload JSONL (bulk-канал, по api-lessons §8) | строка = набор полей записи, __parentId для иерархий | тот же rules-барьер построчно; отчёт — JSONL, где строка N = результат строки N, лимит: 1 активная bulk-операция на клиента |
Внешний API через cms/integrations-bus (suggests) | тот же построчный формат, что отдаёт коннектор | схема ответа коннектора валидируется на стороне cms/integrations-bus до передачи в cms/import; данные из внешнего источника не считаются доверенными — проходят те же rules, что и файл |
POST /api/v1/admin/import/mappings (сохранение профиля) | name, target, mapping (json) | FormRequest whitelist, target — тот же whitelist реализаций import-target, что и при создании задания |
Всё, что не перечислено как вход (незадекларированные колонки, поля вне маппинга, параметры вне FormRequest-схемы) — отвергается на границе, а не игнорируется молча.
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Filament UI / /api/v1/admin/import/jobs* | статус, прогресс (processed_rows/total_rows), построчный отчёт об ошибках | конверт {data, meta}, отчёт — keyset-пагинация |
Модуль-владелец цели (реализация import-target) | пакет строк на батчевый upsert | вызов ImportTargetContract::upsertBatch() — модуль-владелец сам пишет в свои таблицы своим сервисом, cms/import не делает mass-assignment по чужой модели напрямую (ревизия ядра 14.07.2026, п.9) |
| Скачиваемый отчёт об ошибках | row_number, errors, raw_row | CSV (файловый источник) или JSONL «строка N = результат» (bulk-канал), генерируется потоково самим модулем |
События ImportJobStarted/ImportChunkProcessed/ImportJobCompleted | факты для подписчиков (например cms/audit) | payload — раздел «События и обмен» |
| Батчевая инвалидация кеша | теги затронутых моделей | вызов CacheTags ядра по завершении задания |
Настройки (группа import)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
import.chunk_size | int | 500 | нет | Размер чанка для Bus::batch |
import.max_file_size_mb | int | 50 | нет | Лимит размера загружаемого файла |
import.max_rows_per_file | int | 200000 | нет | Лимит строк на файл — заградительный барьер до постановки в очередь |
import.dry_run_default | bool | true | нет | Dry-run по умолчанию для новых заданий |
import.error_report_retention_days | int | 30 | нет | Хранение отчётов об ошибках |
import.cache_flush_batched | bool | true | да | Батчевая инвалидация кеша по завершении, не построчно |
import.allowed_target_models | array | [] (пусто = только сущности ядра) | нет | Дополнительный whitelist сущностей ядра (PageRepository/ContentRepository/TaxonomyRepository) поверх автоматически доступных реализаций import-target; для доменных модулей ограничение — сам факт регистрации контракта, не эта настройка |
import.duplicate_strategy | string | "update" | нет | Поведение при совпадении external_id: update/skip/fail |
import.csv_delimiter_default | string | "," | нет | Разделитель CSV по умолчанию (переопределяется на задании) |
import.notify_on_completion | bool | true | нет | Уведомление ответственного администратора по завершении задания |
import.new_jobs_paused | bool | false | нет | Kill-switch (матрица v2.2): аварийная приостановка создания новых заданий без выключения модуля — уже идущие задания доигрывают |
import.jsonl_bulk_enabled | bool | true | нет | Включение bulk-канала JSONL для машинных интеграций (api-lessons §8) |
import.jsonl_signed_url_ttl_days | int | 7 | нет | TTL подписанной ссылки на JSONL-отчёт bulk-импорта |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/admin/import/jobs | admin (import.manage) | Создание задания импорта (файл/API-источник) |
| GET | /api/v1/admin/import/jobs | admin (import.view) | Список заданий (keyset-пагинация, фильтр по статусу) |
| GET | /api/v1/admin/import/jobs/{id} | admin (import.view) | Статус и прогресс задания |
| GET | /api/v1/admin/import/jobs/{id}/errors | admin (import.view) | Построчный отчёт об ошибках |
| POST | /api/v1/admin/import/jobs/{id}/cancel | admin (import.manage) | Отмена ещё не завершённого задания |
| GET/POST/PUT/DELETE | /api/v1/admin/import/mappings | admin (import.manage) | CRUD маппинг-профилей |
| POST | /api/v1/admin/import/bulk | токен интеграции (import.manage) | Staged-upload JSONL → 202 + id (api-lessons §8) |
| GET | /api/v1/admin/import/bulk/{id} | токен интеграции (import.view) | Статус bulk-задания, object_count, ссылка на JSONL-отчёт по готовности |
Построчный отчёт — keyset-пагинация (не OFFSET) для больших файлов. Мутации с внешним эффектом (POST /jobs, POST /jobs/{id}/cancel, POST /bulk) — с Idempotency-Key (§7 стандарта): повторный запрос с тем же ключом не создаёт второе задание и не отменяет уже отменённое. Bulk-канал выполняется вне общего rate-limit (тяжёлая, но редкая операция; лимит — 1 активная bulk-операция на клиента, не частота запросов).
Компоненты
Filament: мастер импорта (источник → маппинг → dry-run → запуск), страница отчёта об ошибках, список заданий с фильтром по статусу, CRUD маппинг-профилей отдельной страницей (профиль переиспользуется между задачами). Команды: cms:import:run --json, cms:import:status --json. Демо-контент (матрица v2.2): не применимо — публичных блоков/виджетов нет, admin-only инструмент.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ImportJobStarted | задание поставлено в очередь | import_job_id, source_type, total_rows |
ImportChunkProcessed | обработан очередной чанк | import_job_id, chunk_index, processed, errors_count |
ImportJobCompleted | задание завершено (успех/частично/провал) | import_job_id, status, total_rows, error_rows |
Слушает: сервис-вызов cms/integrations-bus (по suggests) как источник данных при импорте из внешнего API вместо прямого HTTP. Provides: не декларирует свой контракт, но потребляет канонический import-target (ревизия ядра 14.07.2026, п.9) — резолвит зарегистрированные модулями-владельцами реализации через DI, аналогично тому, как cms/commerce-facets потребляет search-provider у cms/search.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Медиатека ядра (MediaService) | сервис-вызов по requires (канал 4) | in | Загруженный файл сохраняется и проверяется по типу/размеру через MediaService, не собственным кодом |
CacheTags ядра | сервис-вызов по requires (канал 4) | out | Батчевая инвалидация тегов затронутых моделей по завершении задания |
RequestContext ядра | сервис-вызов по requires (канал 4) | in | site_id/locale/city_id для скоупирования импортируемых записей, если целевая модель несёт эти измерения |
cms/integrations-bus (suggests) | сервис-вызов (канал 4, при включении модуля) | in | Источник построчных данных при импорте из внешнего API вместо прямого HTTP |
Модуль-владелец цели (реализация import-target) | provides-контракт (канал 3, ревизия ядра 14.07.2026, п.9) | out | Батчевый upsert записей владельца по маппингу через его собственный сервис — cms/import не пишет в чужие таблицы напрямую |
cms/audit (если включён) | слушает событие (канал 1) | in для audit | Фиксирует факт запуска/завершения импорта, кто запустил, сколько строк |
cms/notifications-bus (если включён, import.notify_on_completion) | сервис-вызов (канал 3, notification-channel) | out | Уведомление ответственного о завершении задания; выключен — log-fallback |
Фоновая работа
Очередь import — весь пайплайн (валидация, upsert, инвалидация кеша) батчами через Bus::batch, синхронного импорта в HTTP-запросе нет. Джобы идемпотентны по external_id. Чтение исходного файла — потоковое (генератор по строкам CSV/XML-парсера, не file_get_contents/SimpleXML::load целиком) вне зависимости от import.chunk_size: чанк управляет размером батча записи в БД, а не тем, сколько файла лежит в памяти одновременно. Bulk-канал JSONL — staged-upload сохраняется в хранилище, дальше обрабатывается той же очередью import, отчёт пишется построчно в JSONL по мере обработки (строка N результата доступна, не дожидаясь всего файла).
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
Задание зависло в running | глубина очереди import, живость воркера | cms:import:status --json |
| Импорт молча не пишет в целевой модуль | зарегистрирован ли import-target у владельца | cms:map --json (секция provides) |
| Массовый сбой строк с одной ошибкой | некорректный маппинг-профиль | сверка cms_import_mappings |
| Срочно остановить приём новых импортов | инцидент у целевого модуля/БД | import.new_jobs_paused=true |
| Bulk JSONL не завершается | staged-upload просрочен, object_count не растёт | GET /bulk/{id} |
Производительность и кеш
Ожидаемые объёмы (managed-парк студии): типовой прайс/фид — от сотен до десятков тысяч строк (каталоги услуг/товаров), фиды маркетплейсов (аналог Wildberries-фида донора) — до import.max_rows_per_file (200000) строк, файлы CSV/XML — единицы-десятки мегабайт, лимит import.max_file_size_mb (50) — заградительный барьер для файлов, приложенных не тем способом (полный дамп БД в CSV и подобное).
Горячие пути и бюджет запросов: создание задания — 1 запрос (insert cms_import_jobs) + постановка Bus::batch в очередь, без синхронного парсинга файла в HTTP-запросе; статус задания — 1 запрос по PK; список ошибок — keyset-пагинация по индексу (import_job_id, row_number), без OFFSET. Upsert — один batched-запрос на чанк (import.chunk_size строк), не построчный INSERT; на PostgreSQL highload-путь — COPY/upsert ... on conflict вместо построчных вставок.
Что кешируется: сохранённые пресеты маппинга (cms_import_mappings) — read-heavy, редко меняются, тег import:mappings, инвалидация по событию сохранения/удаления пресета. Статус и прогресс задания — не кешируются, потому что читаются с высокой частотой поллингом Filament UI во время выполнения и обязаны быть свежими на 100% — кеш дал бы админу устаревший прогресс. Инвалидация кеша целевых моделей — одним батчем по ImportJobCompleted (import.cache_flush_batched), не на каждую строку и не на каждый чанк.
Безопасность
Загружаемый файл — только через медиатеку с проверкой типа/размера (import.max_file_size_mb), маппинг колонок — FormRequest-whitelist разрешённых атрибутов модели, без маппинга на системные поля. Импорт из внешнего API — только через cms/integrations-bus, не прямым HTTP.
Векторы, специфичные для модуля:
- XXE/entity expansion в XML-источнике — парсер XML обязан грузиться с отключёнными внешними сущностями (
libxml_disable_entity_loader/аналог), иначе файл-эксплойт получает доступ к файловой системе воркера через «безобидную» загрузку XML; - CSV/formula injection в отчёте об ошибках — построчный
raw_rowможет содержать значения, начинающиеся с=/+/-/@(формулы Excel); отчёт об ошибках при генерации экранирует такие значения (ведущий апостроф/квотирование), иначе открытие CSV админом в Excel выполняет чужой код; - маппинг на системные/скрытые поля — whitelist FormRequest действует и на
id,created_at/updated_at/deleted_at, поля авторизации и связи — не только на «очевидно опасные» бизнес-поля; - произвольная цель импорта — только реализации
import-target, зарегистрированные модулями- владельцами, плюс whitelist сущностей ядра изimport.allowed_target_models; произвольный Eloquent-класс приложения недостижим как цель в принципе (нет reflection по имени класса из пользовательского ввода — резолв только по имени зарегистрированного контракта); - данные из внешнего API как доверенные — payload из
cms/integrations-busпроходит те же rules движка полей, что и загруженный файл; коннектор не считается «безопасным источником» по умолчанию; - flood создания заданий — rate-limit на
POST /jobs(per-граница ядра), иначе очередьimportзаваливается фиктивными заданиями; bulk-канал (POST /bulk) вне общего rate-limit, но ограничен «1 активная операция на клиента» (api-lessons §8) — тот же эффект другим механизмом.
ПДн-паспорт (матрица v2.2). Собственные таблицы не хранят ПДн намеренно, но cms_import_job_errors.raw_row может содержать ПДн импортируемых записей (email/телефон в фиде) — покрывается тем же error_report_retention_days (30 дней). ПДн в целевой сущности — паспорт модуля-владельца import-target, не этого модуля. «Выгрузить/забыть по запросу» — не применимо напрямую, данные не живут дольше ретеншна отчёта об ошибках.
Матрица ролей:
| Роль | Просмотр заданий/отчётов | Создание/отмена/маппинг-профили |
|---|---|---|
| studio | ✅ | ✅ |
| админ | ✅ | ✅ |
| менеджер | ✅ | ✅ (в проектах, где импорт — операционная задача, не только техническая) |
| редактор | ❌ | ❌ |
Права: import.view, import.manage.
UX-требования
Админ:
- пустое состояние списка заданий — «Импортов ещё не было» с кнопкой «Создать задание», не пустая таблица без объяснения;
- массовое действие на списке заданий — отмена/повторный запуск нескольких заданий одним действием (для «зависших» после сбоя внешнего API), не по одному клику;
- построчные ошибки читаются как факт на человеческом языке: «Строка 42: поле
emailобязательно к заполнению» — не голый JSONerrorsизcms_import_job_errors; при массе однотипных ошибок — агрегация («ещё 128 строк с той же ошибкой»), не простыня из тысяч строк; - подтверждение перед запуском не-dry-run импорта — модальное окно с числом строк и предупреждением о необратимости upsert, особенно если пользователь явно выключил
dry_run_default; - прогресс задания виден в реальном времени (
processed_rows/total_rows), не «крутилка» без деталей — критично для файлов в десятки тысяч строк; - дублирующиеся
external_idв самом файле (внутри одной загрузки) — не тихий overwrite, а видимая пользователю сводка «N строк переопределили более раннюю строку того же файла».
Посетитель: прямого взаимодействия с модулем нет — импорт целиком admin-only функциональность, публичный сайт не обращается к cms/import ни в одном сценарии.
Крайние случаи и типовые баги
- битые кодировки/CSV (не UTF-8, BOM, разные разделители, кавычки/переносы строк внутри полей) → потоковый парсер определяет и приводит кодировку к UTF-8 на входе, BOM отбрасывается, разделитель — из
import.csv_delimiter_defaultлибо явно указан на задании; строка с неразрешимым конфликтом кавычек/переноса — построчная ошибка в отчёте, не падение всего файла; - частичный импорт с ошибками → построчный отчёт в
cms_import_job_errorsобязателен для каждой невалидной строки,ImportJobCompletedсо статусом «частично» отражаетerror_rows— молчаливый пропуск строки без записи в отчёт считается багом, а не приемлемой деградацией; - повторный импорт того же файла → при идентичном файле совпадающие
external_idобрабатываются поimport.duplicate_strategy(updateпо умолчанию — те же значения, эффект нулевой;skip— строки не трогаются;fail— задание завершается с построчными конфликтами), дублей записей не создаётся ни при каком режиме — идемпотентность гарантируется на уровнеexternal_id, не на уровне «файл тот же/другой»; - повторный импорт частично изменённого файла → строки с изменившимися значениями обновляются по
duplicate_strategy=update, неизменившиеся — no-op, новыеexternal_id— вставляются; поведение идентично «первому» импорту минус уже существующие неизменные строки; - огромный файл → до постановки в очередь проверяются
import.max_file_size_mbи (после предварительного подсчёта строк)import.max_rows_per_file; превышение — 422 без постановки задания в очередь, а не обрыв уже выполняющейся джобы; в границах лимитов чтение — потоковое (генератор по строкам), файл целиком в память не грузится независимо отchunk_size; - двойной сабмит формы создания задания →
Idempotency-KeyнаPOST /jobsпредотвращает создание второго идентичного задания при повторном клике/ретрае клиента; - параллельные джобы над одной целью → батчи
Bus::batchразных заданий над одной реализациейimport-targetне блокируют друг друга искусственно (upsert поexternal_id— конфликты решает БД/сам модуль-владелец вupsertBatch()), но два параллельных задания с разнымиduplicate_strategyнад одними и теми же строками дают недетерминированный порядок применения — задание блокируется, если для той же цели уже выполняется активное задание (лок на уровне БД), второе — в очередь после первого; - выключение модуля посреди выполнения → уже запущенная джоба в очереди
importдоигрывает до конца (диспетчер очереди не знает о состоянии модуля ядра), UI/API создания новых заданий недоступны сразу; ранее импортированные данные не откатываются; - отсутствие suggests-модуля
cms/integrations-bus→ источник «внешний API» скрыт/недоступен в мастере создания задания, доступен только «файл» — не ошибка 500 при попытке выбрать отсутствующий источник; - сбой/таймаут внешнего API (через
cms/integrations-bus) → чанк, не дождавшийся ответа, фиксируется как построчная ошибка «источник недоступен», задание не зависает бесконечно — таймаут на уровне джобы, ретраи с backoff (правило шины интеграций), после исчерпания —ImportJobCompletedсо статусом «провал»; - пустой файл/датасет → задание завершается статусом «успех, 0 строк», не ошибкой — админ видит явное «файл не содержит строк для импорта», а не подвисшее «processing»;
- отсутствие измерений locale/city/site у целевой модели → если целевая модель их не несёт, импорт работает без них (правило измерений §4 стандарта — nullable, не отдельная ветка кода); если несёт —
RequestContextзадаёт значение по умолчанию для всей партии, а не построчно; - новая bulk-операция при уже активной у того же клиента →
POST /bulkотклоняется 409 «уже выполняется bulk-операция» (лимит api-lessons §8: 1 активная операция на клиента), клиент дожидается завершения или отменяет текущую, не плодит параллельные staged-upload; - истёк TTL подписанной ссылки на JSONL-отчёт (
jsonl_signed_url_ttl_days) → ссылка возвращает 403 у объектного хранилища, не 500 уcms/import; повторный запрос статуса задания всё ещё отдаёт метаданные (object_count, время завершения), только сам файл недоступен — задание не считается «потерянным», просто отчёт истёк по TTL; - отсутствует реализация
import-targetдля нужной сущности → мастер создания задания не предлагает её как цель (не ошибка, штатная деградация канала 3, см. «Зависимости и выключение»); попытка обратиться напрямую по API с несуществующим именем цели —422до постановки в очередь; - Батчевый upsert в доменные модули — зафиксированное решение (ревизия ядра 14.07.2026, п.9). Исходная версия ТЗ конфигурировала upsert в произвольную
target_model, что противоречило §5 стандарта и data-exchange.md (запись в чужие таблицы только через сервис владельца), при этом жёсткой зависимости на конкретные модули-владельцы в манифесте не было и не могло быть (список целей меняется на лету в UI). Разрешение: реестр provides пополнен каноническимimport-target— модуль-владелец реализует контракт (upsertBatch()со своей валидацией/событиями) и регистрируется при bootstrap;cms/importрезолвит цель через DI (канал 3), никогда не пишет в чужую таблицу напрямую. «Карантин» ad-hoc целей снят — доменные модули участвуют штатно через контракт.
Донорский код
| Что взять | Путь |
|---|---|
| Excel-импорт, построчная валидация | masha/src/app/Domain/Import/ExcelImportService.php |
| Импорт фида Wildberries (маппинг, идемпотентность) | masha/src/app/Domain/Wb/ |
| Импортёры категорий/товаров/изображений чанками | universal/src/app/Services/Import/CategoryImporter.php, ProductImporter.php, ProductImageImporter.php |
Миграция legacy-данных (§16, матрица v2.2): cms/import — сам механизм, которым другие модули реализуют cms:<slug>:import-legacy (донор → маппинг-профиль → import-target). Для самого cms/import переноса нет — профили создаются администратором по месту. CategoryImporter/ ProductImporter донора — референс реализации import-target в модуле-владельце.
Тесты и приёмка
- [ ] Контрактный тест: повторный импорт с тем же
external_idне создаёт дублей (идемпотентность) - [ ] Health-чек модуля отражает зависшие/просроченные задания импорта
- [ ] При выключении модуля ранее импортированные данные остаются целыми
- [ ] Батчевый upsert не создаёт N+1: один запрос на чанк, не на строку
- [ ] Инвалидация кеша выполняется один раз по завершении задания, не на каждую строку
- [ ] Dry-run не пишет в БД — покрыто тестом
- [ ] Права
import.view/import.manageразграничивают просмотр и запуск заданий - [ ] Битые CSV (не-UTF8, BOM, разные разделители, переносы строк в кавычках) не роняют джобу, дают построчные ошибки
- [ ] XML-парсер не резолвит внешние сущности (XXE) — контрактный тест на злонамеренный файл
- [ ] Отчёт об ошибках экранирует формулы Excel (
=/+/-/@) при скачивании CSV - [ ] Частичный импорт даёт полный построчный отчёт, ни одна невалидная строка не пропущена молча
- [ ] Файл сверх
import.max_file_size_mb/import.max_rows_per_fileотклоняется до постановки в очередь (422), не обрывает уже запущенную джобу - [ ] Потоковое чтение файла — тест на файл, заведомо больший разумного лимита памяти воркера
- [ ] Выбор цели импорта ограничен зарегистрированными
import-target+ whitelist сущностей ядра — попытка обратиться к незарегистрированной цели даёт422 - [ ] Батчевый upsert для доменной цели идёт через
ImportTargetContract::upsertBatch(), не raw mass-assignment - [ ] Двойной сабмит
POST /jobsсIdempotency-Keyне создаёт второе задание - [ ] Отсутствие
cms/integrations-busскрывает источник «API», не роняет мастер создания задания - [ ] Таймаут внешнего источника фиксируется построчной ошибкой, задание не зависает
- [ ] Bulk-канал JSONL:
POST /bulkотклоняет вторую активную операцию того же клиента (409) - [ ] JSONL-отчёт bulk-импорта — строка N соответствует результату строки N входного файла
- [ ]
import.new_jobs_paused=trueблокирует создание новых заданий, не влияет на уже идущие - [ ] Маппинг-профиль (
cms_import_mappings) переиспользуется между заданиями без потери настроек - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
import_test,migrate:freshзапрещён