Skip to content

ТЗ — Услуги (иерархия) (cms/services)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: artel-23ru Статус: ТЗ к разработке

🔄 Ревизия (волна D корп-MVP, 15.07.2026, реализация): доведён до продакшен-среза: Filament-ресурсы Service/ServiceCategory (RelationManager, reorder, optimistic-lock lock_version, галерея-портфолио gallery jsonb из медиатеки, 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_directionsid, slug, title, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_versionверхний уровень иерархии
cms_service_categoriesid, direction_id, slug, title, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_versionсредний уровень
cms_servicesid, category_id, slug, title, price, price_type, unit, sort_order, locale, city_id (nullable), external_id (nullable, unique), lock_versionнижний уровень, лист иерархии
cms_service_galleryservice_id, media_id, sort_orderpivot к медиатеке ядра для портфолио

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_idFormRequest движка полей, whitelist
Filament-форма категорииdirection_id, title, slug, sort_orderFormRequest + проверка «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/servicesfilter[city_id], cursor, per_pagewhitelist фильтров/сортировок (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/ServiceDeletedservice_id, level, city_idpayload EventBus

Настройки (группа services)

КлючТипДефолтaffectsPageCacheОписание
services.price_currencystringRUBдаВалюта отображения цен
services.show_price_by_defaultbooltrueдаПоказывать цену на карточке, если не задан оверрайд
services.max_depthint3нетКонтроль глубины иерархии (защита от случайного 4-го уровня)
services.gallery_enabledbooltrueдаВключение блока портфолио на карточке услуги
services.max_services_per_categoryint500нетЛимит-квота: защита от аномального разрастания категории (лимит — понятная ошибка при превышении, не тихое обрезание)
services.bulk_delete_confirm_thresholdint10нетKill-switch для массового удаления: удаление направления/категории с числом дочерних услуг выше порога требует явного подтверждения с указанием количества

API

МетодПутьДоступНазначение
GET/api/v1/servicespublicДерево направлений/категорий/услуг (с фильтром по городу)
GET/api/v1/services/{slug}publicКарточка услуги с ценой, портфолио, хлебными крошками
GET/POST/PUT/DELETE/api/v1/admin/services…admin (services.manage)CRUD всех трёх уровней иерархии
PATCH/api/v1/admin/services/reorderadmin (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/servicescity_id текущего визита резолвится ядром, модуль не парсит URL сам
cms/multicityprovides: city-contextcms/multicitycms/servicesпри включённом suggests город фильтрует дерево и определяет city_id записи
cms/services-seorequires (прямой сервис-вызов)cms/services-seocms/servicesчитает title/slug/breadcrumbs через ServicesService::find() для шаблонов и склонений
cms/services-axesrequires (прямой сервис-вызов)cms/services-axescms/servicesчитает service_id/category_id для построения кросс-продукта URL
cms/leadsFK на первичный id (без сервис-вызова)cms/leadscms/servicesзаявка хранит service_id; при удалении услуги ссылка «осиротевает», не каскадится
очередь servicesjob repricecms/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.viewservices.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/multicitycity_id — nullable-измерение: услуги трактуются как глобальные, фильтр city_id в API молча игнорируется (не 422), карточка не показывает региональный блок — контрактный тест гоняется в обоих режимах (§4 стандарта).
  • Конкурентное редактирование услуги двумя админамиlock_version в теле PUT /api/v1/admin/services/{id}; несовпадение → 409 + человекочитаемое сообщение, не «последний победил» молча.
  • Гонка reprice и ручного редактирования услуги → массовый пересчёт цены — batch UPDATE по фильтру, не по одной строке через модель; если админ в этот момент сохраняет черновик той же услуги — его lock_version не совпадёт с результатом batch (batch обязан бампать lock_version), сохранение админа отдаёт 409 вместо тихой перезаписи цены сразу после смены курса.
  • Каскадное удаление направления с сотнями услуг → синхронный cascadeOnDelete на большом поддереве может держать лок таблицы; при превышении bulk_delete_confirm_threshold удаление уходит в очередь services (батчами), Filament показывает прогресс, а не подвешивает запрос.
  • Перенос услуги в категорию с занятым slugunique(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 запрещены

Внутренняя база знаний студии