Тема
ТЗ — Услуги (иерархия) (cms/services)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: artel-23ru Статус: ТЗ к разработке
🔄 Ревизия (волна D корп-MVP, 15.07.2026, реализация): доведён до продакшен-среза: Filament-ресурсы
Service/ServiceCategory(RelationManager, reorder, optimistic-locklock_version, галерея-портфолиоgalleryjsonb из медиатеки, RichEditor с двойным барьером санитайза); публичные HTML-страницы/{route_prefix}(каталог по категориям + группа «без категории») и/{route_prefix}/{slug}(карточка: цена, описание, галерея, форма заявки через блок ядра, похожие, хлебные крошки) — модульные роуты выигрывают у fallback-резолвера, префикс резервируется вcms.routing.reserved_prefixes; SEO-цель передаётся ядру generic-контекстомseo_page_type/seo_entity(мета-слои и SEO-блоки ядра работают на карточке: позиции after_hero/after_prices/before_footer);ServiceSavedнесётpreviousSlug→ авто-301 (services-seo). Вне среза (по этому ТЗ): 3-й уровень иерархии (направления), city_id/locale измерения, admin API, legacy-импорт.
🔄 Ревизия (корпоративный MVP, 15.07.2026): услуги остаются бесспок-модулем (иерархия направление→категория→услуга + SEO-подсистема — не тип контента, по производительности/модели; граница гибрида — engine §1, критерий «тип перерос движок» — engine §12). Корп-MVP добавляет к этому ТЗ (приоритет P0): Filament ресурсы услуг, публичную карточку
/{slug}, вывод иерархичного SEO-блока услуг на фронт (сейчас есть в админке, не рендерится), 3-й уровень иерархии.
Назначение и возможности
3-уровневая иерархия услуг «направление → услуга → подуслуга» с ценами «от», привязкой к портфолио и заявкам. Базовый переносимый модуль студии для любого проекта с прайс-листом услуг — работает как в мультигороде, так и на сайте одного города. Детальный разбор эталонного донора и рекомендуемая разбивка на связанные модули — /cms-v2/services-module.
- 3-уровневая иерархия: направление → услуга → подуслуга (FK parent→child,
cascadeOnDelete) - Цены «от» с типом (
fixed/from/by_agreement) и единицей измерения sort_orderс drag&drop переупорядочиванием в Filament- Уникальность slug в рамках направления (
unique(direction_id, slug)) - Связь с портфолио/кейсами через медиатеку ядра (см. ⚠️ противоречие в «События и обмен»)
- Связь с заявками (лид фиксирует, к какой услуге относится обращение)
- Падежи названия услуги/направления (см.
cms/services-seo) как отдельный слой - Склонения хранятся в отдельном модуле — сам модуль услуг падежей не считает
Зависимости и выключение
requires: ядро · suggests: cms/multicity (город-падежи, гео-контекст на карточке услуги)
Поведение при выключении: карточки услуг и блок «Прайс-лист» перестают рендериться (fallback-заглушка «раздел временно недоступен»), связанные заявки и портфолио сохраняют данные, но теряют отображаемую связь с услугой до включения модуля обратно. По каскаду выключения (граф зависимостей) ядро не даст выключить cms/services, пока включён cms/services-seo или cms/services-axes (оба requires: cms/services) — сначала выключаются они.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_service_directions | id, slug, title, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_version | верхний уровень иерархии |
cms_service_categories | id, direction_id, slug, title, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_version | средний уровень |
cms_services | id, category_id, slug, title, price, price_type, unit, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_version | нижний уровень, лист иерархии |
cms_service_gallery | service_id, media_id, sort_order | pivot к медиатеке ядра для портфолио |
FK иерархии — constrained()->cascadeOnDelete() + index() на каждом уровне; price_type — PHP Enum, не строка; unique(category_id, slug) — составной индекс; external_id — ключ идемпотентности legacy-импорта (§ «Донорский код»); lock_version — optimistic lock конкурентного редактирования (§4 стандарта).
ПДн-паспорт: модуль ПДн не хранит — таблицы содержат только справочные данные услуг (название, цена, slug). Привязка заявки к услуге живёт в модуле заявок (leads.service_id как ссылка на cms_services.id), ПДн посетителя там же, не здесь; «выгрузить всё по субъекту»/«забыть по запросу» этот модуль не реализует — нечего выгружать.
Входные и выходные данные
Входы (whitelist-принцип: всё, что не перечислено, — отвергается 422):
| Источник | Поля | Чем валидируется |
|---|---|---|
| Filament-форма направления | title, slug, sort_order, locale, city_id | FormRequest движка полей, whitelist |
| Filament-форма категории | direction_id, title, slug, sort_order | FormRequest + проверка «direction_id существует и не глубже max_depth» |
| Filament-форма услуги | category_id, title, slug, price, price_type, unit, sort_order, description (rich-text) | FormRequest + rich-text санитайзер ядра при сохранении |
POST/PUT /api/v1/admin/services… | те же поля, JSON | тот же FormRequest, что и Filament (общий rules-класс), permissions: services.manage |
Drag&drop reorder (Filament/PATCH .../reorder) | [{id, sort_order, lock_version}] | FormRequest + optimistic lock (несовпадение lock_version → 409) |
Публичный GET /api/v1/services | filter[city_id], cursor, per_page | whitelist фильтров/сортировок (spatie/laravel-query-builder), неизвестный параметр → 422 |
Событие MediaUploaded (ядро) | media_id, collection | не парсится вручную — только media_id кладётся в pivot по ссылке |
Импорт-файл cms:services:import-legacy | донорские таблицы services/categories/directions | маппер + external_id, --dry-run без записи |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
Публичный сайт GET /api/v1/services… | дерево направлений/категорий/услуг, карточка | JSON {data, meta}, keyset-пагинация |
| Блок «Прайс-лист категории» / «Карточка услуги» | услуги с ценами, портфолио | Blade props из сервиса (не запрос из шаблона) |
cms/services-seo (requires) | title, slug, breadcrumbs услуги/категории/направления | вызов публичного сервиса ServicesService::find() (канал 4) |
cms/services-axes (requires) | service_id, category_id для кросс-продукта | вызов публичного сервиса (канал 4) |
cms/leads (ссылка, не запрос) | service_id как FK на первичный ключ | приложение к заявке, без выгрузки бизнес-данных услуги |
Событие ServiceSaved/ServiceDeleted | service_id, level, city_id | payload EventBus |
Настройки (группа services)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
services.price_currency | string | RUB | да | Валюта отображения цен |
services.show_price_by_default | bool | true | да | Показывать цену на карточке, если не задан оверрайд |
services.max_depth | int | 3 | нет | Контроль глубины иерархии (защита от случайного 4-го уровня) |
services.gallery_enabled | bool | true | да | Включение блока портфолио на карточке услуги |
services.max_services_per_category | int | 500 | нет | Лимит-квота: защита от аномального разрастания категории (лимит — понятная ошибка при превышении, не тихое обрезание) |
services.bulk_delete_confirm_threshold | int | 10 | нет | Kill-switch для массового удаления: удаление направления/категории с числом дочерних услуг выше порога требует явного подтверждения с указанием количества |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/services | public | Дерево направлений/категорий/услуг (с фильтром по городу) |
| GET | /api/v1/services/{slug} | public | Карточка услуги с ценой, портфолио, хлебными крошками |
| GET/POST/PUT/DELETE | /api/v1/admin/services… | admin (services.manage) | CRUD всех трёх уровней иерархии |
| PATCH | /api/v1/admin/services/reorder | admin (services.manage) | Массовое изменение sort_order с проверкой lock_version на каждой строке |
Коллекции (GET /api/v1/services) — keyset-пагинация по (sort_order, id), не OFFSET.
Компоненты
Блоки (BlockRegistry): «Прайс-лист категории», «Карточка услуги», «Список направлений» — демо-props из сидера (галерея /_gallery показывает блоки без ручного ввода тестовых услуг). Filament: ресурсы направлений/категорий/услуг с RelationManager для вложенного редактирования и drag&drop сортировкой; массовые действия в таблицах (переместить в другую категорию, изменить price_type, удалить с подтверждением). Команды: cms:services:reprice --json.
Фронтенд-бюджет: блок «Прайс-лист категории» — без тяжёлых зависимостей (обычная таблица/список), портфолио-галерея на карточке — lazy-load изображений по видимости (loading="lazy" + резерв aspect-ratio, без CLS); drag&drop сортировка — только в админке, публичная навигация по иерархии полностью доступна с клавиатуры (Tab/Enter по ссылкам дерева).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ServiceSaved | услуга/категория/направление сохранены | service_id, level, city_id |
ServiceDeleted | услуга удалена (каскад дочерних) | service_id, level |
Слушает: MediaUploaded из ядра — не создаёт файлов, только ссылается через pivot cms_service_gallery. Provides-контрактов не реализует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
ядро (MediaService) | событие MediaUploaded | ядро → cms/services | модуль слушает, кладёт media_id в cms_service_gallery по ссылке |
ядро (RequestContext) | контекст запроса (не событие) | ядро → cms/services | city_id текущего визита резолвится ядром, модуль не парсит URL сам |
cms/multicity | provides: city-context | cms/multicity → cms/services | при включённом suggests город фильтрует дерево и определяет city_id записи |
cms/services-seo | requires (прямой сервис-вызов) | cms/services-seo → cms/services | читает title/slug/breadcrumbs через ServicesService::find() для шаблонов и склонений |
cms/services-axes | requires (прямой сервис-вызов) | cms/services-axes → cms/services | читает service_id/category_id для построения кросс-продукта URL |
cms/leads | FK на первичный id (без сервис-вызова) | cms/leads → cms/services | заявка хранит service_id; при удалении услуги ссылка «осиротевает», не каскадится |
очередь services | job reprice | cms/services (само себе) | массовый пересчёт цен по расписанию |
⚠️ Противоречие: в «Назначение и возможности» ранее была заявлена прямая связь с модулем cms/galleries («портфолио/кейсы через cms/galleries»), но cms/galleries не объявлен ни в requires, ни в suggests, а таблица cms_service_gallery физически ссылается на media_id (медиатека ядра), а не на сущности cms/galleries. Вызов чужого модуля без объявленной зависимости — анти-паттерн скрытой связи (§5 стандарта). Разрешение, принятое в этом ТЗ: портфолио на карточке услуги — это медиатека ядра (MediaService), не модуль cms/galleries; формулировка в «Назначение» исправлена выше. Если проекту нужна витрина кейсов именно модулем cms/galleries — это отдельная интеграция, добавляемая через suggests: cms/galleries + requires-вызов его сервиса, а не через прямую ссылку в pivot-таблице cms/services.
Фоновая работа
Джоба cms:services:reprice (очередь services) — массовый пересчёт отображаемых цен по расписанию через ScheduleRegistrar ядра при изменении services.price_currency. Джоба идемпотентна (повторный прогон не меняет результат при неизменном курсе/цене), батчами через Bus::batch с прогрессом, видимым в Filament.
Эксплуатация (мини-ранбук):
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| Цены на сайте не обновились после смены валюты | Очередь services не отстаёт? | cms:services:reprice --dry-run --json — отчёт расхождений, затем без --dry-run |
| Дерево иерархии «рвётся» (услуга без категории) | Осиротевшие строки после ручной правки БД (запрещено политикой, но проверить факт) | cms:doctor --json (секция services) — консистентность FK |
| 409 массово на reorder | Два админа одновременно тащили список | Информационный, не инцидент — конфликт разрешается повторным сохранением |
| После рестора дерево пустое | Таблицы cms_service_* входят в бэкап (данные), но кеш-теги — нет | Пересоздаётся автоматически на первом обращении (page-cache) — ручных действий не требует |
Метрики: длительность reprice по числу услуг, число 409-конфликтов reorder за период (алерт при аномальном росте — признак сломанного UI, а не пользовательской гонки).
Производительность и кеш
Ожидаемые объёмы: 10–50 направлений, 50–300 категорий, 500–5000 услуг на сайт (на мультигороде — то же дерево умножается измерением city_id, если используются per-город записи, а не общий прайс с оверрайдами); до ~20 медиа-ссылок портфолио на услугу.
Горячие пути: GET /api/v1/services (дерево — публичный прайс-лист, высокая частота) и GET /api/v1/services/{slug} (карточка услуги) — оба почти всегда отдаются из page-cache; на промахе бюджет — дерево ≤3 запроса (direction->with('categories.services')), карточка ≤2 запроса (услуга + портфолио через withCount/with('gallery.media')).
Критичные индексы: index(sort_order) в паре с parent-FK на каждом уровне, unique(category_id, slug), index(city_id) (nullable-измерение — учитывается и в отсутствующем виде: WHERE city_id IS NULL OR city_id = ?), FK-индексы каскада.
Теги кеша: services (дерево целиком), services:city:{id} (сегмент мультигорода, если измерение активно) — инвалидация по ServiceSaved/ServiceDeleted только своим городом/веткой, не флашем всего тега. Влияет на page-cache карточек услуг и списков направлений. Массовый reprice — батчевая инвалидация одним тегом в конце батча, не по одной услуге.
Безопасность
Границы входа: FormRequest-whitelist на CRUD иерархии (админка), публичный вход — только параметры фильтра (city_id, пагинация) через whitelist. Rich-текст в описании услуги санитизируется двойным барьером (на сохранении и на выводе). Rate-limit не требуется (публичные эндпоинты только на чтение). Пользовательских regex модуль не вводит — ReDoS не применим.
Векторы атак и защита:
- Подмена
category_id/direction_idчерез API (перенос услуги в чужую категорию другого сайта/города) — FormRequest проверяет принадлежность целевого узла тому жеcity_id/site_id, что и перемещаемая сущность, не только факт существования id; - Массовое удаление через
DELETEбез подтверждения — см.bulk_delete_confirm_thresholdв «Настройки»: удаление с числом дочерних выше порога требует явного второго запроса с явным списком id (не «удалить все»); - IDOR через автоинкрементный id — публичные роуты используют
slug, неid(§4 стандарта); админ-роуты —idпод Policy с проверкой владения сайтом.
Матрица ролей (permissions services.view, services.manage):
| Роль | services.view | services.manage | Комментарий |
|---|---|---|---|
| studio (разработчик студии) | ✅ | ✅ | Плюс диагностика cms:services:reprice, снятие kill-switch |
| админ (владелец сайта) | ✅ | ✅ | Полный CRUD иерархии своего сайта |
| менеджер (контент-менеджер клиента) | ✅ | ✅ | CRUD без доступа к настройкам max_depth/лимитам |
| редактор | ✅ | ✅ (только назначенные категории) | Policy ограничивает запись вне назначенных разделов; удаление направлений — недоступно |
UX-требования
Для админа: пустое состояние направления без категорий — подсказка «Добавьте первую категорию» вместо пустого списка; массовые действия на списках услуг (переместить в категорию, изменить price_type, удалить с подтверждением); человеческие ошибки — 409 на reorder/сохранении показывает «Запись изменил другой пользователь, обновите страницу», а не код исключения; подтверждение необратимых операций — удаление направления/категории с дочерними требует модального подтверждения с указанием точного количества затронутых услуг.
Для посетителя: фильтр по городу на публичном прайс-листе сохраняется в URL/cookie и не сбрасывается при ошибке; ответ из page-cache — без ощутимой задержки; таблица прайс-листа — семантическая разметка (<table>/<th scope>), цена и единица измерения доступны скринридеру, хлебные крошки — с aria-label.
Крайние случаи и типовые баги
- Перемещение категории с услугами в другое направление → транзакция:
category.direction_idменяется, дочерние услуги наследуют новыйdirection_idчерез FK-цепочку без прямой правки; slug-уникальность проверяется в целевом направлении (конфликт → 422), кеш-теги инвалидируются для обеих веток (старой и новой). - Циклы в дереве (
parent_id = selfили родитель становится своим потомком) → при сохранении рекурсивная проверка «цель — не текущий узел и не его потомок» доmax_depth; нарушение → 422 «нельзя сделать раздел вложенным в самого себя». - Удаление услуги со связанным SEO-контентом и заявками →
ServiceDeletedуходит в шину;cms/services-seoслушает и архивирует/удаляетcms_seo_meta_overridesиcms_declensionsпоentity_id(не FK cascade — другой модуль, только через событие); заявки (cms/leads) сохраняютservice_idкак осиротевшую ссылку — UI заявки показывает «услуга удалена», история не теряется. - Глубина дерева и генерация меню →
services.max_depth=3блокирует создание 4-го уровня (422 при попытке); меню строится по включённым направлениям/категориям без рекурсии глубже лимита — доп. уровень в БД (если появится импортом) в меню не попадает, а не валит рендер. - Гонки при drag&drop сортировке двумя админами →
lock_versionна каждой переупорядочиваемой строке; несовпадение версии вPATCH .../reorder→ 409 с ответом «порядок изменён другим пользователем», клиент обязан перезапросить список. - Пустой прайс-лист (0 услуг в направлении) → блок «Прайс-лист категории» отдаёт пустое состояние с текстом (не 404 и не пустой
<div>); направление с 0 опубликованных категорий скрывается из публичного меню, но не удаляется. - Выключенный
cms/multicity→city_id— nullable-измерение: услуги трактуются как глобальные, фильтрcity_idв API молча игнорируется (не 422), карточка не показывает региональный блок — контрактный тест гоняется в обоих режимах (§4 стандарта). - Конкурентное редактирование услуги двумя админами →
lock_versionв телеPUT /api/v1/admin/services/{id}; несовпадение → 409 + человекочитаемое сообщение, не «последний победил» молча. - Гонка
repriceи ручного редактирования услуги → массовый пересчёт цены — batchUPDATEпо фильтру, не по одной строке через модель; если админ в этот момент сохраняет черновик той же услуги — егоlock_versionне совпадёт с результатом batch (batch обязан бампатьlock_version), сохранение админа отдаёт 409 вместо тихой перезаписи цены сразу после смены курса. - Каскадное удаление направления с сотнями услуг → синхронный
cascadeOnDeleteна большом поддереве может держать лок таблицы; при превышенииbulk_delete_confirm_thresholdудаление уходит в очередьservices(батчами), Filament показывает прогресс, а не подвешивает запрос. - Перенос услуги в категорию с занятым slug →
unique(category_id, slug)ловит конфликт при сменеcategory_id, не только при создании — 422 с указанием, какая услуга уже занимает этот slug в целевой категории.
Донорский код
| Что взять | Путь |
|---|---|
| Иерархия направлений/категорий/услуг, цены | universal/src/app/Models/{ServiceDirection,ServiceCategory,Service,BrandService}.php (свежий обобщённый порт) · первоисточник artel-23ru/src/app/Models/ |
Трейт склонений (интерфейс для cms/services-seo) | universal/src/app/Traits/HasDeclensions.php · первоисточник artel-23ru/src/app/Traits/HasDeclensions.php |
| Трейт привязки к городу | universal/src/app/Traits/BelongsToCity.php · первоисточник artel-23ru/src/app/Traits/BelongsToCity.php |
Legacy-импорт (cms:services:import-legacy --source=artel-23ru --dry-run): маппинг донорских таблиц services/service_categories/service_directions (artel-23ru) на cms_services/cms_service_categories/cms_service_directions; связь по донорскому id записывается в новую колонку external_id — повторный прогон обновляет запись по external_id, а не создаёт дубль. --dry-run печатает отчёт (создано/обновлено/пропущено и почему) без записи в БД; построчные ошибки (например конфликт slug при импорте двух донорских услуг с одинаковым названием в одну категорию) — в скачиваемый отчёт, молчаливый пропуск запрещён (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест
/api/v1/services/{slug}возвращает корректную иерархию и хлебные крошки - [ ] При выключении модуля блоки услуг отдают fallback-заглушку, данные заявок не теряются
- [ ] Права
services.view/services.manageразграничивают публичный доступ и админку по матрице ролей - [ ] Нет N+1 при построении дерева (
->with('categories.services')) - [ ] Инвалидация page-cache по тегу
services/services:city:{id}приServiceSaved/ServiceDeleted - [ ]
unique(category_id, slug)защищает от дублей на одном уровне и при переносе между категориями - [ ] Каскадное удаление направления не оставляет висячих услуг (
cascadeOnDelete); большое поддерево уходит в очередь по порогуbulk_delete_confirm_threshold - [ ] Защита от циклов:
parent_id = selfи «потомок как родитель» отдают 422, не создают бесконечную рекурсию - [ ]
PATCH .../reorderиPUT .../{id}отдают 409 при несовпаденииlock_version, не перезаписывают молча - [ ]
services.max_depthблокирует создание 4-го уровня иерархии - [ ] Публичный фильтр
city_idне падает и не 422-ит при выключенномcms/multicity(nullable-режим) - [ ]
cms:services:import-legacy --dry-runне пишет в БД; повторный прогон без--dry-runидемпотентен поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (
/api/v1/services,/api/v1/services/{slug}, admin CRUD, reorder) - [ ] Тестовая БД только
services_test;migrate:fresh/refresh/reset/db:wipeзапрещены